From 059ff9c6b4c0a682bc3cc1c85f919c082e687b2c Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Fri, 10 Apr 2026 20:48:46 +0000
Subject: [PATCH 01/57] fix: update Go version from 1.26.1 to 1.26.2 in
Dockerfile and documentation for security improvements
---
Dockerfile | 6 +++---
docs/development/go_version_upgrades.md | 4 ++--
docs/plans/current_spec.md | 4 ++--
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 3430c9ec2..19efb9359 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -160,7 +160,7 @@ RUN set -eux; \
# Note: xx-go install puts binaries in /go/bin/TARGETOS_TARGETARCH/dlv if cross-compiling.
# We find it and move it to /go/bin/dlv so it's in a consistent location for the next stage.
# renovate: datasource=go depName=github.com/go-delve/delve
-ARG DLV_VERSION=1.26.1
+ARG DLV_VERSION=1.26.2
# hadolint ignore=DL3059,DL4006
RUN CGO_ENABLED=0 xx-go install github.com/go-delve/delve/cmd/dlv@v${DLV_VERSION} && \
DLV_PATH=$(find /go/bin -name dlv -type f | head -n 1) && \
@@ -345,7 +345,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
rm -rf /tmp/buildenv_* /tmp/caddy-initial'
# ---- CrowdSec Builder ----
-# Build CrowdSec from source to ensure we use Go 1.26.1+ and avoid stdlib vulnerabilities
+# Build CrowdSec from source to ensure we use Go 1.26.2+ and avoid stdlib vulnerabilities
# (CVE-2025-58183, CVE-2025-58186, CVE-2025-58187, CVE-2025-61729)
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS crowdsec-builder
COPY --from=xx / /
@@ -516,7 +516,7 @@ COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy
# Allow non-root to bind privileged ports (80/443) securely
RUN setcap 'cap_net_bind_service=+ep' /usr/bin/caddy
-# Copy CrowdSec binaries from the crowdsec-builder stage (built with Go 1.26.1+)
+# Copy CrowdSec binaries from the crowdsec-builder stage (built with Go 1.26.2+)
# This ensures we don't have stdlib vulnerabilities from older Go versions
COPY --from=crowdsec-builder /crowdsec-out/crowdsec /usr/local/bin/crowdsec
COPY --from=crowdsec-builder /crowdsec-out/cscli /usr/local/bin/cscli
diff --git a/docs/development/go_version_upgrades.md b/docs/development/go_version_upgrades.md
index 58a1da52b..09cc45972 100644
--- a/docs/development/go_version_upgrades.md
+++ b/docs/development/go_version_upgrades.md
@@ -251,13 +251,13 @@ Go releases **two major versions per year**:
- February (e.g., Go 1.26.0)
- August (e.g., Go 1.27.0)
-Plus occasional patch releases (e.g., Go 1.26.1) for security fixes.
+Plus occasional patch releases (e.g., Go 1.26.2) for security fixes.
**Bottom line:** Expect to run `./scripts/rebuild-go-tools.sh` 2-3 times per year.
### Do I need to rebuild tools for patch releases?
-**Usually no**, but it doesn't hurt. Patch releases (like 1.26.0 → 1.26.1) rarely break tool compatibility.
+**Usually no**, but it doesn't hurt. Patch releases (like 1.26.0 → 1.26.2) rarely break tool compatibility.
**Rebuild if:**
diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md
index c4284ceac..0e956d1d0 100644
--- a/docs/plans/current_spec.md
+++ b/docs/plans/current_spec.md
@@ -252,7 +252,7 @@ No UI/UX changes — this is a dependency-only update. Existing E2E tests valida
| Task | File(s) | Action |
|------|---------|--------|
-| 4.1 | `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Bump Go version 1.26.1 → 1.26.2 |
+| 4.1 | `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Bump Go version 1.26.2 → 1.26.2 |
### Phase 5: Validation
@@ -339,7 +339,7 @@ The `pgproto3/v2` module has **no patched release** — the fix exists only in `
| `backend/go.mod` | Dependency version bumps (grpc, otlptracehttp) |
| `backend/go.sum` | Auto-generated checksum updates |
| `Dockerfile` | Add `go get` patches in caddy-builder and crowdsec-builder stages |
-| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Go version 1.26.1 → 1.26.2 |
+| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Go version 1.26.2 → 1.26.2 |
**Dependencies**: None (standalone)
From a439e1d467b5c86b895c2aa3113d718453e2bff0 Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Fri, 10 Apr 2026 21:00:52 +0000
Subject: [PATCH 02/57] fix: add git to Dockerfile dependencies for improved
build capabilities
---
Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Dockerfile b/Dockerfile
index 19efb9359..cfacf2f7d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -131,7 +131,7 @@ SHELL ["/bin/ash", "-o", "pipefail", "-c"]
ARG TARGETPLATFORM
ARG TARGETARCH
# hadolint ignore=DL3018
-RUN apk add --no-cache clang lld
+RUN apk add --no-cache git clang lld
# hadolint ignore=DL3059
# hadolint ignore=DL3018
# Install musl (headers + runtime) and gcc for cross-compilation linker
From 8f7c10440c5ca36cc1f2218ce55eff41ba96b2e5 Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Fri, 10 Apr 2026 23:41:05 +0000
Subject: [PATCH 03/57] chore: align agent and instruction files with single-PR
commit-slicing model
- Rewrote commit slicing guidance in Management, Planning, and subagent
instruction files to enforce one-feature-one-PR with ordered logical commits
- Removed multi-PR branching logic from the execution workflow
- Prevents partial feature merges that cause user confusion on self-hosted tools
- All cross-references now use "Commit N" instead of "PR-N"
---
.github/agents/Management.agent.md | 16 +++++++---------
.github/agents/Planning.agent.md | 8 ++++----
.github/instructions/subagent.instructions.md | 18 +++++++++---------
3 files changed, 20 insertions(+), 22 deletions(-)
diff --git a/.github/agents/Management.agent.md b/.github/agents/Management.agent.md
index d01d687d6..fc38d8adf 100644
--- a/.github/agents/Management.agent.md
+++ b/.github/agents/Management.agent.md
@@ -43,7 +43,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
- **Identify Goal**: Understand the user's request.
- **STOP**: Do not look at the code. Do not run `list_dir`. No code is to be changed or implemented until there is a fundamentally sound plan of action that has been approved by the user.
- **Action**: Immediately call `Planning` subagent.
- - *Prompt*: "Research the necessary files for '{user_request}' and write a comprehensive plan detailing as many specifics as possible to `docs/plans/current_spec.md`. Be an artist with directions and discriptions. Include file names, function names, and component names wherever possible. Break the plan into phases based on the least amount of requests. Include a Commit Slicing Strategy section that decides whether to split work into multiple PRs and, when split, defines PR-1/PR-2/PR-3 scope, dependencies, and acceptance criteria. Review and suggest updaetes to `.gitignore`, `codecov.yml`, `.dockerignore`, and `Dockerfile` if necessary. Return only when the plan is complete."
+ - *Prompt*: "Research the necessary files for '{user_request}' and write a comprehensive plan detailing as many specifics as possible to `docs/plans/current_spec.md`. Be an artist with directions and discriptions. Include file names, function names, and component names wherever possible. Break the plan into phases based on the least amount of requests. Include a Commit Slicing Strategy section that organizes work into logical commits within a single PR — one feature = one PR, with ordered commits (Commit 1, Commit 2, …) each defining scope, files, dependencies, and validation gates. Review and suggest updaetes to `.gitignore`, `codecov.yml`, `.dockerignore`, and `Dockerfile` if necessary. Return only when the plan is complete."
- **Task Specifics**:
- If the task is to just run tests or audits, there is no need for a plan. Directly call `QA_Security` to perform the tests and write the report. If issues are found, return to `Planning` for a remediation plan and delegate the fixes to the corresponding subagents.
@@ -59,15 +59,13 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
- **Ask**: "Plan created. Shall I authorize the construction?"
4. **Phase 4: Execution (Waterfall)**:
- - **Single-PR or Multi-PR Decision**: Read the Commit Slicing Strategy in `docs/plans/current_spec.md`.
- - **If single PR**:
+ - **Read Commit Slicing Strategy**: Read the Commit Slicing Strategy in `docs/plans/current_spec.md` to understand the ordered commits.
+ - **Single PR, Multiple Commits**: All work ships as one PR. Each commit maps to a phase in the plan.
- **Backend**: Call `Backend_Dev` with the plan file.
- **Frontend**: Call `Frontend_Dev` with the plan file.
- - **If multi-PR**:
- - Execute in PR slices, one slice at a time, in dependency order.
- - Require each slice to pass review + QA gates before starting the next slice.
- - Keep every slice deployable and independently testable.
- - **MANDATORY**: Implementation agents must perform linting and type checks locally before declaring their slice "DONE". This is a critical step that must not be skipped to avoid broken commits and security issues.
+ - Execute commits in dependency order. Each commit must pass its validation gates before the next commit begins.
+ - The PR is merged only when all commits are complete and all DoD gates pass.
+ - **MANDATORY**: Implementation agents must perform linting and type checks locally before declaring their commit "DONE". This is a critical step that must not be skipped to avoid broken commits and security issues.
5. **Phase 5: Review**:
- **Supervisor**: Call `Supervisor` to review the implementation against the plan. Provide feedback and ensure alignment with best practices.
@@ -80,7 +78,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
- **Docs**: Call `Docs_Writer`.
- **Manual Testing**: create a new test plan in `docs/issues/*.md` for tracking manual testing focused on finding potential bugs of the implemented features.
- **Final Report**: Summarize the successful subagent runs.
- - **PR Roadmap**: If split mode was used, include a concise roadmap of completed and remaining PR slices.
+ - **Commit Roadmap**: Include a concise summary of completed and remaining commits within the PR.
**Mandatory Commit Message**: When you reach a stopping point, provide a copy and paste code block commit message at the END of the response on format laid out in `.github/instructions/commit-message.instructions.md`
- **STRICT RULES**:
diff --git a/.github/agents/Planning.agent.md b/.github/agents/Planning.agent.md
index 561e9fdfb..5e7d8ae9a 100644
--- a/.github/agents/Planning.agent.md
+++ b/.github/agents/Planning.agent.md
@@ -38,7 +38,7 @@ You are a PRINCIPAL ARCHITECT responsible for technical planning and system desi
- Specify database schema changes
- Document component interactions and data flow
- Identify potential risks and mitigation strategies
- - Determine PR sizing and whether to split the work into multiple PRs for safer and faster review
+ - Determine commit sizing and how to organize work into logical commits within a single PR for safer and faster review
3. **Documentation**:
- Write plan to `docs/plans/current_spec.md`
@@ -46,10 +46,10 @@ You are a PRINCIPAL ARCHITECT responsible for technical planning and system desi
- Break down into implementable tasks using examples, diagrams, and tables
- Estimate complexity for each component
- Add a **Commit Slicing Strategy** section with:
- - Decision: single PR or multiple PRs
+ - Decision: single PR with ordered logical commits (one feature = one PR)
- Trigger reasons (scope, risk, cross-domain changes, review size)
- - Ordered PR slices (`PR-1`, `PR-2`, ...), each with scope, files, dependencies, and validation gates
- - Rollback and contingency notes per slice
+ - Ordered commits (`Commit 1`, `Commit 2`, ...), each with scope, files, dependencies, and validation gates
+ - Rollback and contingency notes for the PR as a whole
4. **Handoff**:
- Once plan is approved, delegate to `Supervisor` agent for review.
diff --git a/.github/instructions/subagent.instructions.md b/.github/instructions/subagent.instructions.md
index b2c0f2370..ef0af637b 100644
--- a/.github/instructions/subagent.instructions.md
+++ b/.github/instructions/subagent.instructions.md
@@ -23,21 +23,21 @@ runSubagent({
- Validate: `plan_file` exists and contains a `Handoff Contract` JSON.
- Kickoff: call `Planning` to create the plan if not present.
-- Decide: check if work should be split into multiple PRs (size, risk, cross-domain impact).
+- Decide: check how to organize work into logical commits within a single PR (size, risk, cross-domain impact).
- Run: execute `Backend Dev` then `Frontend Dev` sequentially.
- Parallel: run `QA and Security`, `DevOps` and `Doc Writer` in parallel for CI / QA checks and documentation.
- Return: a JSON summary with `subagent_results`, `overall_status`, and aggregated artifacts.
2.1) Multi-Commit Slicing Protocol
-- If a task is large or high-risk, split into PR slices and execute in order.
-- Each slice must have:
+- All work for a single feature ships as one PR with ordered logical commits.
+- Each commit must have:
- Scope boundary (what is included/excluded)
- - Dependency on previous slices
- - Validation gates (tests/scans required for that slice)
- - Explicit rollback notes
-- Do not start the next slice until the current slice is complete and verified.
-- Keep each slice independently reviewable and deployable.
+ - Dependency on previous commits
+ - Validation gates (tests/scans required for that commit)
+ - Explicit rollback notes for the PR as a whole
+- Do not start the next commit until the current commit is complete and verified.
+- Keep each commit independently reviewable within the PR.
3) Return Contract that all subagents must return
@@ -55,7 +55,7 @@ runSubagent({
- On a subagent failure, the Management agent must capture `tests.output` and decide to retry (1 retry maximum), or request a revert/rollback.
- Clearly mark the `status` as `failed`, and include `errors` and `failing_tests` in the `summary`.
-- For multi-PR execution, mark failed slice as blocked and stop downstream slices until resolved.
+- For multi-commit execution, mark failed commit as blocked and stop downstream commits until resolved.
5) Example: Run a full Feature Implementation
From 9e82efd23a6c78026dfca8cd017e6fe4f3065172 Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Sat, 11 Apr 2026 00:11:25 +0000
Subject: [PATCH 04/57] fix: downgrade delve version from 1.26.2 to 1.26.1 for
compatibility
---
Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Dockerfile b/Dockerfile
index cfacf2f7d..7a31c7f47 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -160,7 +160,7 @@ RUN set -eux; \
# Note: xx-go install puts binaries in /go/bin/TARGETOS_TARGETARCH/dlv if cross-compiling.
# We find it and move it to /go/bin/dlv so it's in a consistent location for the next stage.
# renovate: datasource=go depName=github.com/go-delve/delve
-ARG DLV_VERSION=1.26.2
+ARG DLV_VERSION=1.26.1
# hadolint ignore=DL3059,DL4006
RUN CGO_ENABLED=0 xx-go install github.com/go-delve/delve/cmd/dlv@v${DLV_VERSION} && \
DLV_PATH=$(find /go/bin -name dlv -type f | head -n 1) && \
From 4b925418f249fd52ecdbd3d9f03aa181228a1860 Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Sat, 11 Apr 2026 07:17:45 +0000
Subject: [PATCH 05/57] feat: Add certificate validation service with parsing
and metadata extraction
- Implemented certificate parsing for PEM, DER, and PFX formats.
- Added functions to validate key matches and certificate chains.
- Introduced metadata extraction for certificates including common name, domains, and issuer organization.
- Created unit tests for all new functionalities to ensure reliability and correctness.
---
backend/go.mod | 1 +
backend/go.sum | 2 +
.../api/handlers/certificate_handler.go | 422 +++++-
.../certificate_handler_coverage_test.go | 16 +-
.../certificate_handler_security_test.go | 10 +-
.../api/handlers/certificate_handler_test.go | 38 +-
backend/internal/api/routes/routes.go | 39 +-
backend/internal/api/routes/routes_test.go | 2 +-
backend/internal/caddy/config.go | 44 +-
backend/internal/caddy/manager.go | 8 +-
.../internal/caddy/manager_additional_test.go | 11 +-
.../caddy/manager_patch_coverage_test.go | 6 +-
.../caddy/manager_ssl_provider_test.go | 5 +-
backend/internal/config/config.go | 15 +
backend/internal/models/ssl_certificate.go | 31 +-
.../network/internal_service_client_test.go | 1 +
backend/internal/network/safeclient_test.go | 3 +
.../internal/services/certificate_service.go | 742 +++++++++--
.../services/certificate_service_test.go | 148 ++-
.../services/certificate_validator.go | 521 ++++++++
.../services/certificate_validator_test.go | 388 ++++++
.../custom/rfc2136_provider_test.go | 1 +
.../archive/nightly-vuln-remediation-spec.md | 432 +++++++
docs/plans/current_spec.md | 1151 ++++++++++++-----
24 files changed, 3454 insertions(+), 583 deletions(-)
create mode 100644 backend/internal/services/certificate_validator.go
create mode 100644 backend/internal/services/certificate_validator_test.go
create mode 100644 docs/plans/archive/nightly-vuln-remediation-spec.md
diff --git a/backend/go.mod b/backend/go.mod
index 5c7ff0ef4..349628689 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -99,4 +99,5 @@ require (
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.2 // indirect
+ software.sslmate.com/src/go-pkcs12 v0.7.1 // indirect
)
diff --git a/backend/go.sum b/backend/go.sum
index 6aba58d7b..f385ec49e 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -269,3 +269,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8=
+software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go
index 658bd6a97..e5a1f1ddf 100644
--- a/backend/internal/api/handlers/certificate_handler.go
+++ b/backend/internal/api/handlers/certificate_handler.go
@@ -2,14 +2,18 @@ package handlers
import (
"fmt"
+ "io"
"net/http"
"strconv"
+ "strings"
"sync"
"time"
"github.com/gin-gonic/gin"
+ "gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/logger"
+ "github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
@@ -28,9 +32,10 @@ type CertificateHandler struct {
service *services.CertificateService
backupService BackupServiceInterface
notificationService *services.NotificationService
+ db *gorm.DB
// Rate limiting for notifications
notificationMu sync.Mutex
- lastNotificationTime map[uint]time.Time
+ lastNotificationTime map[string]time.Time
}
func NewCertificateHandler(service *services.CertificateService, backupService BackupServiceInterface, ns *services.NotificationService) *CertificateHandler {
@@ -38,10 +43,18 @@ func NewCertificateHandler(service *services.CertificateService, backupService B
service: service,
backupService: backupService,
notificationService: ns,
- lastNotificationTime: make(map[uint]time.Time),
+ lastNotificationTime: make(map[string]time.Time),
}
}
+// SetDB sets the database connection for user lookups (export re-auth).
+func (h *CertificateHandler) SetDB(db *gorm.DB) {
+ h.db = db
+}
+
+// maxFileSize is 1MB for certificate file uploads.
+const maxFileSize = 1 << 20
+
func (h *CertificateHandler) List(c *gin.Context) {
certs, err := h.service.ListCertificates()
if err != nil {
@@ -53,34 +66,41 @@ func (h *CertificateHandler) List(c *gin.Context) {
c.JSON(http.StatusOK, certs)
}
-type UploadCertificateRequest struct {
- Name string `form:"name" binding:"required"`
- Certificate string `form:"certificate"` // PEM content
- PrivateKey string `form:"private_key"` // PEM content
+func (h *CertificateHandler) Get(c *gin.Context) {
+ certUUID := c.Param("uuid")
+ if certUUID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"})
+ return
+ }
+
+ detail, err := h.service.GetCertificate(certUUID)
+ if err != nil {
+ if err == services.ErrCertNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
+ return
+ }
+ logger.Log().WithError(err).Error("failed to get certificate")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get certificate"})
+ return
+ }
+
+ c.JSON(http.StatusOK, detail)
}
func (h *CertificateHandler) Upload(c *gin.Context) {
- // Handle multipart form
name := c.PostForm("name")
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
- // Read files
+ // Read certificate file
certFile, err := c.FormFile("certificate_file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"})
return
}
- keyFile, err := c.FormFile("key_file")
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required"})
- return
- }
-
- // Open and read content
certSrc, err := certFile.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"})
@@ -92,35 +112,70 @@ func (h *CertificateHandler) Upload(c *gin.Context) {
}
}()
- keySrc, err := keyFile.Open()
+ certBytes, err := io.ReadAll(io.LimitReader(certSrc, maxFileSize))
if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
+ c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read certificate file"})
return
}
- defer func() {
- if errClose := keySrc.Close(); errClose != nil {
- logger.Log().WithError(errClose).Warn("failed to close key file")
+ certPEM := string(certBytes)
+
+ // Read private key file (optional for PFX)
+ var keyPEM string
+ keyFile, err := c.FormFile("key_file")
+ if err == nil {
+ keySrc, errOpen := keyFile.Open()
+ if errOpen != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
+ return
}
- }()
+ defer func() {
+ if errClose := keySrc.Close(); errClose != nil {
+ logger.Log().WithError(errClose).Warn("failed to close key file")
+ }
+ }()
- // Read to string
- // Limit size to avoid DoS (e.g. 1MB)
- certBytes := make([]byte, 1024*1024)
- n, _ := certSrc.Read(certBytes)
- certPEM := string(certBytes[:n])
+ keyBytes, errRead := io.ReadAll(io.LimitReader(keySrc, maxFileSize))
+ if errRead != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read key file"})
+ return
+ }
+ keyPEM = string(keyBytes)
+ } else if !strings.HasSuffix(strings.ToLower(certFile.Filename), ".pfx") &&
+ !strings.HasSuffix(strings.ToLower(certFile.Filename), ".p12") {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required for PEM certificates"})
+ return
+ }
- keyBytes := make([]byte, 1024*1024)
- n, _ = keySrc.Read(keyBytes)
- keyPEM := string(keyBytes[:n])
+ // Read chain file (optional)
+ var chainPEM string
+ chainFile, err := c.FormFile("chain_file")
+ if err == nil {
+ chainSrc, errOpen := chainFile.Open()
+ if errOpen != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open chain file"})
+ return
+ }
+ defer func() {
+ if errClose := chainSrc.Close(); errClose != nil {
+ logger.Log().WithError(errClose).Warn("failed to close chain file")
+ }
+ }()
- cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
+ chainBytes, errRead := io.ReadAll(io.LimitReader(chainSrc, maxFileSize))
+ if errRead != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read chain file"})
+ return
+ }
+ chainPEM = string(chainBytes)
+ }
+
+ cert, err := h.service.UploadCertificate(name, certPEM, keyPEM, chainPEM)
if err != nil {
logger.Log().WithError(err).Error("failed to upload certificate")
- c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload certificate"})
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- // Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(c.Request.Context(),
"cert",
@@ -137,24 +192,250 @@ func (h *CertificateHandler) Upload(c *gin.Context) {
c.JSON(http.StatusCreated, cert)
}
-func (h *CertificateHandler) Delete(c *gin.Context) {
- idStr := c.Param("id")
- id, err := strconv.ParseUint(idStr, 10, 32)
+type updateCertificateRequest struct {
+ Name string `json:"name" binding:"required"`
+}
+
+func (h *CertificateHandler) Update(c *gin.Context) {
+ certUUID := c.Param("uuid")
+ if certUUID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"})
+ return
+ }
+
+ var req updateCertificateRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
+ return
+ }
+
+ info, err := h.service.UpdateCertificate(certUUID, req.Name)
if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ if err == services.ErrCertNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
+ return
+ }
+ logger.Log().WithError(err).Error("failed to update certificate")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update certificate"})
+ return
+ }
+
+ c.JSON(http.StatusOK, info)
+}
+
+func (h *CertificateHandler) Validate(c *gin.Context) {
+ // Read certificate file
+ certFile, err := c.FormFile("certificate_file")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"})
return
}
- // Validate ID range
- if id == 0 {
+ certSrc, err := certFile.Open()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"})
+ return
+ }
+ defer func() {
+ if errClose := certSrc.Close(); errClose != nil {
+ logger.Log().WithError(errClose).Warn("failed to close certificate file")
+ }
+ }()
+
+ certBytes, err := io.ReadAll(io.LimitReader(certSrc, maxFileSize))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read certificate file"})
+ return
+ }
+
+ // Read optional key file
+ var keyPEM string
+ keyFile, err := c.FormFile("key_file")
+ if err == nil {
+ keySrc, errOpen := keyFile.Open()
+ if errOpen != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
+ return
+ }
+ defer func() {
+ if errClose := keySrc.Close(); errClose != nil {
+ logger.Log().WithError(errClose).Warn("failed to close key file")
+ }
+ }()
+
+ keyBytes, errRead := io.ReadAll(io.LimitReader(keySrc, maxFileSize))
+ if errRead != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read key file"})
+ return
+ }
+ keyPEM = string(keyBytes)
+ }
+
+ // Read optional chain file
+ var chainPEM string
+ chainFile, err := c.FormFile("chain_file")
+ if err == nil {
+ chainSrc, errOpen := chainFile.Open()
+ if errOpen != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open chain file"})
+ return
+ }
+ defer func() {
+ if errClose := chainSrc.Close(); errClose != nil {
+ logger.Log().WithError(errClose).Warn("failed to close chain file")
+ }
+ }()
+
+ chainBytes, errRead := io.ReadAll(io.LimitReader(chainSrc, maxFileSize))
+ if errRead != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read chain file"})
+ return
+ }
+ chainPEM = string(chainBytes)
+ }
+
+ result, err := h.service.ValidateCertificate(string(certBytes), keyPEM, chainPEM)
+ if err != nil {
+ logger.Log().WithError(err).Error("failed to validate certificate")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "validation failed"})
+ return
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+type exportCertificateRequest struct {
+ Format string `json:"format" binding:"required"`
+ IncludeKey bool `json:"include_key"`
+ PFXPassword string `json:"pfx_password"`
+ Password string `json:"password"`
+}
+
+func (h *CertificateHandler) Export(c *gin.Context) {
+ certUUID := c.Param("uuid")
+ if certUUID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"})
+ return
+ }
+
+ var req exportCertificateRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "format is required"})
+ return
+ }
+
+ // Re-authenticate when requesting private key
+ if req.IncludeKey {
+ if req.Password == "" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "password required to export private key"})
+ return
+ }
+
+ userVal, exists := c.Get("user")
+ if !exists || h.db == nil {
+ c.JSON(http.StatusForbidden, gin.H{"error": "authentication required"})
+ return
+ }
+
+ userMap, ok := userVal.(map[string]any)
+ if !ok {
+ c.JSON(http.StatusForbidden, gin.H{"error": "invalid session"})
+ return
+ }
+
+ userID, ok := userMap["id"]
+ if !ok {
+ c.JSON(http.StatusForbidden, gin.H{"error": "invalid session"})
+ return
+ }
+
+ var user models.User
+ if err := h.db.First(&user, userID).Error; err != nil {
+ c.JSON(http.StatusForbidden, gin.H{"error": "user not found"})
+ return
+ }
+
+ if !user.CheckPassword(req.Password) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "incorrect password"})
+ return
+ }
+ }
+
+ data, filename, err := h.service.ExportCertificate(certUUID, req.Format, req.IncludeKey)
+ if err != nil {
+ if err == services.ErrCertNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
+ return
+ }
+ logger.Log().WithError(err).Error("failed to export certificate")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export certificate"})
+ return
+ }
+
+ c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
+ c.Data(http.StatusOK, "application/octet-stream", data)
+}
+
+func (h *CertificateHandler) Delete(c *gin.Context) {
+ idStr := c.Param("uuid")
+
+ // Support both numeric ID (legacy) and UUID
+ if numID, err := strconv.ParseUint(idStr, 10, 32); err == nil && numID > 0 {
+ inUse, err := h.service.IsCertificateInUse(uint(numID))
+ if err != nil {
+ logger.Log().WithError(err).WithField("certificate_id", numID).Error("failed to check certificate usage")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"})
+ return
+ }
+ if inUse {
+ c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
+ return
+ }
+
+ if h.backupService != nil {
+ if availableSpace, err := h.backupService.GetAvailableSpace(); err != nil {
+ logger.Log().WithError(err).Warn("unable to check disk space, proceeding with backup")
+ } else if availableSpace < 100*1024*1024 {
+ logger.Log().WithField("available_bytes", availableSpace).Warn("low disk space, skipping backup")
+ c.JSON(http.StatusInsufficientStorage, gin.H{"error": "insufficient disk space for backup"})
+ return
+ }
+
+ if _, err := h.backupService.CreateBackup(); err != nil {
+ logger.Log().WithError(err).Error("failed to create backup before deletion")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup before deletion"})
+ return
+ }
+ }
+
+ if err := h.service.DeleteCertificateByID(uint(numID)); err != nil {
+ if err == services.ErrCertInUse {
+ c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
+ return
+ }
+ logger.Log().WithError(err).WithField("certificate_id", numID).Error("failed to delete certificate")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"})
+ return
+ }
+
+ h.sendDeleteNotification(c, fmt.Sprintf("%d", numID))
+ c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
+ return
+ }
+
+ // UUID path - value isn't numeric, validate it looks like a UUID
+ if idStr == "" || idStr == "0" || len(idStr) < 32 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
- // Check if certificate is in use before proceeding
- inUse, err := h.service.IsCertificateInUse(uint(id))
+ inUse, err := h.service.IsCertificateInUseByUUID(idStr)
if err != nil {
- logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to check certificate usage")
+ if err == services.ErrCertNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
+ return
+ }
+ logger.Log().WithError(err).WithField("certificate_uuid", idStr).Error("failed to check certificate usage")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"})
return
}
@@ -163,13 +444,10 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
return
}
- // Create backup before deletion
if h.backupService != nil {
- // Check disk space before backup (require at least 100MB free)
if availableSpace, err := h.backupService.GetAvailableSpace(); err != nil {
logger.Log().WithError(err).Warn("unable to check disk space, proceeding with backup")
} else if availableSpace < 100*1024*1024 {
- logger.Log().WithField("available_bytes", availableSpace).Warn("low disk space, skipping backup")
c.JSON(http.StatusInsufficientStorage, gin.H{"error": "insufficient disk space for backup"})
return
}
@@ -181,38 +459,46 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
}
}
- // Proceed with deletion
- if err := h.service.DeleteCertificate(uint(id)); err != nil {
+ if err := h.service.DeleteCertificate(idStr); err != nil {
if err == services.ErrCertInUse {
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
return
}
- logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to delete certificate")
+ if err == services.ErrCertNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
+ return
+ }
+ logger.Log().WithError(err).WithField("certificate_uuid", idStr).Error("failed to delete certificate")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"})
return
}
- // Send Notification with rate limiting (1 per cert per 10 seconds)
- if h.notificationService != nil {
- h.notificationMu.Lock()
- lastTime, exists := h.lastNotificationTime[uint(id)]
- if !exists || time.Since(lastTime) > 10*time.Second {
- h.lastNotificationTime[uint(id)] = time.Now()
- h.notificationMu.Unlock()
- h.notificationService.SendExternal(c.Request.Context(),
- "cert",
- "Certificate Deleted",
- fmt.Sprintf("Certificate ID %d deleted", id),
- map[string]any{
- "ID": id,
- "Action": "deleted",
- },
- )
- } else {
- h.notificationMu.Unlock()
- logger.Log().WithField("certificate_id", id).Debug("notification rate limited")
- }
+ h.sendDeleteNotification(c, idStr)
+ c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
+}
+
+func (h *CertificateHandler) sendDeleteNotification(c *gin.Context, certRef string) {
+ if h.notificationService == nil {
+ return
}
- c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
+ h.notificationMu.Lock()
+ lastTime, exists := h.lastNotificationTime[certRef]
+ if exists && time.Since(lastTime) < 10*time.Second {
+ h.notificationMu.Unlock()
+ logger.Log().WithField("certificate_ref", certRef).Debug("notification rate limited")
+ return
+ }
+ h.lastNotificationTime[certRef] = time.Now()
+ h.notificationMu.Unlock()
+
+ h.notificationService.SendExternal(c.Request.Context(),
+ "cert",
+ "Certificate Deleted",
+ fmt.Sprintf("Certificate %s deleted", certRef),
+ map[string]any{
+ "Ref": certRef,
+ "Action": "deleted",
+ },
+ )
}
diff --git a/backend/internal/api/handlers/certificate_handler_coverage_test.go b/backend/internal/api/handlers/certificate_handler_coverage_test.go
index acf70e3dd..146fd1584 100644
--- a/backend/internal/api/handlers/certificate_handler_coverage_test.go
+++ b/backend/internal/api/handlers/certificate_handler_coverage_test.go
@@ -18,7 +18,7 @@ func TestCertificateHandler_List_DBError(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
@@ -34,7 +34,7 @@ func TestCertificateHandler_Delete_InvalidID(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
@@ -50,7 +50,7 @@ func TestCertificateHandler_Delete_NotFound(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
@@ -70,7 +70,7 @@ func TestCertificateHandler_Delete_NoBackupService(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
// No backup service
h := NewCertificateHandler(svc, nil, nil)
@@ -95,7 +95,7 @@ func TestCertificateHandler_Delete_CheckUsageDBError(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
@@ -115,7 +115,7 @@ func TestCertificateHandler_List_WithCertificates(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
@@ -135,7 +135,7 @@ func TestCertificateHandler_Delete_ZeroID(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
@@ -169,7 +169,7 @@ func TestCertificateHandler_DBSetupOrdering(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
diff --git a/backend/internal/api/handlers/certificate_handler_security_test.go b/backend/internal/api/handlers/certificate_handler_security_test.go
index a118fa7ff..08a062ad1 100644
--- a/backend/internal/api/handlers/certificate_handler_security_test.go
+++ b/backend/internal/api/handlers/certificate_handler_security_test.go
@@ -30,7 +30,7 @@ func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) {
r.Use(func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
})
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
@@ -59,7 +59,7 @@ func TestCertificateHandler_List_RequiresAuth(t *testing.T) {
r.Use(func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
})
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
@@ -88,7 +88,7 @@ func TestCertificateHandler_Upload_RequiresAuth(t *testing.T) {
r.Use(func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
})
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -125,7 +125,7 @@ func TestCertificateHandler_Delete_DiskSpaceCheck(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
// Mock backup service that reports low disk space
mockBackup := &mockBackupService{
@@ -177,7 +177,7 @@ func TestCertificateHandler_Delete_NotificationRateLimiting(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
mockBackup := &mockBackupService{
createFunc: func() (string, error) {
diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go
index 7971bcbc6..f8b23797b 100644
--- a/backend/internal/api/handlers/certificate_handler_test.go
+++ b/backend/internal/api/handlers/certificate_handler_test.go
@@ -39,7 +39,7 @@ func setupCertTestRouter(t *testing.T, db *gorm.DB) *gin.Engine {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
return r
@@ -111,7 +111,7 @@ func TestDeleteCertificate_CreatesBackup(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
// Mock BackupService
backupCalled := false
@@ -164,7 +164,7 @@ func TestDeleteCertificate_BackupFailure(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
// Mock BackupService that fails
mockBackupService := &mockBackupService{
@@ -217,7 +217,7 @@ func TestDeleteCertificate_InUse_NoBackup(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
// Mock BackupService
backupCalled := false
@@ -295,7 +295,7 @@ func TestCertificateHandler_List(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
@@ -321,7 +321,7 @@ func TestCertificateHandler_Upload_MissingName(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -348,7 +348,7 @@ func TestCertificateHandler_Upload_MissingCertFile(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -378,7 +378,7 @@ func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -404,7 +404,7 @@ func TestCertificateHandler_Upload_MissingKeyFile_MultipartWithCert(t *testing.T
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -447,7 +447,7 @@ func TestCertificateHandler_Upload_Success(t *testing.T) {
// Create a mock CertificateService that returns a created certificate
// Create a temporary services.CertificateService with a temp dir and DB
tmpDir := t.TempDir()
- svc := services.NewCertificateService(tmpDir, db)
+ svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -519,7 +519,7 @@ func TestCertificateHandler_Upload_WithNotificationService(t *testing.T) {
r.Use(mockAuthMiddleware())
tmpDir := t.TempDir()
- svc := services.NewCertificateService(tmpDir, db)
+ svc := services.NewCertificateService(tmpDir, db, nil)
ns := services.NewNotificationService(db, nil)
h := NewCertificateHandler(svc, nil, ns)
r.POST("/api/certificates", h.Upload)
@@ -555,7 +555,7 @@ func TestDeleteCertificate_InvalidID(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
@@ -580,7 +580,7 @@ func TestDeleteCertificate_ZeroID(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
@@ -611,7 +611,7 @@ func TestDeleteCertificate_LowDiskSpace(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
// Mock BackupService with low disk space
mockBackupService := &mockBackupService{
@@ -659,7 +659,7 @@ func TestDeleteCertificate_DiskSpaceCheckError(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
// Mock BackupService with space check error but backup succeeds
mockBackupService := &mockBackupService{
@@ -717,7 +717,7 @@ func TestDeleteCertificate_ExpiredLetsEncrypt_NotInUse(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
mockBS := &mockBackupService{
createFunc: func() (string, error) {
@@ -775,7 +775,7 @@ func TestDeleteCertificate_ValidLetsEncrypt_NotInUse(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
mockBS := &mockBackupService{
createFunc: func() (string, error) {
@@ -820,7 +820,7 @@ func TestDeleteCertificate_UsageCheckError(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
@@ -857,7 +857,7 @@ func TestDeleteCertificate_NotificationRateLimit(t *testing.T) {
r := gin.New()
r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
+ svc := services.NewCertificateService("/tmp", db, nil)
ns := services.NewNotificationService(db, nil)
mockBackupService := &mockBackupService{
diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go
index dc6d09250..0cb00e988 100644
--- a/backend/internal/api/routes/routes.go
+++ b/backend/internal/api/routes/routes.go
@@ -152,6 +152,14 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
caddyClient := caddy.NewClient(cfg.CaddyAdminAPI)
caddyManager = caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security)
}
+
+ // Wire encryption service to Caddy manager for decrypting certificate private keys
+ if cfg.EncryptionKey != "" {
+ if svc, err := crypto.NewEncryptionService(cfg.EncryptionKey); err == nil {
+ caddyManager.SetEncryptionService(svc)
+ }
+ }
+
if cerb == nil {
cerb = cerberus.New(cfg.Security, db)
}
@@ -666,11 +674,38 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
// where ACME and certificates are stored (e.g. /data).
caddyDataDir := cfg.CaddyConfigDir + "/data"
logger.Log().WithField("caddy_data_dir", caddyDataDir).Info("Using Caddy data directory for certificates scan")
- certService := services.NewCertificateService(caddyDataDir, db)
+ var certEncSvc *crypto.EncryptionService
+ if cfg.EncryptionKey != "" {
+ svc, err := crypto.NewEncryptionService(cfg.EncryptionKey)
+ if err != nil {
+ logger.Log().WithError(err).Warn("Failed to initialize encryption service for certificate key storage")
+ } else {
+ certEncSvc = svc
+ }
+ }
+ certService := services.NewCertificateService(caddyDataDir, db, certEncSvc)
certHandler := handlers.NewCertificateHandler(certService, backupService, notificationService)
+ certHandler.SetDB(db)
+
+ // Migrate unencrypted private keys
+ if err := certService.MigratePrivateKeys(); err != nil {
+ logger.Log().WithError(err).Warn("Failed to migrate certificate private keys")
+ }
+
management.GET("/certificates", certHandler.List)
management.POST("/certificates", certHandler.Upload)
- management.DELETE("/certificates/:id", certHandler.Delete)
+ management.POST("/certificates/validate", certHandler.Validate)
+ management.GET("/certificates/:uuid", certHandler.Get)
+ management.PUT("/certificates/:uuid", certHandler.Update)
+ management.POST("/certificates/:uuid/export", certHandler.Export)
+ management.DELETE("/certificates/:uuid", certHandler.Delete)
+
+ // Start certificate expiry checker
+ warningDays := 30
+ if cfg.CertExpiryWarningDays > 0 {
+ warningDays = cfg.CertExpiryWarningDays
+ }
+ go certService.StartExpiryChecker(context.Background(), notificationService, warningDays)
// Proxy Hosts & Remote Servers
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService)
diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go
index 9f8f8dfc5..28a3ed16b 100644
--- a/backend/internal/api/routes/routes_test.go
+++ b/backend/internal/api/routes/routes_test.go
@@ -773,7 +773,7 @@ func TestRegister_CertificateRoutes(t *testing.T) {
// Certificate routes
assert.True(t, routeMap["/api/v1/certificates"])
- assert.True(t, routeMap["/api/v1/certificates/:id"])
+ assert.True(t, routeMap["/api/v1/certificates/:uuid"])
}
// TestRegister_NilHandlers verifies registration behavior with minimal/nil components
diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go
index 63a8b8930..75863f70e 100644
--- a/backend/internal/caddy/config.go
+++ b/backend/internal/caddy/config.go
@@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
+ "github.com/Wikid82/charon/backend/internal/crypto"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
@@ -15,7 +16,7 @@ import (
// GenerateConfig creates a Caddy JSON configuration from proxy hosts.
// This is the core transformation layer from our database model to Caddy config.
-func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
+func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
// Define log file paths for Caddy access logs.
// When CrowdSec is enabled, we use /var/log/caddy/access.log which is the standard
// location that CrowdSec's acquis.yaml is configured to monitor.
@@ -427,16 +428,47 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
}
if len(customCerts) > 0 {
+ // Resolve encryption service from variadic parameter
+ var certEncSvc *crypto.EncryptionService
+ if len(encSvc) > 0 && encSvc[0] != nil {
+ certEncSvc = encSvc[0]
+ }
+
var loadPEM []LoadPEMConfig
for _, cert := range customCerts {
- // Validate that custom cert has both certificate and key
- if cert.Certificate == "" || cert.PrivateKey == "" {
- logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing certificate or key, skipping")
+ // Determine private key: prefer encrypted, fall back to plaintext for migration
+ var keyPEM string
+ if cert.PrivateKeyEncrypted != "" && certEncSvc != nil {
+ decrypted, err := certEncSvc.Decrypt(cert.PrivateKeyEncrypted)
+ if err != nil {
+ logger.Log().WithField("cert", cert.Name).WithError(err).Warn("Failed to decrypt private key, skipping certificate")
+ continue
+ }
+ keyPEM = string(decrypted)
+ } else if cert.PrivateKeyEncrypted != "" {
+ logger.Log().WithField("cert", cert.Name).Warn("Certificate has encrypted key but no encryption service available, skipping")
+ continue
+ } else if cert.PrivateKey != "" {
+ keyPEM = cert.PrivateKey
+ } else {
+ logger.Log().WithField("cert", cert.Name).Warn("Custom certificate has no encrypted key, skipping")
continue
}
+
+ if cert.Certificate == "" {
+ logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing certificate PEM, skipping")
+ continue
+ }
+
+ // Concatenate chain with leaf certificate
+ fullCert := cert.Certificate
+ if cert.CertificateChain != "" {
+ fullCert = fullCert + "\n" + cert.CertificateChain
+ }
+
loadPEM = append(loadPEM, LoadPEMConfig{
- Certificate: cert.Certificate,
- Key: cert.PrivateKey,
+ Certificate: fullCert,
+ Key: keyPEM,
Tags: []string{cert.UUID},
})
}
diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go
index c2cfab9d3..4b6c6af12 100644
--- a/backend/internal/caddy/manager.go
+++ b/backend/internal/caddy/manager.go
@@ -73,6 +73,7 @@ type Manager struct {
frontendDir string
acmeStaging bool
securityCfg config.SecurityConfig
+ encSvc *crypto.EncryptionService
}
// NewManager creates a configuration manager.
@@ -87,6 +88,11 @@ func NewManager(client CaddyClient, db *gorm.DB, configDir, frontendDir string,
}
}
+// SetEncryptionService configures the encryption service for decrypting private keys in Caddy config generation.
+func (m *Manager) SetEncryptionService(svc *crypto.EncryptionService) {
+ m.encSvc = svc
+}
+
// ApplyConfig generates configuration from database, validates it, applies to Caddy with rollback on failure.
func (m *Manager) ApplyConfig(ctx context.Context) error {
// Fetch all proxy hosts from database
@@ -418,7 +424,7 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
}
}
- generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs)
+ generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs, m.encSvc)
if err != nil {
return fmt.Errorf("generate config: %w", err)
}
diff --git a/backend/internal/caddy/manager_additional_test.go b/backend/internal/caddy/manager_additional_test.go
index 4dd488462..41881de73 100644
--- a/backend/internal/caddy/manager_additional_test.go
+++ b/backend/internal/caddy/manager_additional_test.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/crypto"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
@@ -422,7 +423,7 @@ func TestManager_ApplyConfig_GenerateConfigFails(t *testing.T) {
// stub generateConfigFunc to always return error
orig := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
return nil, fmt.Errorf("generate fail")
}
defer func() { generateConfigFunc = orig }()
@@ -600,7 +601,7 @@ func TestManager_ApplyConfig_PassesAdminWhitelistToGenerateConfig(t *testing.T)
// Stub generateConfigFunc to capture adminWhitelist
var capturedAdmin string
orig := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
capturedAdmin = adminWhitelist
// return minimal config
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
@@ -651,7 +652,7 @@ func TestManager_ApplyConfig_PassesRuleSetsToGenerateConfig(t *testing.T) {
var capturedRules []models.SecurityRuleSet
orig := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
capturedRules = rulesets
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
}
@@ -706,7 +707,7 @@ func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) {
var capturedWafEnabled bool
var capturedRulesets []models.SecurityRuleSet
origGen := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
capturedWafEnabled = wafEnabled
capturedRulesets = rulesets
return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs)
@@ -811,7 +812,7 @@ func TestManager_ApplyConfig_RulesetWriteFileFailure(t *testing.T) {
// Capture rulesetPaths from GenerateConfig
var capturedPaths map[string]string
origGen := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
capturedPaths = rulesetPaths
return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs)
}
diff --git a/backend/internal/caddy/manager_patch_coverage_test.go b/backend/internal/caddy/manager_patch_coverage_test.go
index 5939b3222..370ecec5e 100644
--- a/backend/internal/caddy/manager_patch_coverage_test.go
+++ b/backend/internal/caddy/manager_patch_coverage_test.go
@@ -57,7 +57,7 @@ func TestManagerApplyConfig_DNSProviders_NoKey_SkipsDecryption(t *testing.T) {
generateConfigFunc = origGen
validateConfigFunc = origVal
}()
- generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
+ generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) {
capturedLen = len(dnsProviderConfigs)
return &Config{}, nil
}
@@ -111,7 +111,7 @@ func TestManagerApplyConfig_DNSProviders_UsesFallbackEnvKeys(t *testing.T) {
generateConfigFunc = origGen
validateConfigFunc = origVal
}()
- generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
+ generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) {
captured = append([]DNSProviderConfig(nil), dnsProviderConfigs...)
return &Config{}, nil
}
@@ -175,7 +175,7 @@ func TestManagerApplyConfig_DNSProviders_SkipsDecryptOrJSONFailures(t *testing.T
generateConfigFunc = origGen
validateConfigFunc = origVal
}()
- generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
+ generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) {
captured = append([]DNSProviderConfig(nil), dnsProviderConfigs...)
return &Config{}, nil
}
diff --git a/backend/internal/caddy/manager_ssl_provider_test.go b/backend/internal/caddy/manager_ssl_provider_test.go
index 39f8b8a98..f5c5072d0 100644
--- a/backend/internal/caddy/manager_ssl_provider_test.go
+++ b/backend/internal/caddy/manager_ssl_provider_test.go
@@ -9,6 +9,7 @@ import (
"testing"
"github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/crypto"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -17,8 +18,8 @@ import (
)
// mockGenerateConfigFunc creates a mock config generator that captures parameters
-func mockGenerateConfigFunc(capturedProvider *string, capturedStaging *bool) func([]models.ProxyHost, string, string, string, string, bool, bool, bool, bool, bool, string, []models.SecurityRuleSet, map[string]string, []models.SecurityDecision, *models.SecurityConfig, []DNSProviderConfig) (*Config, error) {
- return func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
+func mockGenerateConfigFunc(capturedProvider *string, capturedStaging *bool) func([]models.ProxyHost, string, string, string, string, bool, bool, bool, bool, bool, string, []models.SecurityRuleSet, map[string]string, []models.SecurityDecision, *models.SecurityConfig, []DNSProviderConfig, ...*crypto.EncryptionService) (*Config, error) {
+ return func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
*capturedProvider = sslProvider
*capturedStaging = acmeStaging
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index a21db711e..bf1634df5 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -33,6 +33,7 @@ type Config struct {
CaddyLogDir string
CrowdSecLogDir string
Debug bool
+ CertExpiryWarningDays int
Security SecurityConfig
Emergency EmergencyConfig
}
@@ -109,6 +110,20 @@ func Load() (Config, error) {
Debug: getEnvAny("false", "CHARON_DEBUG", "CPM_DEBUG") == "true",
}
+ // Certificate expiry warning days
+ if days := getEnvAny("30", "CHARON_CERT_EXPIRY_WARNING_DAYS"); days != "" {
+ if n, err := strconv.Atoi(days); err == nil && n > 0 {
+ cfg.CertExpiryWarningDays = n
+ }
+ }
+
+ // Certificate expiry warning days
+ if days := getEnvAny("30", "CHARON_CERT_EXPIRY_WARNING_DAYS"); days != "" {
+ if n, err := strconv.Atoi(days); err == nil && n > 0 {
+ cfg.CertExpiryWarningDays = n
+ }
+ }
+
// Set JWTSecret using os.Getenv directly so no string literal flows into the
// field — prevents CodeQL go/parse-jwt-with-hardcoded-key taint from any fallback.
cfg.JWTSecret = os.Getenv("CHARON_JWT_SECRET")
diff --git a/backend/internal/models/ssl_certificate.go b/backend/internal/models/ssl_certificate.go
index 8734a7896..058786341 100644
--- a/backend/internal/models/ssl_certificate.go
+++ b/backend/internal/models/ssl_certificate.go
@@ -7,15 +7,24 @@ import (
// SSLCertificate represents TLS certificates managed by Charon.
// Can be Let's Encrypt auto-generated or custom uploaded certs.
type SSLCertificate struct {
- ID uint `json:"-" gorm:"primaryKey"`
- UUID string `json:"uuid" gorm:"uniqueIndex"`
- Name string `json:"name" gorm:"index"`
- Provider string `json:"provider" gorm:"index"` // "letsencrypt", "letsencrypt-staging", "custom"
- Domains string `json:"domains" gorm:"index"` // comma-separated list of domains
- Certificate string `json:"certificate" gorm:"type:text"` // PEM-encoded certificate
- PrivateKey string `json:"private_key" gorm:"type:text"` // PEM-encoded private key
- ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"`
- AutoRenew bool `json:"auto_renew" gorm:"default:false"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `json:"-" gorm:"primaryKey"`
+ UUID string `json:"uuid" gorm:"uniqueIndex"`
+ Name string `json:"name" gorm:"index"`
+ Provider string `json:"provider" gorm:"index"`
+ Domains string `json:"domains" gorm:"index"`
+ CommonName string `json:"common_name"`
+ Certificate string `json:"-" gorm:"type:text"`
+ CertificateChain string `json:"-" gorm:"type:text"`
+ PrivateKeyEncrypted string `json:"-" gorm:"column:private_key_enc;type:text"`
+ PrivateKey string `json:"-" gorm:"-"`
+ KeyVersion int `json:"-" gorm:"default:1"`
+ Fingerprint string `json:"fingerprint"`
+ SerialNumber string `json:"serial_number"`
+ IssuerOrg string `json:"issuer_org"`
+ KeyType string `json:"key_type"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"`
+ NotBefore *time.Time `json:"not_before,omitempty"`
+ AutoRenew bool `json:"auto_renew" gorm:"default:false"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
diff --git a/backend/internal/network/internal_service_client_test.go b/backend/internal/network/internal_service_client_test.go
index 2f48e5ab5..5ebdc498a 100644
--- a/backend/internal/network/internal_service_client_test.go
+++ b/backend/internal/network/internal_service_client_test.go
@@ -27,6 +27,7 @@ func TestNewInternalServiceHTTPClient(t *testing.T) {
client := NewInternalServiceHTTPClient(tt.timeout)
if client == nil {
t.Fatal("NewInternalServiceHTTPClient() returned nil")
+ return
}
if client.Timeout != tt.timeout {
t.Errorf("expected timeout %v, got %v", tt.timeout, client.Timeout)
diff --git a/backend/internal/network/safeclient_test.go b/backend/internal/network/safeclient_test.go
index 1216f2e2c..c9a62b19e 100644
--- a/backend/internal/network/safeclient_test.go
+++ b/backend/internal/network/safeclient_test.go
@@ -179,6 +179,7 @@ func TestNewSafeHTTPClient_DefaultOptions(t *testing.T) {
client := NewSafeHTTPClient()
if client == nil {
t.Fatal("NewSafeHTTPClient() returned nil")
+ return
}
if client.Timeout != 10*time.Second {
t.Errorf("expected default timeout of 10s, got %v", client.Timeout)
@@ -190,6 +191,7 @@ func TestNewSafeHTTPClient_WithTimeout(t *testing.T) {
client := NewSafeHTTPClient(WithTimeout(10 * time.Second))
if client == nil {
t.Fatal("NewSafeHTTPClient() returned nil")
+ return
}
if client.Timeout != 10*time.Second {
t.Errorf("expected timeout of 10s, got %v", client.Timeout)
@@ -848,6 +850,7 @@ func TestClientOptions_AllFunctionalOptions(t *testing.T) {
if client == nil {
t.Fatal("NewSafeHTTPClient() returned nil with all options")
+ return
}
if client.Timeout != 15*time.Second {
t.Errorf("expected timeout of 15s, got %v", client.Timeout)
diff --git a/backend/internal/services/certificate_service.go b/backend/internal/services/certificate_service.go
index f6806d8a3..b96f1c289 100644
--- a/backend/internal/services/certificate_service.go
+++ b/backend/internal/services/certificate_service.go
@@ -1,15 +1,19 @@
package services
import (
+ "context"
+ crand "crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
+ "math/big"
"os"
"path/filepath"
"strings"
"sync"
"time"
+ "github.com/Wikid82/charon/backend/internal/crypto"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/util"
@@ -22,22 +26,73 @@ import (
// ErrCertInUse is returned when a certificate is linked to one or more proxy hosts.
var ErrCertInUse = fmt.Errorf("certificate is in use by one or more proxy hosts")
-// CertificateInfo represents parsed certificate details.
+// ErrCertNotFound is returned when a certificate cannot be found by UUID.
+var ErrCertNotFound = fmt.Errorf("certificate not found")
+
+// CertificateInfo represents parsed certificate details for list responses.
type CertificateInfo struct {
- ID uint `json:"id,omitempty"`
- UUID string `json:"uuid,omitempty"`
- Name string `json:"name,omitempty"`
- Domain string `json:"domain"`
+ UUID string `json:"uuid"`
+ Name string `json:"name,omitempty"`
+ CommonName string `json:"common_name,omitempty"`
+ Domains string `json:"domains"`
+ Issuer string `json:"issuer"`
+ IssuerOrg string `json:"issuer_org,omitempty"`
+ Fingerprint string `json:"fingerprint,omitempty"`
+ SerialNumber string `json:"serial_number,omitempty"`
+ KeyType string `json:"key_type,omitempty"`
+ ExpiresAt time.Time `json:"expires_at"`
+ NotBefore time.Time `json:"not_before,omitempty"`
+ Status string `json:"status"`
+ Provider string `json:"provider"`
+ ChainDepth int `json:"chain_depth,omitempty"`
+ HasKey bool `json:"has_key"`
+ InUse bool `json:"in_use"`
+}
+
+// AssignedHostInfo represents a proxy host assigned to a certificate.
+type AssignedHostInfo struct {
+ UUID string `json:"uuid"`
+ Name string `json:"name"`
+ DomainNames string `json:"domain_names"`
+}
+
+// ChainEntry represents a single certificate in the chain.
+type ChainEntry struct {
+ Subject string `json:"subject"`
Issuer string `json:"issuer"`
ExpiresAt time.Time `json:"expires_at"`
- Status string `json:"status"` // "valid", "expiring", "expired", "untrusted"
- Provider string `json:"provider"` // "letsencrypt", "letsencrypt-staging", "custom"
+}
+
+// CertificateDetail contains full certificate metadata for detail responses.
+type CertificateDetail struct {
+ UUID string `json:"uuid"`
+ Name string `json:"name,omitempty"`
+ CommonName string `json:"common_name,omitempty"`
+ Domains string `json:"domains"`
+ Issuer string `json:"issuer"`
+ IssuerOrg string `json:"issuer_org,omitempty"`
+ Fingerprint string `json:"fingerprint,omitempty"`
+ SerialNumber string `json:"serial_number,omitempty"`
+ KeyType string `json:"key_type,omitempty"`
+ ExpiresAt time.Time `json:"expires_at"`
+ NotBefore time.Time `json:"not_before,omitempty"`
+ Status string `json:"status"`
+ Provider string `json:"provider"`
+ ChainDepth int `json:"chain_depth,omitempty"`
+ HasKey bool `json:"has_key"`
+ InUse bool `json:"in_use"`
+ AssignedHosts []AssignedHostInfo `json:"assigned_hosts"`
+ Chain []ChainEntry `json:"chain"`
+ AutoRenew bool `json:"auto_renew"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
// CertificateService manages certificate retrieval and parsing.
type CertificateService struct {
dataDir string
db *gorm.DB
+ encSvc *crypto.EncryptionService
cache []CertificateInfo
cacheMu sync.RWMutex
lastScan time.Time
@@ -46,11 +101,12 @@ type CertificateService struct {
}
// NewCertificateService creates a new certificate service.
-func NewCertificateService(dataDir string, db *gorm.DB) *CertificateService {
+func NewCertificateService(dataDir string, db *gorm.DB, encSvc *crypto.EncryptionService) *CertificateService {
svc := &CertificateService{
dataDir: dataDir,
db: db,
- scanTTL: 5 * time.Minute, // Only rescan disk every 5 minutes
+ encSvc: encSvc,
+ scanTTL: 5 * time.Minute,
}
return svc
}
@@ -224,15 +280,18 @@ func (s *CertificateService) refreshCacheFromDB() error {
return fmt.Errorf("failed to fetch certs from DB: %w", err)
}
- // Build a map of domain -> proxy host name for quick lookup
+ // Build a set of certificate IDs that are in use
+ certInUse := make(map[uint]bool)
var proxyHosts []models.ProxyHost
s.db.Find(&proxyHosts)
domainToName := make(map[string]string)
for _, ph := range proxyHosts {
+ if ph.CertificateID != nil {
+ certInUse[*ph.CertificateID] = true
+ }
if ph.Name == "" {
continue
}
- // Handle comma-separated domains
domains := strings.Split(ph.DomainNames, ",")
for _, d := range domains {
d = strings.TrimSpace(strings.ToLower(d))
@@ -244,27 +303,20 @@ func (s *CertificateService) refreshCacheFromDB() error {
certs := make([]CertificateInfo, 0, len(dbCerts))
for _, c := range dbCerts {
- status := "valid"
-
- // Staging certificates are untrusted by browsers
- if strings.Contains(c.Provider, "staging") {
- status = "untrusted"
- } else if c.ExpiresAt != nil {
- if time.Now().After(*c.ExpiresAt) {
- status = "expired"
- } else if time.Now().AddDate(0, 0, 30).After(*c.ExpiresAt) {
- status = "expiring"
- }
- }
+ status := certStatus(c)
expires := time.Time{}
if c.ExpiresAt != nil {
expires = *c.ExpiresAt
}
+ notBefore := time.Time{}
+ if c.NotBefore != nil {
+ notBefore = *c.NotBefore
+ }
+
// Try to get name from proxy host, fall back to cert name or domain
name := c.Name
- // Check all domains in the cert against proxy hosts
certDomains := strings.Split(c.Domains, ",")
for _, d := range certDomains {
d = strings.TrimSpace(strings.ToLower(d))
@@ -274,15 +326,36 @@ func (s *CertificateService) refreshCacheFromDB() error {
}
}
+ chainDepth := 0
+ if c.CertificateChain != "" {
+ rest := []byte(c.CertificateChain)
+ for {
+ var block *pem.Block
+ block, rest = pem.Decode(rest)
+ if block == nil {
+ break
+ }
+ chainDepth++
+ }
+ }
+
certs = append(certs, CertificateInfo{
- ID: c.ID,
- UUID: c.UUID,
- Name: name,
- Domain: c.Domains,
- Issuer: c.Provider,
- ExpiresAt: expires,
- Status: status,
- Provider: c.Provider,
+ UUID: c.UUID,
+ Name: name,
+ CommonName: c.CommonName,
+ Domains: c.Domains,
+ Issuer: c.Provider,
+ IssuerOrg: c.IssuerOrg,
+ Fingerprint: c.Fingerprint,
+ SerialNumber: c.SerialNumber,
+ KeyType: c.KeyType,
+ ExpiresAt: expires,
+ NotBefore: notBefore,
+ Status: status,
+ Provider: c.Provider,
+ ChainDepth: chainDepth,
+ HasKey: c.PrivateKeyEncrypted != "",
+ InUse: certInUse[c.ID],
})
}
@@ -290,6 +363,21 @@ func (s *CertificateService) refreshCacheFromDB() error {
return nil
}
+func certStatus(c models.SSLCertificate) string {
+ if strings.Contains(c.Provider, "staging") {
+ return "untrusted"
+ }
+ if c.ExpiresAt != nil {
+ if time.Now().After(*c.ExpiresAt) {
+ return "expired"
+ }
+ if time.Now().AddDate(0, 0, 30).After(*c.ExpiresAt) {
+ return "expiring"
+ }
+ }
+ return "valid"
+}
+
// ListCertificates returns cached certificate info.
// Fast path: returns from cache if available.
// Triggers background rescan if cache is stale.
@@ -342,45 +430,205 @@ func (s *CertificateService) InvalidateCache() {
s.cacheMu.Unlock()
}
-// UploadCertificate saves a new custom certificate.
-func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM string) (*models.SSLCertificate, error) {
- // Validate PEM
- block, _ := pem.Decode([]byte(certPEM))
- if block == nil {
- return nil, fmt.Errorf("invalid certificate PEM")
+// UploadCertificate saves a new custom certificate with full validation and encryption.
+func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM, chainPEM string) (*CertificateInfo, error) {
+ parsed, err := ParseCertificateInput([]byte(certPEM), []byte(keyPEM), []byte(chainPEM), "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse certificate input: %w", err)
}
- cert, err := x509.ParseCertificate(block.Bytes)
- if err != nil {
- return nil, fmt.Errorf("failed to parse certificate: %w", err)
+ // Validate key matches certificate if key is provided
+ if parsed.PrivateKey != nil {
+ if err := ValidateKeyMatch(parsed.Leaf, parsed.PrivateKey); err != nil {
+ return nil, fmt.Errorf("key validation failed: %w", err)
+ }
}
- // Create DB entry
+ // Extract metadata
+ meta := ExtractCertificateMetadata(parsed.Leaf)
+
+ domains := meta.CommonName
+ if len(parsed.Leaf.DNSNames) > 0 {
+ domains = strings.Join(parsed.Leaf.DNSNames, ",")
+ }
+
+ notAfter := parsed.Leaf.NotAfter
+ notBefore := parsed.Leaf.NotBefore
+
sslCert := &models.SSLCertificate{
- UUID: uuid.New().String(),
- Name: name,
- Provider: "custom",
- Domains: cert.Subject.CommonName, // Or SANs
- Certificate: certPEM,
- PrivateKey: keyPEM,
- ExpiresAt: &cert.NotAfter,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
+ UUID: uuid.New().String(),
+ Name: name,
+ Provider: "custom",
+ Domains: domains,
+ CommonName: meta.CommonName,
+ Certificate: parsed.CertPEM,
+ CertificateChain: parsed.ChainPEM,
+ Fingerprint: meta.Fingerprint,
+ SerialNumber: meta.SerialNumber,
+ IssuerOrg: meta.IssuerOrg,
+ KeyType: meta.KeyType,
+ ExpiresAt: ¬After,
+ NotBefore: ¬Before,
+ KeyVersion: 1,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
}
- // Handle SANs if present
- if len(cert.DNSNames) > 0 {
- sslCert.Domains = strings.Join(cert.DNSNames, ",")
+ // Encrypt private key at rest
+ if parsed.KeyPEM != "" && s.encSvc != nil {
+ encrypted, err := s.encSvc.Encrypt([]byte(parsed.KeyPEM))
+ if err != nil {
+ return nil, fmt.Errorf("failed to encrypt private key: %w", err)
+ }
+ sslCert.PrivateKeyEncrypted = encrypted
}
if err := s.db.Create(sslCert).Error; err != nil {
- return nil, err
+ return nil, fmt.Errorf("failed to save certificate: %w", err)
}
- // Invalidate cache so the new cert appears immediately
s.InvalidateCache()
- return sslCert, nil
+ chainDepth := len(parsed.Intermediates)
+
+ info := &CertificateInfo{
+ UUID: sslCert.UUID,
+ Name: sslCert.Name,
+ CommonName: sslCert.CommonName,
+ Domains: sslCert.Domains,
+ Issuer: sslCert.Provider,
+ IssuerOrg: sslCert.IssuerOrg,
+ Fingerprint: sslCert.Fingerprint,
+ SerialNumber: sslCert.SerialNumber,
+ KeyType: sslCert.KeyType,
+ ExpiresAt: notAfter,
+ NotBefore: notBefore,
+ Status: certStatus(*sslCert),
+ Provider: sslCert.Provider,
+ ChainDepth: chainDepth,
+ HasKey: sslCert.PrivateKeyEncrypted != "",
+ InUse: false,
+ }
+
+ return info, nil
+}
+
+// GetCertificate returns full certificate detail by UUID.
+func (s *CertificateService) GetCertificate(certUUID string) (*CertificateDetail, error) {
+ var cert models.SSLCertificate
+ if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, ErrCertNotFound
+ }
+ return nil, fmt.Errorf("failed to fetch certificate: %w", err)
+ }
+
+ // Get assigned hosts
+ var hosts []models.ProxyHost
+ s.db.Where("certificate_id = ?", cert.ID).Find(&hosts)
+ assignedHosts := make([]AssignedHostInfo, 0, len(hosts))
+ for _, h := range hosts {
+ assignedHosts = append(assignedHosts, AssignedHostInfo{
+ UUID: h.UUID,
+ Name: h.Name,
+ DomainNames: h.DomainNames,
+ })
+ }
+
+ // Parse chain entries
+ chain := buildChainEntries(cert.Certificate, cert.CertificateChain)
+
+ expires := time.Time{}
+ if cert.ExpiresAt != nil {
+ expires = *cert.ExpiresAt
+ }
+ notBefore := time.Time{}
+ if cert.NotBefore != nil {
+ notBefore = *cert.NotBefore
+ }
+
+ detail := &CertificateDetail{
+ UUID: cert.UUID,
+ Name: cert.Name,
+ CommonName: cert.CommonName,
+ Domains: cert.Domains,
+ Issuer: cert.Provider,
+ IssuerOrg: cert.IssuerOrg,
+ Fingerprint: cert.Fingerprint,
+ SerialNumber: cert.SerialNumber,
+ KeyType: cert.KeyType,
+ ExpiresAt: expires,
+ NotBefore: notBefore,
+ Status: certStatus(cert),
+ Provider: cert.Provider,
+ ChainDepth: len(chain),
+ HasKey: cert.PrivateKeyEncrypted != "",
+ InUse: len(hosts) > 0,
+ AssignedHosts: assignedHosts,
+ Chain: chain,
+ AutoRenew: cert.AutoRenew,
+ CreatedAt: cert.CreatedAt,
+ UpdatedAt: cert.UpdatedAt,
+ }
+
+ return detail, nil
+}
+
+// ValidateCertificate validates certificate data without storing.
+func (s *CertificateService) ValidateCertificate(certPEM, keyPEM, chainPEM string) (*ValidationResult, error) {
+ result := &ValidationResult{
+ Warnings: []string{},
+ Errors: []string{},
+ }
+
+ parsed, err := ParseCertificateInput([]byte(certPEM), []byte(keyPEM), []byte(chainPEM), "")
+ if err != nil {
+ result.Errors = append(result.Errors, err.Error())
+ return result, nil
+ }
+
+ meta := ExtractCertificateMetadata(parsed.Leaf)
+ result.CommonName = meta.CommonName
+ result.Domains = meta.Domains
+ result.IssuerOrg = meta.IssuerOrg
+ result.ExpiresAt = meta.NotAfter
+ result.ChainDepth = len(parsed.Intermediates)
+
+ // Key match check
+ if parsed.PrivateKey != nil {
+ if err := ValidateKeyMatch(parsed.Leaf, parsed.PrivateKey); err != nil {
+ result.Errors = append(result.Errors, fmt.Sprintf("key mismatch: %s", err.Error()))
+ } else {
+ result.KeyMatch = true
+ }
+ }
+
+ // Chain validation (best-effort, warn on failure)
+ if len(parsed.Intermediates) > 0 {
+ if err := ValidateChain(parsed.Leaf, parsed.Intermediates); err != nil {
+ result.Warnings = append(result.Warnings, fmt.Sprintf("chain validation: %s", err.Error()))
+ } else {
+ result.ChainValid = true
+ }
+ } else {
+ // Try verifying with system roots
+ if err := ValidateChain(parsed.Leaf, nil); err != nil {
+ result.Warnings = append(result.Warnings, "certificate could not be verified against system roots")
+ } else {
+ result.ChainValid = true
+ }
+ }
+
+ // Expiry warnings
+ daysUntilExpiry := time.Until(parsed.Leaf.NotAfter).Hours() / 24
+ if daysUntilExpiry < 0 {
+ result.Warnings = append(result.Warnings, "Certificate has expired")
+ } else if daysUntilExpiry < 30 {
+ result.Warnings = append(result.Warnings, fmt.Sprintf("Certificate expires in %.0f days", daysUntilExpiry))
+ }
+
+ result.Valid = len(result.Errors) == 0
+ return result, nil
}
// IsCertificateInUse checks if a certificate is referenced by any proxy host.
@@ -392,10 +640,30 @@ func (s *CertificateService) IsCertificateInUse(id uint) (bool, error) {
return count > 0, nil
}
-// DeleteCertificate removes a certificate.
-func (s *CertificateService) DeleteCertificate(id uint) error {
+// IsCertificateInUseByUUID checks if a certificate is referenced by any proxy host, looked up by UUID.
+func (s *CertificateService) IsCertificateInUseByUUID(certUUID string) (bool, error) {
+ var cert models.SSLCertificate
+ if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return false, ErrCertNotFound
+ }
+ return false, fmt.Errorf("failed to look up certificate: %w", err)
+ }
+ return s.IsCertificateInUse(cert.ID)
+}
+
+// DeleteCertificate removes a certificate by UUID.
+func (s *CertificateService) DeleteCertificate(certUUID string) error {
+ var cert models.SSLCertificate
+ if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return ErrCertNotFound
+ }
+ return fmt.Errorf("failed to look up certificate: %w", err)
+ }
+
// Prevent deletion if the certificate is referenced by any proxy host
- inUse, err := s.IsCertificateInUse(id)
+ inUse, err := s.IsCertificateInUse(cert.ID)
if err != nil {
return err
}
@@ -403,30 +671,22 @@ func (s *CertificateService) DeleteCertificate(id uint) error {
return ErrCertInUse
}
- var cert models.SSLCertificate
- if err := s.db.Where("id = ?", id).First(&cert).Error; err != nil {
- return err
- }
-
- if cert.Provider == "letsencrypt" {
+ if cert.Provider == "letsencrypt" || cert.Provider == "letsencrypt-staging" {
// Best-effort file deletion
certRoot := filepath.Join(s.dataDir, "certificates")
_ = filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") {
if info.Name() == cert.Domains+".crt" {
- // Found it
logger.Log().WithField("path", path).Info("CertificateService: deleting ACME cert file")
if err := os.Remove(path); err != nil {
logger.Log().WithError(err).Error("CertificateService: failed to delete cert file")
}
- // Try to delete key as well
keyPath := strings.TrimSuffix(path, ".crt") + ".key"
if _, err := os.Stat(keyPath); err == nil {
if err := os.Remove(keyPath); err != nil {
logger.Log().WithError(err).Warn("Failed to remove key file")
}
}
- // Also try to delete the json meta file
jsonPath := strings.TrimSuffix(path, ".crt") + ".json"
if _, err := os.Stat(jsonPath); err == nil {
if err := os.Remove(jsonPath); err != nil {
@@ -439,10 +699,348 @@ func (s *CertificateService) DeleteCertificate(id uint) error {
})
}
- if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", id).Error; err != nil {
- return err
+ if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", cert.ID).Error; err != nil {
+ return fmt.Errorf("failed to delete certificate: %w", err)
}
- // Invalidate cache so the deleted cert disappears immediately
s.InvalidateCache()
return nil
}
+
+// ExportCertificate exports a certificate in the requested format.
+// Returns the file data, suggested filename, and any error.
+func (s *CertificateService) ExportCertificate(certUUID string, format string, includeKey bool) ([]byte, string, error) {
+ var cert models.SSLCertificate
+ if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, "", ErrCertNotFound
+ }
+ return nil, "", fmt.Errorf("failed to fetch certificate: %w", err)
+ }
+
+ baseName := cert.Name
+ if baseName == "" {
+ baseName = "certificate"
+ }
+
+ switch strings.ToLower(format) {
+ case "pem":
+ var buf strings.Builder
+ buf.WriteString(cert.Certificate)
+ if cert.CertificateChain != "" {
+ buf.WriteString("\n")
+ buf.WriteString(cert.CertificateChain)
+ }
+ if includeKey {
+ keyPEM, err := s.GetDecryptedPrivateKey(&cert)
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to decrypt private key: %w", err)
+ }
+ buf.WriteString("\n")
+ buf.WriteString(keyPEM)
+ }
+ return []byte(buf.String()), baseName + ".pem", nil
+
+ case "der":
+ derData, err := ConvertPEMToDER(cert.Certificate)
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to convert to DER: %w", err)
+ }
+ return derData, baseName + ".der", nil
+
+ case "pfx", "p12":
+ keyPEM, err := s.GetDecryptedPrivateKey(&cert)
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to decrypt private key for PFX: %w", err)
+ }
+ pfxData, err := ConvertPEMToPFX(cert.Certificate, keyPEM, cert.CertificateChain, "")
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to create PFX: %w", err)
+ }
+ return pfxData, baseName + ".pfx", nil
+
+ default:
+ return nil, "", fmt.Errorf("unsupported export format: %s", format)
+ }
+}
+
+// GetDecryptedPrivateKey decrypts and returns the private key PEM for internal use.
+func (s *CertificateService) GetDecryptedPrivateKey(cert *models.SSLCertificate) (string, error) {
+ if cert.PrivateKeyEncrypted == "" {
+ return "", fmt.Errorf("no encrypted private key stored")
+ }
+ if s.encSvc == nil {
+ return "", fmt.Errorf("encryption service not configured")
+ }
+
+ decrypted, err := s.encSvc.Decrypt(cert.PrivateKeyEncrypted)
+ if err != nil {
+ return "", fmt.Errorf("failed to decrypt private key: %w", err)
+ }
+
+ return string(decrypted), nil
+}
+
+// MigratePrivateKeys encrypts existing plaintext private keys.
+// Idempotent — skips already-migrated rows.
+func (s *CertificateService) MigratePrivateKeys() error {
+ if s.encSvc == nil {
+ logger.Log().Warn("CertificateService: encryption service not configured, skipping key migration")
+ return nil
+ }
+
+ // Use raw SQL because PrivateKey has gorm:"-" tag
+ type rawCert struct {
+ ID uint
+ PrivateKey string
+ PrivateKeyEnc string `gorm:"column:private_key_enc"`
+ }
+
+ var certs []rawCert
+ if err := s.db.Raw("SELECT id, private_key, private_key_enc FROM ssl_certificates WHERE private_key != '' AND (private_key_enc = '' OR private_key_enc IS NULL)").Scan(&certs).Error; err != nil {
+ return fmt.Errorf("failed to query certificates for migration: %w", err)
+ }
+
+ if len(certs) == 0 {
+ logger.Log().Info("CertificateService: no private keys to migrate")
+ return nil
+ }
+
+ logger.Log().WithField("count", len(certs)).Info("CertificateService: migrating plaintext private keys")
+
+ for _, c := range certs {
+ encrypted, err := s.encSvc.Encrypt([]byte(c.PrivateKey))
+ if err != nil {
+ logger.Log().WithField("cert_id", c.ID).WithError(err).Error("CertificateService: failed to encrypt key during migration")
+ continue
+ }
+
+ if err := s.db.Exec("UPDATE ssl_certificates SET private_key_enc = ?, key_version = 1, private_key = '' WHERE id = ?", encrypted, c.ID).Error; err != nil {
+ logger.Log().WithField("cert_id", c.ID).WithError(err).Error("CertificateService: failed to update migrated key")
+ continue
+ }
+
+ logger.Log().WithField("cert_id", c.ID).Info("CertificateService: migrated private key")
+ }
+
+ return nil
+}
+
+// DeleteCertificateByID removes a certificate by numeric ID (legacy compatibility).
+func (s *CertificateService) DeleteCertificateByID(id uint) error {
+ var cert models.SSLCertificate
+ if err := s.db.Where("id = ?", id).First(&cert).Error; err != nil {
+ return fmt.Errorf("failed to look up certificate: %w", err)
+ }
+ return s.DeleteCertificate(cert.UUID)
+}
+
+// UpdateCertificate updates certificate metadata (name only) by UUID.
+func (s *CertificateService) UpdateCertificate(certUUID string, name string) (*CertificateInfo, error) {
+ var cert models.SSLCertificate
+ if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, ErrCertNotFound
+ }
+ return nil, fmt.Errorf("failed to fetch certificate: %w", err)
+ }
+
+ cert.Name = name
+ if err := s.db.Save(&cert).Error; err != nil {
+ return nil, fmt.Errorf("failed to update certificate: %w", err)
+ }
+
+ s.InvalidateCache()
+
+ expires := time.Time{}
+ if cert.ExpiresAt != nil {
+ expires = *cert.ExpiresAt
+ }
+ notBefore := time.Time{}
+ if cert.NotBefore != nil {
+ notBefore = *cert.NotBefore
+ }
+
+ var chainDepth int
+ if cert.CertificateChain != "" {
+ certs, _ := parsePEMCertificates([]byte(cert.CertificateChain))
+ chainDepth = len(certs)
+ }
+
+ inUse, _ := s.IsCertificateInUse(cert.ID)
+
+ return &CertificateInfo{
+ UUID: cert.UUID,
+ Name: cert.Name,
+ CommonName: cert.CommonName,
+ Domains: cert.Domains,
+ Issuer: cert.Provider,
+ IssuerOrg: cert.IssuerOrg,
+ Fingerprint: cert.Fingerprint,
+ SerialNumber: cert.SerialNumber,
+ KeyType: cert.KeyType,
+ ExpiresAt: expires,
+ NotBefore: notBefore,
+ Status: certStatus(cert),
+ Provider: cert.Provider,
+ ChainDepth: chainDepth,
+ HasKey: cert.PrivateKeyEncrypted != "",
+ InUse: inUse,
+ }, nil
+}
+
+// CheckExpiringCertificates returns certificates that are expiring within the given number of days.
+func (s *CertificateService) CheckExpiringCertificates(warningDays int) ([]CertificateInfo, error) {
+ var certs []models.SSLCertificate
+ threshold := time.Now().Add(time.Duration(warningDays) * 24 * time.Hour)
+
+ if err := s.db.Where("provider = ? AND expires_at IS NOT NULL AND expires_at <= ?", "custom", threshold).Find(&certs).Error; err != nil {
+ return nil, fmt.Errorf("failed to query expiring certificates: %w", err)
+ }
+
+ result := make([]CertificateInfo, 0, len(certs))
+ for _, cert := range certs {
+ expires := time.Time{}
+ if cert.ExpiresAt != nil {
+ expires = *cert.ExpiresAt
+ }
+ notBefore := time.Time{}
+ if cert.NotBefore != nil {
+ notBefore = *cert.NotBefore
+ }
+
+ result = append(result, CertificateInfo{
+ UUID: cert.UUID,
+ Name: cert.Name,
+ CommonName: cert.CommonName,
+ Domains: cert.Domains,
+ Issuer: cert.Provider,
+ IssuerOrg: cert.IssuerOrg,
+ Fingerprint: cert.Fingerprint,
+ SerialNumber: cert.SerialNumber,
+ KeyType: cert.KeyType,
+ ExpiresAt: expires,
+ NotBefore: notBefore,
+ Status: certStatus(cert),
+ Provider: cert.Provider,
+ HasKey: cert.PrivateKeyEncrypted != "",
+ })
+ }
+
+ return result, nil
+}
+
+// StartExpiryChecker runs a background goroutine that periodically checks for expiring certificates.
+func (s *CertificateService) StartExpiryChecker(ctx context.Context, notificationSvc *NotificationService, warningDays int) {
+ // Startup delay: avoid notification bursts during frequent restarts
+ startupDelay := 5 * time.Minute
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(startupDelay):
+ }
+
+ // Add random jitter (0-60 minutes) using crypto/rand
+ maxJitter := int64(60 * time.Minute)
+ n, errRand := crand.Int(crand.Reader, big.NewInt(maxJitter))
+ if errRand != nil {
+ n = big.NewInt(maxJitter / 2)
+ }
+ jitter := time.Duration(n.Int64())
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(jitter):
+ }
+
+ s.checkExpiry(ctx, notificationSvc, warningDays)
+
+ ticker := time.NewTicker(24 * time.Hour)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ s.checkExpiry(ctx, notificationSvc, warningDays)
+ }
+ }
+}
+
+func (s *CertificateService) checkExpiry(ctx context.Context, notificationSvc *NotificationService, warningDays int) {
+ if notificationSvc == nil {
+ return
+ }
+
+ certs, err := s.CheckExpiringCertificates(warningDays)
+ if err != nil {
+ logger.Log().WithError(err).Error("CertificateService: failed to check expiring certificates")
+ return
+ }
+
+ for _, cert := range certs {
+ daysLeft := time.Until(cert.ExpiresAt).Hours() / 24
+
+ if daysLeft < 0 {
+ // Expired
+ if _, err := notificationSvc.Create(
+ models.NotificationTypeError,
+ "Certificate Expired",
+ fmt.Sprintf("Certificate %q (%s) has expired.", cert.Name, cert.Domains),
+ ); err != nil {
+ logger.Log().WithError(err).Error("CertificateService: failed to create expiry notification")
+ }
+ notificationSvc.SendExternal(ctx,
+ "cert_expiry",
+ "Certificate Expired",
+ fmt.Sprintf("Certificate %q (%s) has expired.", cert.Name, cert.Domains),
+ map[string]any{"uuid": cert.UUID, "domains": cert.Domains, "status": "expired"},
+ )
+ } else {
+ // Expiring soon
+ if _, err := notificationSvc.Create(
+ models.NotificationTypeWarning,
+ "Certificate Expiring Soon",
+ fmt.Sprintf("Certificate %q (%s) expires in %.0f days.", cert.Name, cert.Domains, daysLeft),
+ ); err != nil {
+ logger.Log().WithError(err).Error("CertificateService: failed to create expiry warning notification")
+ }
+ notificationSvc.SendExternal(ctx,
+ "cert_expiry",
+ "Certificate Expiring Soon",
+ fmt.Sprintf("Certificate %q (%s) expires in %.0f days.", cert.Name, cert.Domains, daysLeft),
+ map[string]any{"uuid": cert.UUID, "domains": cert.Domains, "days_left": int(daysLeft)},
+ )
+ }
+ }
+}
+
+func buildChainEntries(certPEM, chainPEM string) []ChainEntry {
+ var entries []ChainEntry
+
+ // Parse leaf
+ if certPEM != "" {
+ certs, _ := parsePEMCertificates([]byte(certPEM))
+ for _, c := range certs {
+ entries = append(entries, ChainEntry{
+ Subject: c.Subject.CommonName,
+ Issuer: c.Issuer.CommonName,
+ ExpiresAt: c.NotAfter,
+ })
+ }
+ }
+
+ // Parse chain
+ if chainPEM != "" {
+ certs, _ := parsePEMCertificates([]byte(chainPEM))
+ for _, c := range certs {
+ entries = append(entries, ChainEntry{
+ Subject: c.Subject.CommonName,
+ Issuer: c.Issuer.CommonName,
+ ExpiresAt: c.NotAfter,
+ })
+ }
+ }
+
+ return entries
+}
diff --git a/backend/internal/services/certificate_service_test.go b/backend/internal/services/certificate_service_test.go
index c0336b925..d45b4291b 100644
--- a/backend/internal/services/certificate_service_test.go
+++ b/backend/internal/services/certificate_service_test.go
@@ -31,6 +31,14 @@ func newTestCertificateService(dataDir string, db *gorm.DB) *CertificateService
}
}
+// certDBID looks up the numeric DB primary key for a certificate by UUID.
+func certDBID(t *testing.T, db *gorm.DB, uuid string) uint {
+ t.Helper()
+ var cert models.SSLCertificate
+ require.NoError(t, db.Where("uuid = ?", uuid).First(&cert).Error)
+ return cert.ID
+}
+
func TestNewCertificateService(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
@@ -43,7 +51,7 @@ func TestNewCertificateService(t *testing.T) {
require.NoError(t, os.MkdirAll(certDir, 0o750)) // #nosec G301 -- test directory
// Test service creation
- svc := NewCertificateService(tmpDir, db)
+ svc := NewCertificateService(tmpDir, db, nil)
assert.NotNil(t, svc)
assert.Equal(t, tmpDir, svc.dataDir)
assert.Equal(t, db, svc.db)
@@ -54,6 +62,11 @@ func TestNewCertificateService(t *testing.T) {
}
func generateTestCert(t *testing.T, domain string, expiry time.Time) []byte {
+ certPEM, _ := generateTestCertAndKey(t, domain, expiry)
+ return certPEM
+}
+
+func generateTestCertAndKey(t *testing.T, domain string, expiry time.Time) ([]byte, []byte) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate private key: %v", err)
@@ -77,7 +90,9 @@ func generateTestCert(t *testing.T, domain string, expiry time.Time) []byte {
t.Fatalf("Failed to create certificate: %v", err)
}
- return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
+ return certPEM, keyPEM
}
func TestCertificateService_GetCertificateInfo(t *testing.T) {
@@ -123,7 +138,7 @@ func TestCertificateService_GetCertificateInfo(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, certs, 1)
if len(certs) > 0 {
- assert.Equal(t, domain, certs[0].Domain)
+ assert.Equal(t, domain, certs[0].Domains)
assert.Equal(t, "valid", certs[0].Status)
// Check expiry within a margin
assert.WithinDuration(t, expiry, certs[0].ExpiresAt, time.Second)
@@ -153,7 +168,7 @@ func TestCertificateService_GetCertificateInfo(t *testing.T) {
// Find the expired one
var foundExpired bool
for _, c := range certs {
- if c.Domain == expiredDomain {
+ if c.Domains == expiredDomain {
assert.Equal(t, "expired", c.Status)
foundExpired = true
}
@@ -174,11 +189,10 @@ func TestCertificateService_UploadAndDelete(t *testing.T) {
// Generate Cert
domain := "custom.example.com"
expiry := time.Now().Add(24 * time.Hour)
- certPEM := generateTestCert(t, domain, expiry)
- keyPEM := []byte("FAKE PRIVATE KEY")
+ certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
// Test Upload
- cert, err := cs.UploadCertificate("My Custom Cert", string(certPEM), string(keyPEM))
+ cert, err := cs.UploadCertificate("My Custom Cert", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
assert.NotNil(t, cert)
assert.Equal(t, "My Custom Cert", cert.Name)
@@ -190,7 +204,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) {
require.NoError(t, err)
var found bool
for _, c := range certs {
- if c.ID == cert.ID {
+ if c.UUID == cert.UUID {
found = true
assert.Equal(t, "custom", c.Provider)
break
@@ -199,7 +213,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) {
assert.True(t, found)
// Test Delete
- err = cs.DeleteCertificate(cert.ID)
+ err = cs.DeleteCertificate(cert.UUID)
require.NoError(t, err)
// Verify it's gone
@@ -207,7 +221,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) {
require.NoError(t, err)
found = false
for _, c := range certs {
- if c.ID == cert.ID {
+ if c.UUID == cert.UUID {
found = true
break
}
@@ -248,7 +262,7 @@ func TestCertificateService_Persistence(t *testing.T) {
// Verify it's in the returned list
var foundInList bool
for _, c := range certs {
- if c.Domain == domain {
+ if c.Domains == domain {
foundInList = true
assert.Equal(t, "letsencrypt", c.Provider)
break
@@ -264,7 +278,7 @@ func TestCertificateService_Persistence(t *testing.T) {
assert.Equal(t, string(certPEM), dbCert.Certificate)
// 4. Delete the certificate via Service (which should delete the file)
- err = cs.DeleteCertificate(dbCert.ID)
+ err = cs.DeleteCertificate(dbCert.UUID)
require.NoError(t, err)
// Verify file is gone
@@ -278,7 +292,7 @@ func TestCertificateService_Persistence(t *testing.T) {
// Verify it's NOT in the returned list
foundInList = false
for _, c := range certs {
- if c.Domain == domain {
+ if c.Domains == domain {
foundInList = true
break
}
@@ -301,14 +315,14 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) {
cs := newTestCertificateService(tmpDir, db)
t.Run("invalid PEM format", func(t *testing.T) {
- cert, err := cs.UploadCertificate("Invalid", "not-a-valid-pem", "also-not-valid")
+ cert, err := cs.UploadCertificate("Invalid", "not-a-valid-pem", "also-not-valid", "")
assert.Error(t, err)
assert.Nil(t, cert)
- assert.Contains(t, err.Error(), "invalid certificate PEM")
+ assert.Contains(t, err.Error(), "unrecognized certificate format")
})
t.Run("empty certificate", func(t *testing.T) {
- cert, err := cs.UploadCertificate("Empty", "", "some-key")
+ cert, err := cs.UploadCertificate("Empty", "", "some-key", "")
assert.Error(t, err)
assert.Nil(t, cert)
})
@@ -318,19 +332,18 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) {
expiry := time.Now().Add(24 * time.Hour)
certPEM := generateTestCert(t, domain, expiry)
- cert, err := cs.UploadCertificate("No Key", string(certPEM), "")
+ cert, err := cs.UploadCertificate("No Key", string(certPEM), "", "")
assert.NoError(t, err) // Uploading without key is allowed
assert.NotNil(t, cert)
- assert.Equal(t, "", cert.PrivateKey)
+ assert.False(t, cert.HasKey)
})
t.Run("valid certificate with name", func(t *testing.T) {
domain := "valid.com"
expiry := time.Now().Add(24 * time.Hour)
- certPEM := generateTestCert(t, domain, expiry)
- keyPEM := []byte("FAKE PRIVATE KEY")
+ certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
- cert, err := cs.UploadCertificate("Valid Cert", string(certPEM), string(keyPEM))
+ cert, err := cs.UploadCertificate("Valid Cert", string(certPEM), string(keyPEM), "")
assert.NoError(t, err)
assert.NotNil(t, cert)
assert.Equal(t, "Valid Cert", cert.Name)
@@ -341,10 +354,9 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) {
t.Run("expired certificate can be uploaded", func(t *testing.T) {
domain := "expired-upload.com"
expiry := time.Now().Add(-24 * time.Hour) // Already expired
- certPEM := generateTestCert(t, domain, expiry)
- keyPEM := []byte("FAKE PRIVATE KEY")
+ certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
- cert, err := cs.UploadCertificate("Expired Upload", string(certPEM), string(keyPEM))
+ cert, err := cs.UploadCertificate("Expired Upload", string(certPEM), string(keyPEM), "")
// Should still upload successfully, but status will be expired
assert.NoError(t, err)
assert.NotNil(t, cert)
@@ -430,7 +442,7 @@ func TestCertificateService_ListCertificates_EdgeCases(t *testing.T) {
domain2 := "custom.example.com"
expiry2 := time.Now().Add(48 * time.Hour)
certPEM2 := generateTestCert(t, domain2, expiry2)
- _, err = cs.UploadCertificate("Custom", string(certPEM2), "FAKE KEY")
+ _, err = cs.UploadCertificate("Custom", string(certPEM2), "", "")
require.NoError(t, err)
certs, err := cs.ListCertificates()
@@ -457,20 +469,22 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) {
cs := newTestCertificateService(tmpDir, db)
t.Run("delete non-existent certificate", func(t *testing.T) {
- // IsCertificateInUse will succeed (not in use), then First will fail
- err := cs.DeleteCertificate(99999)
+ // DeleteCertificate takes UUID string; non-existent UUID returns error
+ err := cs.DeleteCertificate("non-existent-uuid")
assert.Error(t, err)
- assert.Equal(t, gorm.ErrRecordNotFound, err)
})
t.Run("delete certificate in use returns ErrCertInUse", func(t *testing.T) {
// Create certificate
domain := "in-use.com"
expiry := time.Now().Add(24 * time.Hour)
- certPEM := generateTestCert(t, domain, expiry)
- cert, err := cs.UploadCertificate("In Use", string(certPEM), "FAKE KEY")
+ certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
+ cert, err := cs.UploadCertificate("In Use", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
+ // Look up numeric ID for FK
+ dbID := certDBID(t, db, cert.UUID)
+
// Create proxy host using this certificate
ph := models.ProxyHost{
UUID: "test-ph",
@@ -478,18 +492,18 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) {
DomainNames: "in-use.com",
ForwardHost: "localhost",
ForwardPort: 8080,
- CertificateID: &cert.ID,
+ CertificateID: &dbID,
}
require.NoError(t, db.Create(&ph).Error)
// Attempt to delete certificate - should fail with ErrCertInUse
- err = cs.DeleteCertificate(cert.ID)
+ err = cs.DeleteCertificate(cert.UUID)
assert.Error(t, err)
assert.Equal(t, ErrCertInUse, err)
// Verify certificate still exists
var dbCert models.SSLCertificate
- err = db.First(&dbCert, "id = ?", cert.ID).Error
+ err = db.First(&dbCert, "id = ?", dbID).Error
assert.NoError(t, err)
})
@@ -497,21 +511,24 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) {
// Create and upload cert
domain := "to-delete.com"
expiry := time.Now().Add(24 * time.Hour)
- certPEM := generateTestCert(t, domain, expiry)
- cert, err := cs.UploadCertificate("To Delete", string(certPEM), "FAKE KEY")
+ certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
+ cert, err := cs.UploadCertificate("To Delete", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
+ // Look up numeric ID for verification
+ dbID := certDBID(t, db, cert.UUID)
+
// Manually remove the file (custom certs stored by numeric ID)
certPath := filepath.Join(tmpDir, "certificates", "custom", "cert.crt")
_ = os.Remove(certPath)
// Delete should still work (DB cleanup)
- err = cs.DeleteCertificate(cert.ID)
+ err = cs.DeleteCertificate(cert.UUID)
assert.NoError(t, err)
// Verify DB record is gone
var dbCert models.SSLCertificate
- err = db.First(&dbCert, "id = ?", cert.ID).Error
+ err = db.First(&dbCert, "id = ?", dbID).Error
assert.Error(t, err)
})
}
@@ -781,9 +798,8 @@ func TestCertificateService_CertificateWithSANs(t *testing.T) {
domain := "san.example.com"
expiry := time.Now().Add(24 * time.Hour)
certPEM := generateTestCertWithSANs(t, domain, []string{"san.example.com", "www.san.example.com", "api.san.example.com"}, expiry)
- keyPEM := []byte("FAKE PRIVATE KEY")
- cert, err := cs.UploadCertificate("SAN Cert", string(certPEM), string(keyPEM))
+ cert, err := cs.UploadCertificate("SAN Cert", string(certPEM), "", "")
require.NoError(t, err)
assert.NotNil(t, cert)
// Should have joined SANs
@@ -807,10 +823,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
domain := "unused.com"
expiry := time.Now().Add(24 * time.Hour)
certPEM := generateTestCert(t, domain, expiry)
- cert, err := cs.UploadCertificate("Unused", string(certPEM), "FAKE KEY")
+ cert, err := cs.UploadCertificate("Unused", string(certPEM), "", "")
require.NoError(t, err)
- inUse, err := cs.IsCertificateInUse(cert.ID)
+ dbID := certDBID(t, db, cert.UUID)
+ inUse, err := cs.IsCertificateInUse(dbID)
assert.NoError(t, err)
assert.False(t, inUse)
})
@@ -820,9 +837,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
domain := "used.com"
expiry := time.Now().Add(24 * time.Hour)
certPEM := generateTestCert(t, domain, expiry)
- cert, err := cs.UploadCertificate("Used", string(certPEM), "FAKE KEY")
+ cert, err := cs.UploadCertificate("Used", string(certPEM), "", "")
require.NoError(t, err)
+ dbID := certDBID(t, db, cert.UUID)
+
// Create proxy host using this certificate
ph := models.ProxyHost{
UUID: "ph-1",
@@ -830,11 +849,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
DomainNames: "used.com",
ForwardHost: "localhost",
ForwardPort: 8080,
- CertificateID: &cert.ID,
+ CertificateID: &dbID,
}
require.NoError(t, db.Create(&ph).Error)
- inUse, err := cs.IsCertificateInUse(cert.ID)
+ inUse, err := cs.IsCertificateInUse(dbID)
assert.NoError(t, err)
assert.True(t, inUse)
})
@@ -844,9 +863,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
domain := "shared.com"
expiry := time.Now().Add(24 * time.Hour)
certPEM := generateTestCert(t, domain, expiry)
- cert, err := cs.UploadCertificate("Shared", string(certPEM), "FAKE KEY")
+ cert, err := cs.UploadCertificate("Shared", string(certPEM), "", "")
require.NoError(t, err)
+ dbID := certDBID(t, db, cert.UUID)
+
// Create multiple proxy hosts using this certificate
for i := 1; i <= 3; i++ {
ph := models.ProxyHost{
@@ -855,12 +876,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
DomainNames: fmt.Sprintf("host%d.shared.com", i),
ForwardHost: "localhost",
ForwardPort: 8080 + i,
- CertificateID: &cert.ID,
+ CertificateID: &dbID,
}
require.NoError(t, db.Create(&ph).Error)
}
- inUse, err := cs.IsCertificateInUse(cert.ID)
+ inUse, err := cs.IsCertificateInUse(dbID)
assert.NoError(t, err)
assert.True(t, inUse)
})
@@ -876,9 +897,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
domain := "freed.com"
expiry := time.Now().Add(24 * time.Hour)
certPEM := generateTestCert(t, domain, expiry)
- cert, err := cs.UploadCertificate("Freed", string(certPEM), "FAKE KEY")
+ cert, err := cs.UploadCertificate("Freed", string(certPEM), "", "")
require.NoError(t, err)
+ dbID := certDBID(t, db, cert.UUID)
+
// Create proxy host using this certificate
ph := models.ProxyHost{
UUID: "ph-freed",
@@ -886,12 +909,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
DomainNames: "freed.com",
ForwardHost: "localhost",
ForwardPort: 8080,
- CertificateID: &cert.ID,
+ CertificateID: &dbID,
}
require.NoError(t, db.Create(&ph).Error)
// Verify in use
- inUse, err := cs.IsCertificateInUse(cert.ID)
+ inUse, err := cs.IsCertificateInUse(dbID)
assert.NoError(t, err)
assert.True(t, inUse)
@@ -899,12 +922,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
require.NoError(t, db.Delete(&ph).Error)
// Verify no longer in use
- inUse, err = cs.IsCertificateInUse(cert.ID)
+ inUse, err = cs.IsCertificateInUse(dbID)
assert.NoError(t, err)
assert.False(t, inUse)
// Now deletion should succeed
- err = cs.DeleteCertificate(cert.ID)
+ err = cs.DeleteCertificate(cert.UUID)
assert.NoError(t, err)
})
}
@@ -922,10 +945,9 @@ func TestCertificateService_CacheBehavior(t *testing.T) {
// Create a cert
domain := "cache.example.com"
expiry := time.Now().Add(24 * time.Hour)
- certPEM := generateTestCert(t, domain, expiry)
- keyPEM := []byte("FAKE PRIVATE KEY")
+ certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
- cert, err := cs.UploadCertificate("Cache Test", string(certPEM), string(keyPEM))
+ cert, err := cs.UploadCertificate("Cache Test", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
require.NotNil(t, cert)
@@ -940,7 +962,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) {
require.Len(t, certs2, 1)
// Both should return the same cert
- assert.Equal(t, certs1[0].ID, certs2[0].ID)
+ assert.Equal(t, certs1[0].UUID, certs2[0].UUID)
})
t.Run("invalidate cache forces resync", func(t *testing.T) {
@@ -954,7 +976,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) {
// Create a cert via upload (auto-invalidates)
certPEM := generateTestCert(t, "invalidate.example.com", time.Now().Add(24*time.Hour))
- _, err = cs.UploadCertificate("Invalidate Test", string(certPEM), "")
+ _, err = cs.UploadCertificate("Invalidate Test", string(certPEM), "", "")
require.NoError(t, err)
// Get list (should have 1)
@@ -1012,7 +1034,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) {
certs, err := cs.ListCertificates()
require.NoError(t, err)
require.Len(t, certs, 1)
- assert.Equal(t, "db.example.com", certs[0].Domain)
+ assert.Equal(t, "db.example.com", certs[0].Domains)
})
}
@@ -1032,7 +1054,7 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
-----END CERTIFICATE-----`
- cert, err := cs.UploadCertificate("Corrupted", corruptedPEM, "")
+ cert, err := cs.UploadCertificate("Corrupted", corruptedPEM, "", "")
assert.Error(t, err)
assert.Nil(t, cert)
assert.Contains(t, err.Error(), "failed to parse certificate")
@@ -1047,7 +1069,7 @@ A7qVvdqxevEuUkW4K+2KdMXmnQbG9Aa7k7eBjK1S+0LYmVjPKlJGNXHDGuy5Fw/d
hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtLze8R+KrZdHj0hLjZEPnl
-----END PRIVATE KEY-----`
- cert, err := cs.UploadCertificate("Wrong Type", wrongTypePEM, "")
+ cert, err := cs.UploadCertificate("Wrong Type", wrongTypePEM, "", "")
assert.Error(t, err)
assert.Nil(t, cert)
assert.Contains(t, err.Error(), "failed to parse certificate")
@@ -1070,7 +1092,7 @@ hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtLze8R+KrZdHj0hLjZEPnl
require.NoError(t, err)
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
- cert, err := cs.UploadCertificate("Empty Subject", string(certPEM), "")
+ cert, err := cs.UploadCertificate("Empty Subject", string(certPEM), "", "")
assert.NoError(t, err) // Upload succeeds
assert.NotNil(t, cert)
assert.Equal(t, "", cert.Domains) // Empty domains field
@@ -1165,7 +1187,7 @@ func TestCertificateService_SyncFromDisk_ErrorHandling(t *testing.T) {
certs, err := cs.ListCertificates()
assert.NoError(t, err)
assert.Len(t, certs, 1)
- assert.Equal(t, validDomain, certs[0].Domain)
+ assert.Equal(t, validDomain, certs[0].Domains)
})
}
@@ -1233,7 +1255,7 @@ func TestCertificateService_RefreshCacheFromDB_EdgeCases(t *testing.T) {
require.Len(t, certs, 1)
// Should use proxy host name
assert.Equal(t, "Matched Proxy", certs[0].Name)
- assert.Contains(t, certs[0].Domain, "www.example.com")
+ assert.Contains(t, certs[0].Domains, "www.example.com")
})
}
diff --git a/backend/internal/services/certificate_validator.go b/backend/internal/services/certificate_validator.go
new file mode 100644
index 000000000..4557e4d90
--- /dev/null
+++ b/backend/internal/services/certificate_validator.go
@@ -0,0 +1,521 @@
+package services
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/elliptic"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/hex"
+ "encoding/pem"
+ "fmt"
+ "math/big"
+ "strings"
+ "time"
+
+ "software.sslmate.com/src/go-pkcs12"
+)
+
+// CertFormat represents a certificate file format.
+type CertFormat string
+
+const (
+ FormatPEM CertFormat = "pem"
+ FormatDER CertFormat = "der"
+ FormatPFX CertFormat = "pfx"
+ FormatUnknown CertFormat = "unknown"
+)
+
+// ParsedCertificate contains the parsed result of certificate input.
+type ParsedCertificate struct {
+ Leaf *x509.Certificate
+ Intermediates []*x509.Certificate
+ PrivateKey crypto.PrivateKey
+ CertPEM string
+ KeyPEM string
+ ChainPEM string
+ Format CertFormat
+}
+
+// CertificateMetadata contains extracted metadata from an x509 certificate.
+type CertificateMetadata struct {
+ CommonName string
+ Domains []string
+ Fingerprint string
+ SerialNumber string
+ IssuerOrg string
+ KeyType string
+ NotBefore time.Time
+ NotAfter time.Time
+}
+
+// ValidationResult contains the result of a certificate validation.
+type ValidationResult struct {
+ Valid bool `json:"valid"`
+ CommonName string `json:"common_name"`
+ Domains []string `json:"domains"`
+ IssuerOrg string `json:"issuer_org"`
+ ExpiresAt time.Time `json:"expires_at"`
+ KeyMatch bool `json:"key_match"`
+ ChainValid bool `json:"chain_valid"`
+ ChainDepth int `json:"chain_depth"`
+ Warnings []string `json:"warnings"`
+ Errors []string `json:"errors"`
+}
+
+// DetectFormat determines the certificate format from raw file content.
+// Uses trial-parse strategy: PEM → PFX → DER.
+func DetectFormat(data []byte) CertFormat {
+ block, _ := pem.Decode(data)
+ if block != nil {
+ return FormatPEM
+ }
+
+ if _, _, _, err := pkcs12.DecodeChain(data, ""); err == nil {
+ return FormatPFX
+ }
+ // PFX with empty password failed, but it could be password-protected
+ // If data starts with PKCS12 magic bytes (ASN.1 SEQUENCE), treat as PFX candidate
+ if len(data) > 2 && data[0] == 0x30 {
+ // Could be DER or PFX; try DER parse
+ if _, err := x509.ParseCertificate(data); err == nil {
+ return FormatDER
+ }
+ // If DER parse fails, it's likely PFX
+ return FormatPFX
+ }
+
+ if _, err := x509.ParseCertificate(data); err == nil {
+ return FormatDER
+ }
+
+ return FormatUnknown
+}
+
+// ParseCertificateInput handles PEM, PFX, and DER input parsing.
+func ParseCertificateInput(certData []byte, keyData []byte, chainData []byte, pfxPassword string) (*ParsedCertificate, error) {
+ if len(certData) == 0 {
+ return nil, fmt.Errorf("certificate data is empty")
+ }
+
+ format := DetectFormat(certData)
+
+ switch format {
+ case FormatPEM:
+ return parsePEMInput(certData, keyData, chainData)
+ case FormatPFX:
+ return parsePFXInput(certData, pfxPassword)
+ case FormatDER:
+ return parseDERInput(certData, keyData)
+ default:
+ return nil, fmt.Errorf("unrecognized certificate format")
+ }
+}
+
+func parsePEMInput(certData []byte, keyData []byte, chainData []byte) (*ParsedCertificate, error) {
+ result := &ParsedCertificate{Format: FormatPEM}
+
+ // Parse leaf certificate
+ certs, err := parsePEMCertificates(certData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse certificate PEM: %w", err)
+ }
+ if len(certs) == 0 {
+ return nil, fmt.Errorf("no certificates found in PEM data")
+ }
+
+ result.Leaf = certs[0]
+ result.CertPEM = string(certData)
+
+ // If certData contains multiple certs, treat extras as intermediates
+ if len(certs) > 1 {
+ result.Intermediates = certs[1:]
+ }
+
+ // Parse chain file if provided
+ if len(chainData) > 0 {
+ chainCerts, err := parsePEMCertificates(chainData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse chain PEM: %w", err)
+ }
+ result.Intermediates = append(result.Intermediates, chainCerts...)
+ result.ChainPEM = string(chainData)
+ }
+
+ // Build chain PEM from intermediates if not set from chain file
+ if result.ChainPEM == "" && len(result.Intermediates) > 0 {
+ var chainBuilder strings.Builder
+ for _, ic := range result.Intermediates {
+ if err := pem.Encode(&chainBuilder, &pem.Block{Type: "CERTIFICATE", Bytes: ic.Raw}); err != nil {
+ return nil, fmt.Errorf("failed to encode intermediate certificate: %w", err)
+ }
+ }
+ result.ChainPEM = chainBuilder.String()
+ }
+
+ // Parse private key
+ if len(keyData) > 0 {
+ key, err := parsePEMPrivateKey(keyData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse private key PEM: %w", err)
+ }
+ result.PrivateKey = key
+ result.KeyPEM = string(keyData)
+ }
+
+ return result, nil
+}
+
+func parsePFXInput(pfxData []byte, password string) (*ParsedCertificate, error) {
+ privateKey, leaf, caCerts, err := pkcs12.DecodeChain(pfxData, password)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode PFX/PKCS12: %w", err)
+ }
+
+ result := &ParsedCertificate{
+ Format: FormatPFX,
+ Leaf: leaf,
+ Intermediates: caCerts,
+ PrivateKey: privateKey,
+ }
+
+ // Convert to PEM for storage
+ result.CertPEM = encodeCertToPEM(leaf)
+
+ if len(caCerts) > 0 {
+ var chainBuilder strings.Builder
+ for _, ca := range caCerts {
+ chainBuilder.WriteString(encodeCertToPEM(ca))
+ }
+ result.ChainPEM = chainBuilder.String()
+ }
+
+ keyPEM, err := encodeKeyToPEM(privateKey)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encode private key to PEM: %w", err)
+ }
+ result.KeyPEM = keyPEM
+
+ return result, nil
+}
+
+func parseDERInput(certData []byte, keyData []byte) (*ParsedCertificate, error) {
+ cert, err := x509.ParseCertificate(certData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse DER certificate: %w", err)
+ }
+
+ result := &ParsedCertificate{
+ Format: FormatDER,
+ Leaf: cert,
+ CertPEM: encodeCertToPEM(cert),
+ }
+
+ if len(keyData) > 0 {
+ key, err := parsePEMPrivateKey(keyData)
+ if err != nil {
+ // Try DER key
+ key, err = x509.ParsePKCS8PrivateKey(keyData)
+ if err != nil {
+ key2, err2 := x509.ParseECPrivateKey(keyData)
+ if err2 != nil {
+ return nil, fmt.Errorf("failed to parse private key: %w", err)
+ }
+ key = key2
+ }
+ }
+ result.PrivateKey = key
+ keyPEM, err := encodeKeyToPEM(key)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encode private key to PEM: %w", err)
+ }
+ result.KeyPEM = keyPEM
+ }
+
+ return result, nil
+}
+
+// ValidateKeyMatch checks that the private key matches the certificate public key.
+func ValidateKeyMatch(cert *x509.Certificate, key crypto.PrivateKey) error {
+ if cert == nil {
+ return fmt.Errorf("certificate is nil")
+ }
+ if key == nil {
+ return fmt.Errorf("private key is nil")
+ }
+
+ switch pub := cert.PublicKey.(type) {
+ case *rsa.PublicKey:
+ privKey, ok := key.(*rsa.PrivateKey)
+ if !ok {
+ return fmt.Errorf("key type mismatch: certificate has RSA public key but private key is not RSA")
+ }
+ if pub.N.Cmp(privKey.N) != 0 {
+ return fmt.Errorf("RSA key mismatch: certificate and private key modulus differ")
+ }
+ case *ecdsa.PublicKey:
+ privKey, ok := key.(*ecdsa.PrivateKey)
+ if !ok {
+ return fmt.Errorf("key type mismatch: certificate has ECDSA public key but private key is not ECDSA")
+ }
+ if pub.X.Cmp(privKey.X) != 0 || pub.Y.Cmp(privKey.Y) != 0 {
+ return fmt.Errorf("ECDSA key mismatch: certificate and private key points differ")
+ }
+ case ed25519.PublicKey:
+ privKey, ok := key.(ed25519.PrivateKey)
+ if !ok {
+ return fmt.Errorf("key type mismatch: certificate has Ed25519 public key but private key is not Ed25519")
+ }
+ pubFromPriv := privKey.Public().(ed25519.PublicKey)
+ if !pub.Equal(pubFromPriv) {
+ return fmt.Errorf("Ed25519 key mismatch: certificate and private key differ")
+ }
+ default:
+ return fmt.Errorf("unsupported public key type: %T", cert.PublicKey)
+ }
+
+ return nil
+}
+
+// ValidateChain verifies the certificate chain from leaf to root.
+func ValidateChain(leaf *x509.Certificate, intermediates []*x509.Certificate) error {
+ if leaf == nil {
+ return fmt.Errorf("leaf certificate is nil")
+ }
+
+ pool := x509.NewCertPool()
+ for _, ic := range intermediates {
+ pool.AddCert(ic)
+ }
+
+ opts := x509.VerifyOptions{
+ Intermediates: pool,
+ CurrentTime: time.Now(),
+ KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
+ }
+
+ if _, err := leaf.Verify(opts); err != nil {
+ return fmt.Errorf("chain verification failed: %w", err)
+ }
+
+ return nil
+}
+
+// ConvertDERToPEM converts DER-encoded certificate to PEM.
+func ConvertDERToPEM(derData []byte) (string, error) {
+ cert, err := x509.ParseCertificate(derData)
+ if err != nil {
+ return "", fmt.Errorf("invalid DER data: %w", err)
+ }
+ return encodeCertToPEM(cert), nil
+}
+
+// ConvertPFXToPEM extracts cert, key, and chain from PFX/PKCS12.
+func ConvertPFXToPEM(pfxData []byte, password string) (certPEM string, keyPEM string, chainPEM string, err error) {
+ privateKey, leaf, caCerts, err := pkcs12.DecodeChain(pfxData, password)
+ if err != nil {
+ return "", "", "", fmt.Errorf("failed to decode PFX: %w", err)
+ }
+
+ certPEM = encodeCertToPEM(leaf)
+
+ keyPEM, err = encodeKeyToPEM(privateKey)
+ if err != nil {
+ return "", "", "", fmt.Errorf("failed to encode key: %w", err)
+ }
+
+ if len(caCerts) > 0 {
+ var builder strings.Builder
+ for _, ca := range caCerts {
+ builder.WriteString(encodeCertToPEM(ca))
+ }
+ chainPEM = builder.String()
+ }
+
+ return certPEM, keyPEM, chainPEM, nil
+}
+
+// ConvertPEMToPFX bundles cert, key, chain into PFX.
+func ConvertPEMToPFX(certPEM string, keyPEM string, chainPEM string, password string) ([]byte, error) {
+ certs, err := parsePEMCertificates([]byte(certPEM))
+ if err != nil || len(certs) == 0 {
+ return nil, fmt.Errorf("failed to parse cert PEM: %w", err)
+ }
+
+ key, err := parsePEMPrivateKey([]byte(keyPEM))
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse key PEM: %w", err)
+ }
+
+ var caCerts []*x509.Certificate
+ if chainPEM != "" {
+ caCerts, err = parsePEMCertificates([]byte(chainPEM))
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse chain PEM: %w", err)
+ }
+ }
+
+ pfxData, err := pkcs12.Modern.Encode(key, certs[0], caCerts, password)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encode PFX: %w", err)
+ }
+
+ return pfxData, nil
+}
+
+// ConvertPEMToDER converts PEM certificate to DER.
+func ConvertPEMToDER(certPEM string) ([]byte, error) {
+ block, _ := pem.Decode([]byte(certPEM))
+ if block == nil {
+ return nil, fmt.Errorf("failed to decode PEM")
+ }
+ // Verify it's a valid certificate
+ if _, err := x509.ParseCertificate(block.Bytes); err != nil {
+ return nil, fmt.Errorf("invalid certificate PEM: %w", err)
+ }
+ return block.Bytes, nil
+}
+
+// ExtractCertificateMetadata extracts fingerprint, serial, issuer, key type, etc.
+func ExtractCertificateMetadata(cert *x509.Certificate) *CertificateMetadata {
+ if cert == nil {
+ return nil
+ }
+
+ fingerprint := sha256.Sum256(cert.Raw)
+ fpHex := formatFingerprint(hex.EncodeToString(fingerprint[:]))
+
+ serial := formatSerial(cert.SerialNumber)
+
+ issuerOrg := ""
+ if len(cert.Issuer.Organization) > 0 {
+ issuerOrg = cert.Issuer.Organization[0]
+ }
+
+ domains := make([]string, 0, len(cert.DNSNames)+1)
+ if cert.Subject.CommonName != "" {
+ domains = append(domains, cert.Subject.CommonName)
+ }
+ for _, san := range cert.DNSNames {
+ if san != cert.Subject.CommonName {
+ domains = append(domains, san)
+ }
+ }
+
+ return &CertificateMetadata{
+ CommonName: cert.Subject.CommonName,
+ Domains: domains,
+ Fingerprint: fpHex,
+ SerialNumber: serial,
+ IssuerOrg: issuerOrg,
+ KeyType: detectKeyType(cert),
+ NotBefore: cert.NotBefore,
+ NotAfter: cert.NotAfter,
+ }
+}
+
+// --- helpers ---
+
+func parsePEMCertificates(data []byte) ([]*x509.Certificate, error) {
+ var certs []*x509.Certificate
+ rest := data
+ for {
+ var block *pem.Block
+ block, rest = pem.Decode(rest)
+ if block == nil {
+ break
+ }
+ if block.Type != "CERTIFICATE" {
+ continue
+ }
+ cert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse certificate: %w", err)
+ }
+ certs = append(certs, cert)
+ }
+ return certs, nil
+}
+
+func parsePEMPrivateKey(data []byte) (crypto.PrivateKey, error) {
+ block, _ := pem.Decode(data)
+ if block == nil {
+ return nil, fmt.Errorf("no PEM data found")
+ }
+
+ // Try PKCS8 first (handles RSA, ECDSA, Ed25519)
+ if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
+ return key, nil
+ }
+
+ // Try PKCS1 RSA
+ if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
+ return key, nil
+ }
+
+ // Try EC
+ if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
+ return key, nil
+ }
+
+ return nil, fmt.Errorf("unsupported private key format")
+}
+
+func encodeCertToPEM(cert *x509.Certificate) string {
+ return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))
+}
+
+func encodeKeyToPEM(key crypto.PrivateKey) (string, error) {
+ der, err := x509.MarshalPKCS8PrivateKey(key)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal private key: %w", err)
+ }
+ return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), nil
+}
+
+func formatFingerprint(hex string) string {
+ var parts []string
+ for i := 0; i < len(hex); i += 2 {
+ end := i + 2
+ if end > len(hex) {
+ end = len(hex)
+ }
+ parts = append(parts, strings.ToUpper(hex[i:end]))
+ }
+ return strings.Join(parts, ":")
+}
+
+func formatSerial(n *big.Int) string {
+ if n == nil {
+ return ""
+ }
+ b := n.Bytes()
+ parts := make([]string, len(b))
+ for i, v := range b {
+ parts[i] = fmt.Sprintf("%02X", v)
+ }
+ return strings.Join(parts, ":")
+}
+
+func detectKeyType(cert *x509.Certificate) string {
+ switch pub := cert.PublicKey.(type) {
+ case *rsa.PublicKey:
+ bits := pub.N.BitLen()
+ return fmt.Sprintf("RSA-%d", bits)
+ case *ecdsa.PublicKey:
+ switch pub.Curve {
+ case elliptic.P256():
+ return "ECDSA-P256"
+ case elliptic.P384():
+ return "ECDSA-P384"
+ default:
+ return "ECDSA"
+ }
+ case ed25519.PublicKey:
+ return "Ed25519"
+ default:
+ return "Unknown"
+ }
+}
diff --git a/backend/internal/services/certificate_validator_test.go b/backend/internal/services/certificate_validator_test.go
new file mode 100644
index 000000000..16de35b36
--- /dev/null
+++ b/backend/internal/services/certificate_validator_test.go
@@ -0,0 +1,388 @@
+package services
+
+import (
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// --- helpers ---
+
+func makeRSACertAndKey(t *testing.T, cn string, expiry time.Time) (*x509.Certificate, *rsa.PrivateKey, []byte, []byte) {
+ t.Helper()
+ priv, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: cn},
+ NotBefore: time.Now(),
+ NotAfter: expiry,
+ KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
+ require.NoError(t, err)
+ cert, err := x509.ParseCertificate(der)
+ require.NoError(t, err)
+
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
+ return cert, priv, certPEM, keyPEM
+}
+
+func makeECDSACertAndKey(t *testing.T, cn string) (*x509.Certificate, *ecdsa.PrivateKey, []byte, []byte) {
+ t.Helper()
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ require.NoError(t, err)
+
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(2),
+ Subject: pkix.Name{CommonName: cn},
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(24 * time.Hour),
+ KeyUsage: x509.KeyUsageDigitalSignature,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
+ require.NoError(t, err)
+ cert, err := x509.ParseCertificate(der)
+ require.NoError(t, err)
+
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+ keyDER, err := x509.MarshalECPrivateKey(priv)
+ require.NoError(t, err)
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
+ return cert, priv, certPEM, keyPEM
+}
+
+func makeEd25519CertAndKey(t *testing.T, cn string) (*x509.Certificate, ed25519.PrivateKey, []byte, []byte) {
+ t.Helper()
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ require.NoError(t, err)
+
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(3),
+ Subject: pkix.Name{CommonName: cn},
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(24 * time.Hour),
+ KeyUsage: x509.KeyUsageDigitalSignature,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv)
+ require.NoError(t, err)
+ cert, err := x509.ParseCertificate(der)
+ require.NoError(t, err)
+
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+ keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
+ require.NoError(t, err)
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
+ return cert, priv, certPEM, keyPEM
+}
+
+// --- DetectFormat ---
+
+func TestDetectFormat(t *testing.T) {
+ cert, _, certPEM, _ := makeRSACertAndKey(t, "test.com", time.Now().Add(time.Hour))
+
+ t.Run("PEM format", func(t *testing.T) {
+ assert.Equal(t, FormatPEM, DetectFormat(certPEM))
+ })
+
+ t.Run("DER format", func(t *testing.T) {
+ assert.Equal(t, FormatDER, DetectFormat(cert.Raw))
+ })
+
+ t.Run("unknown format", func(t *testing.T) {
+ assert.Equal(t, FormatUnknown, DetectFormat([]byte("not a cert")))
+ })
+
+ t.Run("empty data", func(t *testing.T) {
+ assert.Equal(t, FormatUnknown, DetectFormat([]byte{}))
+ })
+}
+
+// --- ParseCertificateInput ---
+
+func TestParseCertificateInput(t *testing.T) {
+ t.Run("PEM cert only", func(t *testing.T) {
+ _, _, certPEM, _ := makeRSACertAndKey(t, "pem.test", time.Now().Add(time.Hour))
+ parsed, err := ParseCertificateInput(certPEM, nil, nil, "")
+ require.NoError(t, err)
+ assert.NotNil(t, parsed.Leaf)
+ assert.Equal(t, FormatPEM, parsed.Format)
+ assert.Nil(t, parsed.PrivateKey)
+ })
+
+ t.Run("PEM cert with key", func(t *testing.T) {
+ _, _, certPEM, keyPEM := makeRSACertAndKey(t, "pem-key.test", time.Now().Add(time.Hour))
+ parsed, err := ParseCertificateInput(certPEM, keyPEM, nil, "")
+ require.NoError(t, err)
+ assert.NotNil(t, parsed.Leaf)
+ assert.NotNil(t, parsed.PrivateKey)
+ assert.Equal(t, FormatPEM, parsed.Format)
+ })
+
+ t.Run("DER cert", func(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "der.test", time.Now().Add(time.Hour))
+ parsed, err := ParseCertificateInput(cert.Raw, nil, nil, "")
+ require.NoError(t, err)
+ assert.NotNil(t, parsed.Leaf)
+ assert.Equal(t, FormatDER, parsed.Format)
+ })
+
+ t.Run("empty data returns error", func(t *testing.T) {
+ _, err := ParseCertificateInput(nil, nil, nil, "")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "empty")
+ })
+
+ t.Run("unrecognized format returns error", func(t *testing.T) {
+ _, err := ParseCertificateInput([]byte("garbage"), nil, nil, "")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "unrecognized")
+ })
+
+ t.Run("invalid key PEM returns error", func(t *testing.T) {
+ _, _, certPEM, _ := makeRSACertAndKey(t, "badkey.test", time.Now().Add(time.Hour))
+ _, err := ParseCertificateInput(certPEM, []byte("not-key"), nil, "")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "private key")
+ })
+}
+
+// --- ValidateKeyMatch ---
+
+func TestValidateKeyMatch(t *testing.T) {
+ t.Run("RSA matching", func(t *testing.T) {
+ cert, priv, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour))
+ assert.NoError(t, ValidateKeyMatch(cert, priv))
+ })
+
+ t.Run("RSA mismatched", func(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "rsa1.test", time.Now().Add(time.Hour))
+ _, otherPriv, _, _ := makeRSACertAndKey(t, "rsa2.test", time.Now().Add(time.Hour))
+ err := ValidateKeyMatch(cert, otherPriv)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "mismatch")
+ })
+
+ t.Run("ECDSA matching", func(t *testing.T) {
+ cert, priv, _, _ := makeECDSACertAndKey(t, "ecdsa.test")
+ assert.NoError(t, ValidateKeyMatch(cert, priv))
+ })
+
+ t.Run("ECDSA mismatched", func(t *testing.T) {
+ cert, _, _, _ := makeECDSACertAndKey(t, "ec1.test")
+ _, other, _, _ := makeECDSACertAndKey(t, "ec2.test")
+ assert.Error(t, ValidateKeyMatch(cert, other))
+ })
+
+ t.Run("Ed25519 matching", func(t *testing.T) {
+ cert, priv, _, _ := makeEd25519CertAndKey(t, "ed.test")
+ assert.NoError(t, ValidateKeyMatch(cert, priv))
+ })
+
+ t.Run("Ed25519 mismatched", func(t *testing.T) {
+ cert, _, _, _ := makeEd25519CertAndKey(t, "ed1.test")
+ _, other, _, _ := makeEd25519CertAndKey(t, "ed2.test")
+ assert.Error(t, ValidateKeyMatch(cert, other))
+ })
+
+ t.Run("type mismatch RSA cert with ECDSA key", func(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour))
+ _, ecKey, _, _ := makeECDSACertAndKey(t, "ec.test")
+ err := ValidateKeyMatch(cert, ecKey)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "type mismatch")
+ })
+
+ t.Run("nil certificate", func(t *testing.T) {
+ _, priv, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour))
+ assert.Error(t, ValidateKeyMatch(nil, priv))
+ })
+
+ t.Run("nil key", func(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour))
+ assert.Error(t, ValidateKeyMatch(cert, nil))
+ })
+}
+
+// --- ValidateChain ---
+
+func TestValidateChain(t *testing.T) {
+ t.Run("nil leaf returns error", func(t *testing.T) {
+ err := ValidateChain(nil, nil)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "nil")
+ })
+
+ t.Run("self-signed cert validates", func(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "self.test", time.Now().Add(time.Hour))
+ // Self-signed won't pass chain validation without being a CA
+ err := ValidateChain(cert, nil)
+ assert.Error(t, err)
+ })
+}
+
+// --- ConvertDERToPEM ---
+
+func TestConvertDERToPEM(t *testing.T) {
+ t.Run("valid DER", func(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "der.test", time.Now().Add(time.Hour))
+ pemStr, err := ConvertDERToPEM(cert.Raw)
+ require.NoError(t, err)
+ assert.Contains(t, pemStr, "BEGIN CERTIFICATE")
+ })
+
+ t.Run("invalid DER", func(t *testing.T) {
+ _, err := ConvertDERToPEM([]byte("not-der"))
+ assert.Error(t, err)
+ })
+}
+
+// --- ConvertPEMToDER ---
+
+func TestConvertPEMToDER(t *testing.T) {
+ t.Run("valid PEM", func(t *testing.T) {
+ _, _, certPEM, _ := makeRSACertAndKey(t, "p2d.test", time.Now().Add(time.Hour))
+ der, err := ConvertPEMToDER(string(certPEM))
+ require.NoError(t, err)
+ assert.NotEmpty(t, der)
+ // Round-trip
+ cert, err := x509.ParseCertificate(der)
+ require.NoError(t, err)
+ assert.Equal(t, "p2d.test", cert.Subject.CommonName)
+ })
+
+ t.Run("invalid PEM", func(t *testing.T) {
+ _, err := ConvertPEMToDER("not-pem")
+ assert.Error(t, err)
+ })
+}
+
+// --- ExtractCertificateMetadata ---
+
+func TestExtractCertificateMetadata(t *testing.T) {
+ t.Run("nil cert returns nil", func(t *testing.T) {
+ assert.Nil(t, ExtractCertificateMetadata(nil))
+ })
+
+ t.Run("RSA cert metadata", func(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "meta.test", time.Now().Add(time.Hour))
+ m := ExtractCertificateMetadata(cert)
+ require.NotNil(t, m)
+ assert.Equal(t, "meta.test", m.CommonName)
+ assert.Contains(t, m.KeyType, "RSA")
+ assert.NotEmpty(t, m.Fingerprint)
+ assert.NotEmpty(t, m.SerialNumber)
+ assert.Contains(t, m.Domains, "meta.test")
+ })
+
+ t.Run("ECDSA cert metadata", func(t *testing.T) {
+ cert, _, _, _ := makeECDSACertAndKey(t, "ec-meta.test")
+ m := ExtractCertificateMetadata(cert)
+ require.NotNil(t, m)
+ assert.Contains(t, m.KeyType, "ECDSA")
+ })
+
+ t.Run("Ed25519 cert metadata", func(t *testing.T) {
+ cert, _, _, _ := makeEd25519CertAndKey(t, "ed-meta.test")
+ m := ExtractCertificateMetadata(cert)
+ require.NotNil(t, m)
+ assert.Equal(t, "Ed25519", m.KeyType)
+ })
+
+ t.Run("cert with SANs", func(t *testing.T) {
+ priv, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(10),
+ Subject: pkix.Name{CommonName: "main.test"},
+ DNSNames: []string{"main.test", "alt1.test", "alt2.test"},
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(time.Hour),
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
+ require.NoError(t, err)
+ cert, _ := x509.ParseCertificate(der)
+
+ m := ExtractCertificateMetadata(cert)
+ require.NotNil(t, m)
+ assert.Contains(t, m.Domains, "main.test")
+ assert.Contains(t, m.Domains, "alt1.test")
+ assert.Contains(t, m.Domains, "alt2.test")
+ // CN should not be duplicated when it matches a SAN
+ count := 0
+ for _, d := range m.Domains {
+ if d == "main.test" {
+ count++
+ }
+ }
+ assert.Equal(t, 1, count, "CN should not be duplicated in domains list")
+ })
+
+ t.Run("cert with issuer org", func(t *testing.T) {
+ priv, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(11),
+ Subject: pkix.Name{CommonName: "org.test"},
+ Issuer: pkix.Name{Organization: []string{"Test Org Inc"}},
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(time.Hour),
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
+ require.NoError(t, err)
+ cert, _ := x509.ParseCertificate(der)
+
+ m := ExtractCertificateMetadata(cert)
+ require.NotNil(t, m)
+ // Self-signed cert's issuer org may differ from template
+ assert.NotEmpty(t, m.Fingerprint)
+ })
+}
+
+// --- Helpers ---
+
+func TestFormatFingerprint(t *testing.T) {
+ assert.Equal(t, "AB:CD:EF", formatFingerprint("abcdef"))
+ assert.Equal(t, "01:23", formatFingerprint("0123"))
+ assert.Equal(t, "", formatFingerprint(""))
+}
+
+func TestFormatSerial(t *testing.T) {
+ assert.Equal(t, "01", formatSerial(big.NewInt(1)))
+ assert.Equal(t, "FF", formatSerial(big.NewInt(255)))
+ assert.Equal(t, "", formatSerial(nil))
+}
+
+func TestDetectKeyType(t *testing.T) {
+ t.Run("RSA key type", func(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour))
+ kt := detectKeyType(cert)
+ assert.Contains(t, kt, "RSA-2048")
+ })
+
+ t.Run("ECDSA-P256 key type", func(t *testing.T) {
+ cert, _, _, _ := makeECDSACertAndKey(t, "ec.test")
+ kt := detectKeyType(cert)
+ assert.Equal(t, "ECDSA-P256", kt)
+ })
+
+ t.Run("Ed25519 key type", func(t *testing.T) {
+ cert, _, _, _ := makeEd25519CertAndKey(t, "ed.test")
+ kt := detectKeyType(cert)
+ assert.Equal(t, "Ed25519", kt)
+ })
+}
diff --git a/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go b/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go
index 2c42b5986..7b44bf73f 100644
--- a/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go
+++ b/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go
@@ -12,6 +12,7 @@ func TestNewRFC2136Provider(t *testing.T) {
if provider == nil {
t.Fatal("NewRFC2136Provider() returned nil")
+ return
}
if provider.propagationTimeout != RFC2136DefaultPropagationTimeout {
diff --git a/docs/plans/archive/nightly-vuln-remediation-spec.md b/docs/plans/archive/nightly-vuln-remediation-spec.md
new file mode 100644
index 000000000..0e956d1d0
--- /dev/null
+++ b/docs/plans/archive/nightly-vuln-remediation-spec.md
@@ -0,0 +1,432 @@
+# Nightly Build Vulnerability Remediation Plan
+
+**Date**: 2026-04-09
+**Status**: Draft — Awaiting Approval
+**Scope**: Dependency security patches for 5 HIGH + 3 MEDIUM vulnerability groups
+**Target**: Single PR — all changes ship together
+**Archived**: Previous plan (CrowdSec Hub Bootstrapping) → `docs/plans/archive/crowdsec-hub-bootstrap-spec.md`
+
+---
+
+## 1. Problem Statement
+
+The Charon nightly build is failing container image vulnerability scans with **5 HIGH-severity** and **multiple MEDIUM-severity** findings. These vulnerabilities exist across three compiled binaries embedded in the container image:
+
+1. **Charon backend** (`/app/charon`) — Go binary built from `backend/go.mod`
+2. **Caddy** (`/usr/bin/caddy`) — Built via xcaddy in the Dockerfile Caddy builder stage
+3. **CrowdSec** (`/usr/local/bin/crowdsec`, `/usr/local/bin/cscli`) — Built from source in the Dockerfile CrowdSec builder stage
+
+Additionally, the **nightly branch** was synced from development before the Go 1.26.2 bump landed, so the nightly image was compiled with Go 1.26.1 (confirmed in `ci_failure.log` line 55: `GO_VERSION: 1.26.1`).
+
+---
+
+## 2. Research Findings
+
+### 2.1 Go Version Audit
+
+All files on `development` / `main` already reference **Go 1.26.2**:
+
+| File | Current Value | Status |
+|------|---------------|--------|
+| `backend/go.mod` | `go 1.26.2` | ✅ Current |
+| `go.work` | `go 1.26.2` | ✅ Current |
+| `Dockerfile` (`ARG GO_VERSION`) | `1.26.2` | ✅ Current |
+| `.github/workflows/nightly-build.yml` | `'1.26.2'` | ✅ Current |
+| `.github/workflows/codecov-upload.yml` | `'1.26.2'` | ✅ Current |
+| `.github/workflows/quality-checks.yml` | `'1.26.2'` | ✅ Current |
+| `.github/workflows/codeql.yml` | `'1.26.2'` | ✅ Current |
+| `.github/workflows/benchmark.yml` | `'1.26.2'` | ✅ Current |
+| `.github/workflows/release-goreleaser.yml` | `'1.26.2'` | ✅ Current |
+| `.github/workflows/e2e-tests-split.yml` | `'1.26.2'` | ✅ Current |
+| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | `'1.26.1'` | ❌ **Stale** |
+| `scripts/install-go-1.26.0.sh` | `1.26.0` | ⚠️ Old install script (not used in CI/Docker builds) |
+
+**Root Cause of Go stdlib CVEs**: The nightly branch's last sync predated the 1.26.2 bump. The next nightly sync from development will propagate 1.26.2 automatically. The only file requiring a fix is the example workflow.
+
+### 2.2 Vulnerability Inventory
+
+#### HIGH Severity (must fix — merge-blocking)
+
+| # | CVE / GHSA | Package | Current | Fix | Binary | Dep Type |
+|---|-----------|---------|---------|-----|--------|----------|
+| 1 | CVE-2026-39883 | `go.opentelemetry.io/otel/sdk` | v1.40.0 | v1.43.0 | Caddy | Transitive (Caddy plugins → otelhttp → otel/sdk) |
+| 2 | CVE-2026-34986 | `github.com/go-jose/go-jose/v3` | v3.0.4 | **v3.0.5** | Caddy | Transitive (caddy-security → JWT/JOSE stack) |
+| 3 | CVE-2026-34986 | `github.com/go-jose/go-jose/v4` | v4.1.3 | **v4.1.4** | Caddy | Transitive (grpc v1.79.3 → go-jose/v4) |
+| 4 | CVE-2026-32286 | `github.com/jackc/pgproto3/v2` | v2.3.3 | pgx/v4 v4.18.3 ¹ | CrowdSec | Transitive (CrowdSec → pgx/v4 v4.18.2 → pgproto3/v2) |
+
+¹ pgproto3/v2 has **no patched release**. Fix requires upstream migration to pgx/v5 (uses pgproto3/v3). See §5 Risk Assessment.
+
+#### MEDIUM Severity (fix in same pass)
+
+| # | CVE / GHSA | Package(s) | Current | Fix | Binary | Dep Type |
+|---|-----------|------------|---------|-----|--------|----------|
+| 5 | GHSA-xmrv-pmrh-hhx2 | AWS SDK v2: `eventstream` v1.7.1, `cloudwatchlogs` v1.57.2, `kinesis` v1.40.1, `s3` v1.87.3 | See left | Bump all | CrowdSec | Direct deps of CrowdSec v1.7.7 |
+| 6 | CVE-2026-32281, -32288, -32289 | Go stdlib | 1.26.1 | **1.26.2** | All (nightly image) | Toolchain |
+| 7 | CVE-2026-39882 | OTel HTTP exporters: `otlploghttp` v0.16.0, `otlpmetrichttp` v1.40.0, `otlptracehttp` v1.40.0 | See left | Bump all | Caddy | Transitive (Caddy plugins → OTel exporters) |
+
+### 2.3 Dependency Chain Analysis
+
+#### Backend (`backend/go.mod`)
+
+```
+charon/backend (direct)
+ └─ docker/docker v28.5.2+incompatible (direct)
+ └─ otelhttp v0.68.0 (indirect)
+ └─ otel/sdk v1.43.0 (indirect) — already at latest
+ └─ grpc v1.79.3 (indirect)
+ └─ otlptracehttp v1.42.0 (indirect) ── CVE-2026-39882
+```
+
+Backend resolved versions (verified via `go list -m -json`):
+
+| Package | Version | Type |
+|---------|---------|------|
+| `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` | v1.42.0 | indirect |
+| `google.golang.org/grpc` | v1.79.3 | indirect |
+| `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` | v0.68.0 | indirect |
+
+**Not present in backend**: go-jose/v3, go-jose/v4, otel/sdk, pgproto3/v2, AWS SDK, otlploghttp, otlpmetrichttp.
+
+#### CrowdSec Binary (Dockerfile `crowdsec-builder` stage)
+
+Source: CrowdSec v1.7.7 `go.mod` (verified via `git clone --depth 1 --branch v1.7.7`):
+
+```
+crowdsec v1.7.7
+ └─ pgx/v4 v4.18.2 (direct) → pgproto3/v2 v2.3.3 (indirect) ── CVE-2026-32286
+ └─ aws-sdk-go-v2/service/s3 v1.87.3 (direct) ── GHSA-xmrv-pmrh-hhx2
+ └─ aws-sdk-go-v2/service/cloudwatchlogs v1.57.2 (direct) ── GHSA-xmrv-pmrh-hhx2
+ └─ aws-sdk-go-v2/service/kinesis v1.40.1 (direct) ── GHSA-xmrv-pmrh-hhx2
+ └─ aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 (indirect) ── GHSA-xmrv-pmrh-hhx2
+ └─ otel v1.39.0, otel/metric v1.39.0, otel/trace v1.39.0 (indirect)
+```
+
+Confirmed by Trivy image scan (`trivy-image-report.json`): pgproto3/v2 v2.3.3 flagged in `usr/local/bin/crowdsec` and `usr/local/bin/cscli`.
+
+#### Caddy Binary (Dockerfile `caddy-builder` stage)
+
+Built via xcaddy with plugins. go.mod is generated at build time. Vulnerable packages enter via:
+
+```
+xcaddy build (Caddy v2.11.2 + plugins)
+ └─ caddy-security v1.1.61 → go-jose/v3 (JWT auth stack) ── CVE-2026-34986
+ └─ grpc (patched to v1.79.3 in Dockerfile) → go-jose/v4 v4.1.3 ── CVE-2026-34986
+ └─ Caddy/plugins → otel/sdk v1.40.0 ── CVE-2026-39883
+ └─ Caddy/plugins → otlploghttp v0.16.0, otlpmetrichttp v1.40.0, otlptracehttp v1.40.0 ── CVE-2026-39882
+```
+
+---
+
+## 3. Technical Specifications
+
+### 3.1 Backend go.mod Changes
+
+**File**: `backend/go.mod` (+ `backend/go.sum` auto-generated)
+
+```bash
+cd backend
+
+# Upgrade grpc to v1.80.0 (security patches for transitive deps)
+go get google.golang.org/grpc@v1.80.0
+
+# CVE-2026-39882: OTel HTTP exporter (backend only has otlptracehttp)
+go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0
+
+go mod tidy
+```
+
+Expected `go.mod` diff:
+- `google.golang.org/grpc` v1.79.3 → v1.80.0
+- `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` v1.42.0 → v1.43.0
+
+### 3.2 Dockerfile — Caddy Builder Stage Patches
+
+**File**: `Dockerfile`, within the caddy-builder `RUN bash -c '...'` block, in the **Stage 2: Apply security patches** section.
+
+Add after the existing `go get golang.org/x/net@v${XNET_VERSION};` line:
+
+```bash
+# CVE-2026-34986: go-jose JOSE/JWT validation bypass
+# Fix in v3.0.5 and v4.1.4. Pin here until caddy-security ships fix.
+# renovate: datasource=go depName=github.com/go-jose/go-jose/v3
+go get github.com/go-jose/go-jose/v3@v3.0.5; \
+# renovate: datasource=go depName=github.com/go-jose/go-jose/v4
+go get github.com/go-jose/go-jose/v4@v4.1.4; \
+# CVE-2026-39883: OTel SDK resource leak
+# Fix in v1.43.0. Pin here until Caddy ships with updated OTel.
+# renovate: datasource=go depName=go.opentelemetry.io/otel/sdk
+go get go.opentelemetry.io/otel/sdk@v1.43.0; \
+# CVE-2026-39882: OTel HTTP exporter request smuggling
+# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp
+go get go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp@v0.19.0; \
+# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp
+go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0; \
+# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
+go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0; \
+```
+
+Update existing grpc patch line from `v1.79.3` → `v1.80.0`:
+
+```bash
+# Before:
+go get google.golang.org/grpc@v1.79.3; \
+# After:
+# CVE-2026-33186: gRPC-Go auth bypass (fixed in v1.79.3)
+# CVE-2026-34986: go-jose/v4 transitive fix (requires grpc >= v1.80.0)
+# renovate: datasource=go depName=google.golang.org/grpc
+go get google.golang.org/grpc@v1.80.0; \
+```
+
+### 3.3 Dockerfile — CrowdSec Builder Stage Patches
+
+**File**: `Dockerfile`, within the crowdsec-builder `RUN` block that patches dependencies.
+
+Add after the existing `go get golang.org/x/net@v${XNET_VERSION}` line:
+
+```bash
+# CVE-2026-32286: pgproto3/v2 buffer overflow (no v2 fix exists; bump pgx/v4 to latest patch)
+# renovate: datasource=go depName=github.com/jackc/pgx/v4
+go get github.com/jackc/pgx/v4@v4.18.3 && \
+# GHSA-xmrv-pmrh-hhx2: AWS SDK v2 event stream injection
+# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
+go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.8 && \
+# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs
+go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.68.0 && \
+# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/kinesis
+go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.5 && \
+# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/s3
+go get github.com/aws/aws-sdk-go-v2/service/s3@v1.99.0 && \
+```
+
+CrowdSec grpc already at v1.80.0 — no change needed.
+
+### 3.4 Example Workflow Fix
+
+**File**: `.github/skills/examples/gorm-scanner-ci-workflow.yml` (line 28)
+
+```yaml
+# Before:
+ go-version: "1.26.1"
+# After:
+ go-version: "1.26.2"
+```
+
+### 3.5 Go Stdlib CVEs (nightly branch — no code change needed)
+
+The nightly workflow syncs `development → nightly` via `git merge --ff-only`. Since `development` already has Go 1.26.2 everywhere:
+- Dockerfile `ARG GO_VERSION=1.26.2` ✓
+- All CI workflows `GO_VERSION: '1.26.2'` ✓
+- `backend/go.mod` `go 1.26.2` ✓
+
+The next nightly run at 09:00 UTC will automatically propagate Go 1.26.2 to the nightly branch and rebuild the image.
+
+---
+
+## 4. Implementation Plan
+
+### Phase 1: Playwright Tests (N/A)
+
+No UI/UX changes — this is a dependency-only update. Existing E2E tests validate runtime behavior.
+
+### Phase 2: Backend Implementation
+
+| Task | File(s) | Action |
+|------|---------|--------|
+| 2.1 | `backend/go.mod`, `backend/go.sum` | Run `go get` commands from §3.1 |
+| 2.2 | Verify build | `cd backend && go build ./cmd/api` |
+| 2.3 | Verify vet | `cd backend && go vet ./...` |
+| 2.4 | Verify tests | `cd backend && go test ./...` |
+| 2.5 | Verify vulns | `cd backend && govulncheck ./...` |
+
+### Phase 3: Dockerfile Implementation
+
+| Task | File(s) | Action |
+|------|---------|--------|
+| 3.1 | `Dockerfile` (caddy-builder, ~L258-280) | Add go-jose v3/v4, OTel SDK, OTel exporter patches per §3.2 |
+| 3.2 | `Dockerfile` (caddy-builder, ~L270) | Update grpc patch v1.79.3 → v1.80.0 |
+| 3.3 | `Dockerfile` (crowdsec-builder, ~L360-370) | Add pgx, AWS SDK patches per §3.3 |
+| 3.3a | CrowdSec binaries | After patching deps, run `go build` on CrowdSec binaries before full Docker build for faster compilation feedback |
+| 3.4 | `Dockerfile` | Verify `docker build .` completes successfully (amd64) |
+
+### Phase 4: CI / Misc Fixes
+
+| Task | File(s) | Action |
+|------|---------|--------|
+| 4.1 | `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Bump Go version 1.26.2 → 1.26.2 |
+
+### Phase 5: Validation
+
+| Task | Validation |
+|------|------------|
+| 5.1 | `cd backend && go build ./cmd/api` — compiles cleanly |
+| 5.2 | `cd backend && go test ./...` — all tests pass |
+| 5.3 | `cd backend && go vet ./...` — no issues |
+| 5.4 | `cd backend && govulncheck ./...` — 0 findings |
+| 5.5 | `docker build -t charon:vuln-fix .` — image builds for amd64 |
+| 5.6 | Trivy scan on built image: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --severity CRITICAL,HIGH charon:vuln-fix` — 0 HIGH (pgproto3/v2 excepted) |
+| 5.7 | Container health: `docker run -d -p 8080:8080 charon:vuln-fix && curl -f http://localhost:8080/health` |
+| 5.8 | E2E Playwright tests pass against rebuilt container |
+
+---
+
+## 5. Risk Assessment
+
+### Low Risk
+
+| Change | Risk | Rationale |
+|--------|------|-----------|
+| `go-jose/v3` v3.0.4 → v3.0.5 | Low | Security patch release only |
+| `go-jose/v4` v4.1.3 → v4.1.4 | Low | Security patch release only |
+| `otel/sdk` v1.40.0 → v1.43.0 (Caddy) | Low | Minor bumps, backwards compatible |
+| `otlptracehttp` v1.42.0 → v1.43.0 (backend) | Low | Minor bump |
+| OTel exporters (Caddy) | Low | Minor/patch bumps |
+| Go version example fix | None | Non-runtime file |
+
+### Medium Risk
+
+| Change | Risk | Mitigation |
+|--------|------|------------|
+| `grpc` v1.79.3 → v1.80.0 | Medium | Minor version bump. gRPC is indirect — Charon doesn't use gRPC directly. Run full test suite. Verify Caddy and CrowdSec still compile. |
+| AWS SDK major bumps (s3 v1.87→v1.99, cloudwatchlogs v1.57→v1.68, kinesis v1.40→v1.43) | Medium | CrowdSec build may fail if internal APIs changed between versions. Mitigate: run `go mod tidy` after patches and verify CrowdSec binaries compile. **Note:** AWS SDK Go v2 packages use independent semver within the `v1.x.x` line — these are minor version bumps, not major API breaks. |
+| `pgx/v4` v4.18.2 → v4.18.3 | Medium | Patch release should be safe. May not fully resolve pgproto3/v2 since no patched v2 exists. |
+
+### Known Limitation: pgproto3/v2 (CVE-2026-32286)
+
+The `pgproto3/v2` module has **no patched release** — the fix exists only in `pgproto3/v3` (used by `pgx/v5`). CrowdSec v1.7.7 uses `pgx/v4` which depends on `pgproto3/v2`. Remediation:
+
+1. Bump `pgx/v4` to v4.18.3 (latest v4 patch) — may transitively resolve the issue
+2. If scanner still flags pgproto3/v2 after the bump: document as **accepted risk with upstream tracking**
+3. Monitor CrowdSec releases for `pgx/v5` migration
+4. Consider upgrading `CROWDSEC_VERSION` ARG if a newer CrowdSec release ships with pgx/v5
+
+---
+
+## 6. Acceptance Criteria
+
+- [ ] `cd backend && go build ./cmd/api` succeeds with zero warnings
+- [ ] `cd backend && go test ./...` passes with zero failures
+- [ ] `cd backend && go vet ./...` reports zero issues
+- [ ] `cd backend && govulncheck ./...` reports zero findings
+- [ ] Docker image builds successfully for amd64
+- [ ] Trivy/Grype scan of built image shows 0 new HIGH findings (pgproto3/v2 excepted if upstream unpatched)
+- [ ] Container starts, health check passes on port 8080
+- [ ] Existing E2E Playwright tests pass against rebuilt container
+- [ ] No new compile errors in Caddy or CrowdSec builder stages
+- [ ] `backend/go.mod` shows updated versions for grpc, otlptracehttp
+
+---
+
+## 7. Commit Slicing Strategy
+
+### Decision: Single PR
+
+**Rationale**: All changes are dependency version bumps with no feature or behavioral changes. They address a single concern (security vulnerability remediation) and should be reviewed and merged atomically to avoid partial-fix states.
+
+**Trigger reasons for single PR**:
+- All changes are security patches — cannot ship partial fixes
+- Changes span backend + Dockerfile + CI config — logically coupled
+- No risk of one slice breaking another
+- Total diff is small (go.mod/go.sum + Dockerfile patch lines + 1 YAML fix)
+
+### PR-1: Nightly Build Vulnerability Remediation
+
+**Scope**: All changes in §3.1–§3.4
+
+**Files modified**:
+
+| File | Change Type |
+|------|-------------|
+| `backend/go.mod` | Dependency version bumps (grpc, otlptracehttp) |
+| `backend/go.sum` | Auto-generated checksum updates |
+| `Dockerfile` | Add `go get` patches in caddy-builder and crowdsec-builder stages |
+| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Go version 1.26.2 → 1.26.2 |
+
+**Dependencies**: None (standalone)
+
+**Validation gates**:
+1. `go build` / `go test` / `go vet` / `govulncheck` pass
+2. Docker image builds for amd64
+3. Trivy/Grype scan passes (0 new HIGH)
+4. E2E tests pass
+
+**Rollback**: Revert PR. All changes are version pins — reverting restores previous state with no data migration needed.
+
+### Post-merge Actions
+
+1. Nightly build will automatically sync development → nightly and rebuild the image with all patches
+2. Monitor next nightly scan for zero HIGH findings
+3. If pgproto3/v2 still flagged: open tracking issue for CrowdSec pgx/v5 upstream migration
+4. If any AWS SDK bump breaks CrowdSec compilation: pin to intermediate version and document
+
+---
+
+## 8. CI Failure Amendment: pgx/v4 Module Path Mismatch
+
+**Date**: 2026-04-09
+**Failure**: PR #921 `build-and-push` job, step `crowdsec-builder 7/11`
+**Error**: `go: github.com/jackc/pgx/v4@v5.9.1: invalid version: go.mod has non-.../v4 module path "github.com/jackc/pgx/v5" (and .../v4/go.mod does not exist) at revision v5.9.1`
+
+### Root Cause
+
+Dockerfile line 386 specifies `go get github.com/jackc/pgx/v4@v5.9.1`. This mixes the v4 module path with a v5 version tag. Go's semantic import versioning rejects this because tag `v5.9.1` declares module path `github.com/jackc/pgx/v5` in its go.mod.
+
+### Fix
+
+**Dockerfile line 386** — change:
+```dockerfile
+go get github.com/jackc/pgx/v4@v5.9.1 && \
+```
+to:
+```dockerfile
+go get github.com/jackc/pgx/v4@v4.18.3 && \
+```
+
+No changes needed to the Renovate annotation (line 385) or the CVE comment (line 384) — both are already correct.
+
+### Why v4.18.3
+
+- CrowdSec v1.7.7 uses `github.com/jackc/pgx/v4 v4.18.2` (direct dependency)
+- v4.18.3 is the latest and likely final v4 release
+- pgproto3/v2 is archived at v2.3.3 (July 2025) — no fix will be released in the v2 line
+- The CVE (pgproto3/v2 buffer overflow) can only be fully resolved by CrowdSec migrating to pgx/v5 upstream
+- Bumping pgx/v4 to v4.18.3 gets the latest v4 maintenance patch; the CVE remains an accepted risk per §5
+
+### Validation
+
+The same `docker build` that previously failed at step 7/11 should now pass through the CrowdSec dependency patching stage and proceed to compilation (steps 8-11).
+
+---
+
+## 9. Commands Reference
+
+```bash
+# === Backend dependency upgrades ===
+cd /projects/Charon/backend
+
+go get google.golang.org/grpc@v1.80.0
+go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0
+go mod tidy
+
+# === Validate backend ===
+go build ./cmd/api
+go test ./...
+go vet ./...
+govulncheck ./...
+
+# === Docker build (after Dockerfile edits) ===
+cd /projects/Charon
+docker build -t charon:vuln-fix .
+
+# === Scan built image ===
+docker run --rm \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ aquasec/trivy:latest image \
+ --severity CRITICAL,HIGH \
+ charon:vuln-fix
+
+# === Quick container health check ===
+docker run -d --name charon-vuln-test -p 8080:8080 charon:vuln-fix
+sleep 10
+curl -f http://localhost:8080/health
+docker stop charon-vuln-test && docker rm charon-vuln-test
+```
diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md
index 0e956d1d0..96e9eadc8 100644
--- a/docs/plans/current_spec.md
+++ b/docs/plans/current_spec.md
@@ -1,432 +1,949 @@
-# Nightly Build Vulnerability Remediation Plan
+# Custom Certificate Upload & Management
-**Date**: 2026-04-09
+**Issue**: #22 — Custom Certificate Upload & Management
+**Date**: 2026-04-10
**Status**: Draft — Awaiting Approval
-**Scope**: Dependency security patches for 5 HIGH + 3 MEDIUM vulnerability groups
-**Target**: Single PR — all changes ship together
-**Archived**: Previous plan (CrowdSec Hub Bootstrapping) → `docs/plans/archive/crowdsec-hub-bootstrap-spec.md`
+**Priority**: High
+**Milestone**: Beta
+**Labels**: high, beta, ssl
+**Archived**: Previous plan (Nightly Build Vulnerability Remediation) → `docs/plans/archive/nightly-vuln-remediation-spec.md`
---
-## 1. Problem Statement
+## 1. Executive Summary
-The Charon nightly build is failing container image vulnerability scans with **5 HIGH-severity** and **multiple MEDIUM-severity** findings. These vulnerabilities exist across three compiled binaries embedded in the container image:
+Charon currently supports automatic certificate provisioning via Let's Encrypt/ZeroSSL (ACME) and has a rudimentary custom certificate upload flow (basic PEM upload with name). This plan enhances the certificate management system to support:
-1. **Charon backend** (`/app/charon`) — Go binary built from `backend/go.mod`
-2. **Caddy** (`/usr/bin/caddy`) — Built via xcaddy in the Dockerfile Caddy builder stage
-3. **CrowdSec** (`/usr/local/bin/crowdsec`, `/usr/local/bin/cscli`) — Built from source in the Dockerfile CrowdSec builder stage
+- **Full certificate validation pipeline** (format, chain, expiry, key matching)
+- **Private key encryption at rest** using the existing `CHARON_ENCRYPTION_KEY` infrastructure
+- **Multiple certificate formats** (PEM, PFX/PKCS12, DER)
+- **Certificate chain/intermediate support**
+- **Certificate assignment to proxy hosts** via the UI
+- **Expiry warning notifications** using the existing notification infrastructure
+- **Certificate export** with format conversion
+- **Enhanced upload UI** with drag-and-drop, validation feedback, and chain preview
-Additionally, the **nightly branch** was synced from development before the Go 1.26.2 bump landed, so the nightly image was compiled with Go 1.26.1 (confirmed in `ci_failure.log` line 55: `GO_VERSION: 1.26.1`).
+### Why This Matters
+
+Users who bring their own certificates (enterprise CAs, internal PKI, wildcard certs from commercial providers) need a secure, validated workflow for importing, managing, and assigning certificates. Currently, private keys are stored in plaintext in the database, there is no format validation beyond basic PEM decoding, and the UI lacks chain support and export capabilities.
---
-## 2. Research Findings
+## 2. Current State Analysis
+
+### 2.1 What Already Exists
+
+| Component | Status | Location | Notes |
+|-----------|--------|----------|-------|
+| **SSLCertificate model** | Exists | `backend/internal/models/ssl_certificate.go` | Has `Certificate`, `PrivateKey` (plaintext), `Domains`, `ExpiresAt`, `Provider` |
+| **CertificateService** | Exists | `backend/internal/services/certificate_service.go` | `UploadCertificate()`, `ListCertificates()`, `DeleteCertificate()`, `IsCertificateInUse()`, disk sync for ACME |
+| **CertificateHandler** | Exists | `backend/internal/api/handlers/certificate_handler.go` | `List`, `Upload`, `Delete` endpoints |
+| **API routes** | Exists | `backend/internal/api/routes/routes.go:664-675` | `GET/POST/DELETE /api/v1/certificates` |
+| **Frontend API client** | Exists | `frontend/src/api/certificates.ts` | `getCertificates()`, `uploadCertificate()`, `deleteCertificate()` |
+| **Certificates page** | Exists | `frontend/src/pages/Certificates.tsx` | Upload dialog (name + 2 files), list view |
+| **CertificateList** | Exists | `frontend/src/components/CertificateList.tsx` | Table with sort, bulk delete, status display |
+| **useCertificates hook** | Exists | `frontend/src/hooks/useCertificates.ts` | React Query wrapper |
+| **Caddy TLS loading** | Exists | `backend/internal/caddy/config.go:418-453` | Custom certs loaded via `LoadPEM` in TLS app |
+| **Caddy types** | Exists | `backend/internal/caddy/types.go:239-266` | `TLSApp`, `CertificatesConfig`, `LoadPEMConfig` |
+| **Encryption service** | Exists | `backend/internal/crypto/encryption.go` | AES-256-GCM encrypt/decrypt with `CHARON_ENCRYPTION_KEY` |
+| **Key rotation** | Exists | `backend/internal/crypto/rotation_service.go` | Multi-version key rotation for DNS provider credentials |
+| **Notification service** | Exists | `backend/internal/services/notification_service.go` | `SendExternal()` with event types, `Create()` for in-app |
+| **ProxyHost.CertificateID** | Exists | `backend/internal/models/proxy_host.go` | FK to SSLCertificate, already used in update handler |
+| **Delete E2E tests** | Exists | `tests/certificate-delete.spec.ts`, `tests/certificate-bulk-delete.spec.ts` | Delete flow E2E coverage |
+| **Config tests** | Exists | `backend/internal/caddy/config_test.go:1480-1600` | Custom cert loading via Caddy tested |
+
+### 2.2 Gaps to Address
+
+| Gap | Severity | Description |
+|-----|----------|-------------|
+| **Private keys stored in plaintext** | CRITICAL | `PrivateKey` field in `SSLCertificate` is stored as raw PEM. Must encrypt at rest. |
+| **🔴 Active private key disclosure** | CRITICAL | The Upload handler (`certificate_handler.go:137`) returns the full `*SSLCertificate` struct via `c.JSON(http.StatusCreated, cert)`. Because the model has `json:"private_key"`, the raw PEM private key is sent to the client in every upload response. **This is an active vulnerability in production.** Commit 1 fixes this by changing the tag to `json:"-"`. |
+| **Unsafe file read pattern** | HIGH | `certificate_handler.go:109` uses `certSrc.Read(certBytes)` which may return partial reads. Must use `io.ReadAll(io.LimitReader(src, 1<<20))` for safe, bounded reads. Commit 2 (task 2.1) fixes this. |
+| **No certificate chain validation** | HIGH | Upload accepts any PEM without verifying chain or key-cert match. |
+| **No format conversion** | HIGH | Only PEM is accepted. PFX/DER users cannot upload. |
+| **No expiry warnings** | HIGH | No scheduled check or notification for upcoming certificate expiry. |
+| **No certificate export** | MEDIUM | Users cannot download certs they uploaded (for migration, backup). |
+| **No chain/intermediate storage** | MEDIUM | Model has single `Certificate` field; no dedicated chain field. |
+| **No certificate detail view** | MEDIUM | Frontend shows only list; no detail/expand view showing SANs, issuer chain, fingerprint. |
+| **`CertificateInfo` leaks numeric ID** | HIGH | `CertificateInfo.ID uint json:"id,omitempty"` in service — violates GORM security rules. |
+| **Delete uses numeric ID in URL** | HIGH | `DELETE /certificates/:id` uses numeric ID; should use UUID. |
+
+---
-### 2.1 Go Version Audit
+## 3. Requirements (EARS Notation)
+
+### 3.1 Certificate Upload
+
+| ID | Requirement |
+|----|-------------|
+| R-UP-01 | WHEN a user submits a certificate upload form with valid PEM, PFX, or DER files, THE SYSTEM SHALL parse and validate the certificate, encrypt the private key at rest, store the certificate in the database, and return the certificate metadata. |
+| R-UP-02 | WHEN a user uploads a PFX/PKCS12 file with a password, THE SYSTEM SHALL decrypt the PFX, extract the certificate chain and private key, convert to PEM, and store them. |
+| R-UP-03 | WHEN a user uploads a DER-encoded certificate, THE SYSTEM SHALL convert it to PEM format before storage. |
+| R-UP-04 | WHEN a certificate upload contains intermediate certificates, THE SYSTEM SHALL store the full chain in order (leaf then intermediate then root). |
+| R-UP-05 | IF a user uploads a certificate whose private key does not match the certificate's public key, THEN THE SYSTEM SHALL reject the upload with a descriptive error. |
+| R-UP-06 | IF a user uploads an expired certificate, THEN THE SYSTEM SHALL warn but still allow storage (with status "expired"). |
+| R-UP-07 | THE SYSTEM SHALL enforce a maximum upload size of 1MB per file to prevent abuse. |
+| R-UP-08 | IF a user uploads a file that is not a valid certificate or key format, THEN THE SYSTEM SHALL reject the upload with a descriptive error. |
+
+### 3.2 Certificate Validation
+
+| ID | Requirement |
+|----|-------------|
+| R-VL-01 | WHEN a certificate is uploaded, THE SYSTEM SHALL verify the X.509 structure, extract the Common Name, SANs, issuer, serial number, and expiry date. |
+| R-VL-02 | WHEN a certificate chain is provided, THE SYSTEM SHALL verify that each certificate in the chain is signed by the next certificate (leaf then intermediate then root). |
+| R-VL-03 | WHEN a private key is uploaded, THE SYSTEM SHALL verify that the key matches the certificate's public key by comparing the public key modulus. |
+
+### 3.3 Private Key Security
+
+| ID | Requirement |
+|----|-------------|
+| R-PK-01 | THE SYSTEM SHALL encrypt all custom certificate private keys at rest using AES-256-GCM via the existing `CHARON_ENCRYPTION_KEY`. |
+| R-PK-02 | THE SYSTEM SHALL decrypt private keys only when serving them to Caddy for TLS or when exporting. |
+| R-PK-03 | THE SYSTEM SHALL never return private key content in API list/get responses. |
+| R-PK-04 | WHEN `CHARON_ENCRYPTION_KEY` is rotated, THE SYSTEM SHALL re-encrypt all stored private keys during the rotation process. |
+
+### 3.4 Certificate Assignment
+
+| ID | Requirement |
+|----|-------------|
+| R-AS-01 | WHEN a user assigns a custom certificate to a proxy host, THE SYSTEM SHALL update the proxy host's `CertificateID` and reload Caddy configuration. |
+| R-AS-02 | WHEN a custom certificate is assigned to a proxy host, THE SYSTEM SHALL use `LoadPEM` in Caddy's TLS app to serve the certificate for that domain. |
+| R-AS-03 | THE SYSTEM SHALL prevent deletion of certificates that are assigned to one or more proxy hosts. |
+
+### 3.5 Expiry Warnings
+
+| ID | Requirement |
+|----|-------------|
+| R-EX-01 | THE SYSTEM SHALL check certificate expiry dates daily via a background scheduler. |
+| R-EX-02 | WHEN a custom certificate will expire within 30 days, THE SYSTEM SHALL create an in-app warning notification. |
+| R-EX-03 | WHEN a custom certificate will expire within 30 days AND external notification providers are configured, THE SYSTEM SHALL send an external notification (rate-limited to once per 24 hours per certificate). |
+| R-EX-04 | WHEN a custom certificate has expired, THE SYSTEM SHALL update its status to "expired" and send a critical notification. |
+
+### 3.6 Certificate Export
+
+| ID | Requirement |
+|----|-------------|
+| R-EXP-01 | WHEN a user requests a certificate export, THE SYSTEM SHALL provide the certificate and chain in the requested format (PEM, PFX, DER). |
+| R-EXP-02 | WHEN exporting in PFX format, THE SYSTEM SHALL prompt for a password and encrypt the PFX bundle. |
+| R-EXP-03 | THE SYSTEM SHALL require authentication for all export operations. |
+| R-EXP-04 | THE SYSTEM SHALL never include the private key in export unless explicitly requested with re-authentication. |
+
+### 3.7 UI/UX
+
+| ID | Requirement |
+|----|-------------|
+| R-UI-01 | THE SYSTEM SHALL support drag-and-drop file upload for certificate and key files. |
+| R-UI-02 | WHEN a certificate is uploaded, THE SYSTEM SHALL display a preview showing: domains (CN + SANs), issuer, expiry date, chain depth, and key match status. |
+| R-UI-03 | THE SYSTEM SHALL display an expiry warning badge on certificates expiring within 30 days. |
+| R-UI-04 | THE SYSTEM SHALL provide a certificate detail view showing full metadata including fingerprint, serial number, issuer chain, and assigned hosts. |
-All files on `development` / `main` already reference **Go 1.26.2**:
+---
-| File | Current Value | Status |
-|------|---------------|--------|
-| `backend/go.mod` | `go 1.26.2` | ✅ Current |
-| `go.work` | `go 1.26.2` | ✅ Current |
-| `Dockerfile` (`ARG GO_VERSION`) | `1.26.2` | ✅ Current |
-| `.github/workflows/nightly-build.yml` | `'1.26.2'` | ✅ Current |
-| `.github/workflows/codecov-upload.yml` | `'1.26.2'` | ✅ Current |
-| `.github/workflows/quality-checks.yml` | `'1.26.2'` | ✅ Current |
-| `.github/workflows/codeql.yml` | `'1.26.2'` | ✅ Current |
-| `.github/workflows/benchmark.yml` | `'1.26.2'` | ✅ Current |
-| `.github/workflows/release-goreleaser.yml` | `'1.26.2'` | ✅ Current |
-| `.github/workflows/e2e-tests-split.yml` | `'1.26.2'` | ✅ Current |
-| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | `'1.26.1'` | ❌ **Stale** |
-| `scripts/install-go-1.26.0.sh` | `1.26.0` | ⚠️ Old install script (not used in CI/Docker builds) |
+## 4. Technical Architecture
+
+### 4.1 Database Model Changes
+
+#### Modified: `SSLCertificate` (`backend/internal/models/ssl_certificate.go`)
+
+```go
+type SSLCertificate struct {
+ ID uint `json:"-" gorm:"primaryKey"`
+ UUID string `json:"uuid" gorm:"uniqueIndex"`
+ Name string `json:"name" gorm:"index"`
+ Provider string `json:"provider" gorm:"index"`
+ Domains string `json:"domains" gorm:"index"`
+ CommonName string `json:"common_name"` // NEW
+ Certificate string `json:"-" gorm:"type:text"` // CHANGED: hide from JSON
+ CertificateChain string `json:"-" gorm:"type:text"` // NEW
+ PrivateKeyEncrypted string `json:"-" gorm:"column:private_key_enc;type:text"` // NEW
+ PrivateKey string `json:"-" gorm:"-"` // CHANGED: json:"-" fixes active private key disclosure (was json:"private_key"), gorm:"-" excludes from queries (column kept but values cleared)
+ KeyVersion int `json:"-" gorm:"default:1"` // NEW
+ Fingerprint string `json:"fingerprint"` // NEW
+ SerialNumber string `json:"serial_number"` // NEW
+ IssuerOrg string `json:"issuer_org"` // NEW
+ KeyType string `json:"key_type"` // NEW — see KeyType values below
+ ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"`
+ NotBefore *time.Time `json:"not_before,omitempty"` // NEW
+ AutoRenew bool `json:"auto_renew" gorm:"default:false"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+```
-**Root Cause of Go stdlib CVEs**: The nightly branch's last sync predated the 1.26.2 bump. The next nightly sync from development will propagate 1.26.2 automatically. The only file requiring a fix is the example workflow.
+**`KeyType` enum values**: `RSA-2048`, `RSA-4096`, `ECDSA-P256`, `ECDSA-P384`, `Ed25519`. Derived from the parsed private key at upload time.
+
+**Migration strategy**: Add new columns with defaults. Migrate existing plaintext `PrivateKey` data to `PrivateKeyEncrypted` via a dedicated migration step. After migration, clear `private_key` values (set to empty string) but **do NOT drop the column** — SQLite < 3.35.0 does not support `ALTER TABLE DROP COLUMN`, and GORM's `DropColumn` has varying support. Add `gorm:"-"` tag to the `PrivateKey` field so GORM ignores it in all queries.
+
+**Migration verification criteria**: No rows where `private_key != '' AND private_key_enc == ''`.
+
+**Follow-up task** (outside this feature's 4 PRs): Drop `private_key` column in a future release once all deployments are confirmed migrated and SQLite version requirements are established.
+
+#### Modified: `CertificateInfo` (`backend/internal/services/certificate_service.go`)
+
+```go
+type CertificateInfo struct {
+ UUID string `json:"uuid"`
+ Name string `json:"name,omitempty"`
+ CommonName string `json:"common_name,omitempty"`
+ Domains string `json:"domains"`
+ Issuer string `json:"issuer"`
+ IssuerOrg string `json:"issuer_org,omitempty"`
+ Fingerprint string `json:"fingerprint,omitempty"`
+ SerialNumber string `json:"serial_number,omitempty"`
+ KeyType string `json:"key_type,omitempty"`
+ ExpiresAt time.Time `json:"expires_at"`
+ NotBefore time.Time `json:"not_before,omitempty"`
+ Status string `json:"status"`
+ Provider string `json:"provider"`
+ ChainDepth int `json:"chain_depth,omitempty"`
+ HasKey bool `json:"has_key"`
+ InUse bool `json:"in_use"`
+}
+```
-### 2.2 Vulnerability Inventory
+### 4.2 API Endpoints
+
+All endpoints under `/api/v1` require authentication (existing middleware).
+
+#### Existing (Modified)
+
+| Method | Path | Changes |
+|--------|------|---------|
+| `GET` | `/certificates` | Response uses `CertificateInfo` (UUID only, no numeric ID, new metadata fields) |
+| `POST` | `/certificates` | Accept PEM, PFX, DER; encrypt private key; validate chain; return `CertificateInfo` |
+| `DELETE` | `/certificates/:uuid` | CHANGED: Use UUID param instead of numeric ID |
+
+#### UUID-to-uint Resolution for Certificate Assignment
+
+The `ProxyHost.CertificateID` field is `*uint` — this **will not change** to UUID. It remains a numeric foreign key. When the certificate assignment endpoint receives a certificate UUID (from the UI/API), the handler **must resolve UUID → numeric ID** via a DB lookup (`SELECT id FROM ssl_certificates WHERE uuid = ?`) before setting `ProxyHost.CertificateID`. Implementers must NOT attempt to change the FK type to UUID.
+
+#### New Endpoints
+
+| Method | Path | Description | Request | Response |
+|--------|------|-------------|---------|----------|
+| `GET` | `/certificates/:uuid` | Get certificate detail | — | `CertificateDetail` (full metadata, chain info, assigned hosts) |
+| `POST` | `/certificates/:uuid/export` | Export certificate | JSON body (format, include_key, pfx_password, password) | Binary file download |
+| `PUT` | `/certificates/:uuid` | Update certificate metadata (name) | JSON body (name) | `CertificateInfo` |
+| `POST` | `/certificates/validate` | Validate certificate without storing | Multipart (same as upload) | `ValidationResult` |
+
+#### Request/Response Schemas
+
+**Upload Request** (`POST /certificates`) — Multipart form:
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | string | Yes | Display name |
+| `certificate_file` | file | Yes | Certificate file (.pem, .crt, .cer, .pfx, .p12, .der) |
+| `key_file` | file | Conditional | Private key file (required for PEM/DER; not needed for PFX) |
+| `chain_file` | file | No | Intermediate chain file (PEM) |
+| `pfx_password` | string | Conditional | Password for PFX decryption |
+
+**Upload Response** (`201 Created`):
+
+```json
+{
+ "uuid": "a1b2c3d4-...",
+ "name": "My Wildcard Cert",
+ "common_name": "*.example.com",
+ "domains": "*.example.com,example.com",
+ "issuer": "custom",
+ "issuer_org": "DigiCert Inc",
+ "fingerprint": "AB:CD:EF:...",
+ "serial_number": "03:A1:...",
+ "key_type": "RSA-2048",
+ "expires_at": "2027-04-10T00:00:00Z",
+ "not_before": "2026-04-10T00:00:00Z",
+ "status": "valid",
+ "provider": "custom",
+ "chain_depth": 2,
+ "has_key": true,
+ "in_use": false
+}
+```
-#### HIGH Severity (must fix — merge-blocking)
+**Certificate Detail Response** (`GET /certificates/:uuid`):
+
+```json
+{
+ "uuid": "a1b2c3d4-...",
+ "name": "My Wildcard Cert",
+ "common_name": "*.example.com",
+ "domains": "*.example.com,example.com",
+ "issuer": "custom",
+ "issuer_org": "DigiCert Inc",
+ "fingerprint": "AB:CD:EF:...",
+ "serial_number": "03:A1:...",
+ "key_type": "RSA-2048",
+ "expires_at": "2027-04-10T00:00:00Z",
+ "not_before": "2026-04-10T00:00:00Z",
+ "status": "valid",
+ "provider": "custom",
+ "chain_depth": 2,
+ "has_key": true,
+ "in_use": true,
+ "assigned_hosts": [
+ {"uuid": "host-uuid-1", "name": "My App", "domain_names": "app.example.com"}
+ ],
+ "chain": [
+ {"subject": "*.example.com", "issuer": "DigiCert SHA2 Extended Validation Server CA", "expires_at": "2027-04-10T00:00:00Z"},
+ {"subject": "DigiCert SHA2 Extended Validation Server CA", "issuer": "DigiCert Global Root CA", "expires_at": "2031-11-10T00:00:00Z"}
+ ],
+ "auto_renew": false,
+ "created_at": "2026-04-10T12:00:00Z",
+ "updated_at": "2026-04-10T12:00:00Z"
+}
+```
-| # | CVE / GHSA | Package | Current | Fix | Binary | Dep Type |
-|---|-----------|---------|---------|-----|--------|----------|
-| 1 | CVE-2026-39883 | `go.opentelemetry.io/otel/sdk` | v1.40.0 | v1.43.0 | Caddy | Transitive (Caddy plugins → otelhttp → otel/sdk) |
-| 2 | CVE-2026-34986 | `github.com/go-jose/go-jose/v3` | v3.0.4 | **v3.0.5** | Caddy | Transitive (caddy-security → JWT/JOSE stack) |
-| 3 | CVE-2026-34986 | `github.com/go-jose/go-jose/v4` | v4.1.3 | **v4.1.4** | Caddy | Transitive (grpc v1.79.3 → go-jose/v4) |
-| 4 | CVE-2026-32286 | `github.com/jackc/pgproto3/v2` | v2.3.3 | pgx/v4 v4.18.3 ¹ | CrowdSec | Transitive (CrowdSec → pgx/v4 v4.18.2 → pgproto3/v2) |
+**Export Request** (`POST /certificates/:uuid/export`):
-¹ pgproto3/v2 has **no patched release**. Fix requires upstream migration to pgx/v5 (uses pgproto3/v3). See §5 Risk Assessment.
+```json
+{
+ "format": "pem",
+ "include_key": true,
+ "pfx_password": "optional-for-pfx",
+ "password": "current-user-password"
+}
+```
-#### MEDIUM Severity (fix in same pass)
+**R-EXP-04 Re-authentication Design**: When `include_key: true` is set, the request body **must** include the `password` field containing the current user's password. The export handler validates the password against the authenticated user's stored credentials before decrypting and returning the private key. If the password is missing or incorrect, the endpoint returns `403 Forbidden`. This prevents key exfiltration via stolen session tokens.
+
+```json
+// Example: export without key (no password required)
+{
+ "format": "pem",
+ "include_key": false
+}
+
+// Example: export with key (password confirmation required)
+{
+ "format": "pem",
+ "include_key": true,
+ "password": "MyCurrentPassword123"
+}
+```
-| # | CVE / GHSA | Package(s) | Current | Fix | Binary | Dep Type |
-|---|-----------|------------|---------|-----|--------|----------|
-| 5 | GHSA-xmrv-pmrh-hhx2 | AWS SDK v2: `eventstream` v1.7.1, `cloudwatchlogs` v1.57.2, `kinesis` v1.40.1, `s3` v1.87.3 | See left | Bump all | CrowdSec | Direct deps of CrowdSec v1.7.7 |
-| 6 | CVE-2026-32281, -32288, -32289 | Go stdlib | 1.26.1 | **1.26.2** | All (nightly image) | Toolchain |
-| 7 | CVE-2026-39882 | OTel HTTP exporters: `otlploghttp` v0.16.0, `otlpmetrichttp` v1.40.0, `otlptracehttp` v1.40.0 | See left | Bump all | Caddy | Transitive (Caddy plugins → OTel exporters) |
+**Validation Response** (`POST /certificates/validate`):
+
+```json
+{
+ "valid": true,
+ "common_name": "*.example.com",
+ "domains": ["*.example.com", "example.com"],
+ "issuer_org": "DigiCert Inc",
+ "expires_at": "2027-04-10T00:00:00Z",
+ "key_match": true,
+ "chain_valid": true,
+ "chain_depth": 2,
+ "warnings": ["Certificate expires in 365 days"],
+ "errors": []
+}
+```
-### 2.3 Dependency Chain Analysis
+### 4.3 Service Layer Changes
-#### Backend (`backend/go.mod`)
+#### Modified: `CertificateService` (`backend/internal/services/certificate_service.go`)
-```
-charon/backend (direct)
- └─ docker/docker v28.5.2+incompatible (direct)
- └─ otelhttp v0.68.0 (indirect)
- └─ otel/sdk v1.43.0 (indirect) — already at latest
- └─ grpc v1.79.3 (indirect)
- └─ otlptracehttp v1.42.0 (indirect) ── CVE-2026-39882
-```
+New/modified function signatures:
-Backend resolved versions (verified via `go list -m -json`):
+```go
+// NewCertificateService — MODIFIED: add encryption service dependency
+func NewCertificateService(dataDir string, db *gorm.DB, encSvc *crypto.EncryptionService) *CertificateService
-| Package | Version | Type |
-|---------|---------|------|
-| `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` | v1.42.0 | indirect |
-| `google.golang.org/grpc` | v1.79.3 | indirect |
-| `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` | v0.68.0 | indirect |
+// UploadCertificate — MODIFIED: accepts parsed content, validates, encrypts key
+func (s *CertificateService) UploadCertificate(name string, certPEM string, keyPEM string, chainPEM string) (*CertificateInfo, error)
-**Not present in backend**: go-jose/v3, go-jose/v4, otel/sdk, pgproto3/v2, AWS SDK, otlploghttp, otlpmetrichttp.
+// GetCertificate — NEW: get single certificate detail by UUID
+func (s *CertificateService) GetCertificate(uuid string) (*CertificateDetail, error)
-#### CrowdSec Binary (Dockerfile `crowdsec-builder` stage)
+// UpdateCertificate — NEW: update metadata (name)
+func (s *CertificateService) UpdateCertificate(uuid string, name string) (*CertificateInfo, error)
-Source: CrowdSec v1.7.7 `go.mod` (verified via `git clone --depth 1 --branch v1.7.7`):
+// DeleteCertificate — MODIFIED: accept UUID instead of numeric ID
+func (s *CertificateService) DeleteCertificate(uuid string) error
-```
-crowdsec v1.7.7
- └─ pgx/v4 v4.18.2 (direct) → pgproto3/v2 v2.3.3 (indirect) ── CVE-2026-32286
- └─ aws-sdk-go-v2/service/s3 v1.87.3 (direct) ── GHSA-xmrv-pmrh-hhx2
- └─ aws-sdk-go-v2/service/cloudwatchlogs v1.57.2 (direct) ── GHSA-xmrv-pmrh-hhx2
- └─ aws-sdk-go-v2/service/kinesis v1.40.1 (direct) ── GHSA-xmrv-pmrh-hhx2
- └─ aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 (indirect) ── GHSA-xmrv-pmrh-hhx2
- └─ otel v1.39.0, otel/metric v1.39.0, otel/trace v1.39.0 (indirect)
-```
+// IsCertificateInUse — MODIFIED: accept UUID instead of numeric ID
+func (s *CertificateService) IsCertificateInUse(uuid string) (bool, error)
-Confirmed by Trivy image scan (`trivy-image-report.json`): pgproto3/v2 v2.3.3 flagged in `usr/local/bin/crowdsec` and `usr/local/bin/cscli`.
+// ExportCertificate — NEW: export cert in requested format
+func (s *CertificateService) ExportCertificate(uuid string, format string, includeKey bool) ([]byte, string, error)
-#### Caddy Binary (Dockerfile `caddy-builder` stage)
+// ValidateCertificate — NEW: validate without storing
+func (s *CertificateService) ValidateCertificate(certPEM string, keyPEM string, chainPEM string) (*ValidationResult, error)
-Built via xcaddy with plugins. go.mod is generated at build time. Vulnerable packages enter via:
+// GetDecryptedPrivateKey — NEW: internal only, decrypt key for Caddy/export
+func (s *CertificateService) GetDecryptedPrivateKey(cert *models.SSLCertificate) (string, error)
-```
-xcaddy build (Caddy v2.11.2 + plugins)
- └─ caddy-security v1.1.61 → go-jose/v3 (JWT auth stack) ── CVE-2026-34986
- └─ grpc (patched to v1.79.3 in Dockerfile) → go-jose/v4 v4.1.3 ── CVE-2026-34986
- └─ Caddy/plugins → otel/sdk v1.40.0 ── CVE-2026-39883
- └─ Caddy/plugins → otlploghttp v0.16.0, otlpmetrichttp v1.40.0, otlptracehttp v1.40.0 ── CVE-2026-39882
+// CheckExpiringCertificates — NEW: called by scheduler
+func (s *CertificateService) CheckExpiringCertificates() ([]CertificateInfo, error)
+
+// MigratePrivateKeys — NEW: one-time migration from plaintext to encrypted
+func (s *CertificateService) MigratePrivateKeys() error
```
----
+#### New: `CertificateValidator` (`backend/internal/services/certificate_validator.go`)
+
+```go
+// ParseCertificateInput handles PEM, PFX, and DER input parsing
+func ParseCertificateInput(certData []byte, keyData []byte, chainData []byte, pfxPassword string) (*ParsedCertificate, error)
+
+// ValidateKeyMatch checks that the private key matches the certificate public key
+func ValidateKeyMatch(cert *x509.Certificate, key crypto.PrivateKey) error
-## 3. Technical Specifications
+// ValidateChain verifies the certificate chain from leaf to root.
+// Uses x509.Certificate.Verify() with an intermediate cert pool to validate
+// the chain against system roots (or provided root certificates).
+func ValidateChain(leaf *x509.Certificate, intermediates []*x509.Certificate) error
-### 3.1 Backend go.mod Changes
+// DetectFormat determines the certificate format from file content
+func DetectFormat(data []byte) (string, error)
-**File**: `backend/go.mod` (+ `backend/go.sum` auto-generated)
+// ConvertDERToPEM converts DER-encoded certificate to PEM
+func ConvertDERToPEM(derData []byte) (string, error)
-```bash
-cd backend
+// ConvertPFXToPEM extracts cert, key, and chain from PFX/PKCS12
+func ConvertPFXToPEM(pfxData []byte, password string) (certPEM string, keyPEM string, chainPEM string, err error)
-# Upgrade grpc to v1.80.0 (security patches for transitive deps)
-go get google.golang.org/grpc@v1.80.0
+// ConvertPEMToPFX bundles cert, key, chain into PFX
+func ConvertPEMToPFX(certPEM string, keyPEM string, chainPEM string, password string) ([]byte, error)
-# CVE-2026-39882: OTel HTTP exporter (backend only has otlptracehttp)
-go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0
+// ConvertPEMToDER converts PEM certificate to DER
+func ConvertPEMToDER(certPEM string) ([]byte, error)
-go mod tidy
+// ExtractCertificateMetadata extracts fingerprint, serial, issuer, key type, etc.
+func ExtractCertificateMetadata(cert *x509.Certificate) *CertificateMetadata
```
-Expected `go.mod` diff:
-- `google.golang.org/grpc` v1.79.3 → v1.80.0
-- `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` v1.42.0 → v1.43.0
-
-### 3.2 Dockerfile — Caddy Builder Stage Patches
-
-**File**: `Dockerfile`, within the caddy-builder `RUN bash -c '...'` block, in the **Stage 2: Apply security patches** section.
-
-Add after the existing `go get golang.org/x/net@v${XNET_VERSION};` line:
-
-```bash
-# CVE-2026-34986: go-jose JOSE/JWT validation bypass
-# Fix in v3.0.5 and v4.1.4. Pin here until caddy-security ships fix.
-# renovate: datasource=go depName=github.com/go-jose/go-jose/v3
-go get github.com/go-jose/go-jose/v3@v3.0.5; \
-# renovate: datasource=go depName=github.com/go-jose/go-jose/v4
-go get github.com/go-jose/go-jose/v4@v4.1.4; \
-# CVE-2026-39883: OTel SDK resource leak
-# Fix in v1.43.0. Pin here until Caddy ships with updated OTel.
-# renovate: datasource=go depName=go.opentelemetry.io/otel/sdk
-go get go.opentelemetry.io/otel/sdk@v1.43.0; \
-# CVE-2026-39882: OTel HTTP exporter request smuggling
-# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp
-go get go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp@v0.19.0; \
-# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp
-go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0; \
-# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
-go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0; \
+### 4.4 Caddy Integration Changes
+
+#### Modified: Config Generation (`backend/internal/caddy/config.go`)
+
+The existing custom certificate loading logic (lines 418-453) needs modification to:
+
+1. **Decrypt private keys** before passing to Caddy's `LoadPEM`
+2. **Include certificate chain** in the `Certificate` field (full PEM chain)
+3. **Add TLS automation policy** with `skip` for custom cert domains (prevent ACME from trying to issue for those domains)
+
+Updated custom cert loading block:
+
+```go
+for _, cert := range customCerts {
+ if cert.Certificate == "" || cert.PrivateKeyEncrypted == "" {
+ logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing data, skipping")
+ continue
+ }
+
+ decryptedKey, err := encSvc.Decrypt(cert.PrivateKeyEncrypted)
+ if err != nil {
+ logger.Log().WithError(err).WithField("cert", cert.Name).Warn("Failed to decrypt custom cert key, skipping")
+ continue
+ }
+
+ fullCert := cert.Certificate
+ if cert.CertificateChain != "" {
+ fullCert = cert.Certificate + "\n" + cert.CertificateChain
+ }
+
+ loadPEM = append(loadPEM, LoadPEMConfig{
+ Certificate: fullCert,
+ Key: string(decryptedKey),
+ Tags: []string{cert.UUID},
+ })
+}
```
-Update existing grpc patch line from `v1.79.3` → `v1.80.0`:
+Additionally, add a TLS automation policy that skips ACME for custom cert domains:
-```bash
-# Before:
-go get google.golang.org/grpc@v1.79.3; \
-# After:
-# CVE-2026-33186: gRPC-Go auth bypass (fixed in v1.79.3)
-# CVE-2026-34986: go-jose/v4 transitive fix (requires grpc >= v1.80.0)
-# renovate: datasource=go depName=google.golang.org/grpc
-go get google.golang.org/grpc@v1.80.0; \
+```go
+if len(customCertDomains) > 0 {
+ tlsPolicies = append(tlsPolicies, &AutomationPolicy{
+ Subjects: customCertDomains,
+ IssuersRaw: nil,
+ })
+}
```
-### 3.3 Dockerfile — CrowdSec Builder Stage Patches
-
-**File**: `Dockerfile`, within the crowdsec-builder `RUN` block that patches dependencies.
-
-Add after the existing `go get golang.org/x/net@v${XNET_VERSION}` line:
-
-```bash
-# CVE-2026-32286: pgproto3/v2 buffer overflow (no v2 fix exists; bump pgx/v4 to latest patch)
-# renovate: datasource=go depName=github.com/jackc/pgx/v4
-go get github.com/jackc/pgx/v4@v4.18.3 && \
-# GHSA-xmrv-pmrh-hhx2: AWS SDK v2 event stream injection
-# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
-go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.8 && \
-# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs
-go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.68.0 && \
-# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/kinesis
-go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.5 && \
-# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/s3
-go get github.com/aws/aws-sdk-go-v2/service/s3@v1.99.0 && \
+### 4.5 Encryption Strategy
+
+**Private Key Encryption at Rest**:
+
+1. On upload: `encSvc.Encrypt([]byte(keyPEM))` stores in `PrivateKeyEncrypted`
+2. On Caddy config generation: `encSvc.Decrypt(cert.PrivateKeyEncrypted)` passes decrypted PEM to Caddy
+3. On export: `encSvc.Decrypt(cert.PrivateKeyEncrypted)` converts to requested format
+4. `KeyVersion` tracks which encryption key version was used (for rotation via `RotationService`)
+
+**Migration**: Existing certificates with plaintext `PrivateKey` will be migrated to encrypted form during application startup if `CHARON_ENCRYPTION_KEY` is set. The migration:
+
+- Reads `private_key` column
+- Encrypts with current key
+- Writes to `private_key_enc` column
+- Sets `key_version = 1`
+- Clears `private_key` column
+- Logs migration progress
+
+### 4.6 Certificate Format Handling
+
+| Input Format | Detection | Processing |
+|-------------|-----------|------------|
+| **PEM** | Trial parse: `pem.Decode` succeeds | Direct parse via `pem.Decode` + `x509.ParseCertificate` |
+| **PFX/PKCS12** | Trial parse: if PEM fails, attempt `pkcs12.Decode` | `pkcs12.Decode(pfxData, password)` then extract cert, key, chain and store as PEM |
+| **DER** | Trial parse: if PEM and PFX fail, attempt `x509.ParseCertificate(raw)` | `x509.ParseCertificate(derBytes)` then convert to PEM for storage |
+
+**Detection strategy**: Use trial-parse (not magic bytes). Try PEM decode first → if that fails, try PFX/PKCS12 decode → if that also fails, try raw DER parse via `x509.ParseCertificate`. This is more reliable than magic byte sniffing, especially for DER which shares ASN.1 structure with PFX.
+
+**Dependencies**: Use `software.sslmate.com/src/go-pkcs12` for PFX handling (widely used, maintained).
+
+### 4.7 Expiry Warning Scheduler
+
+Add a background goroutine in `CertificateService` that runs daily:
+
+```go
+func (s *CertificateService) StartExpiryChecker(ctx context.Context, notificationSvc *NotificationService, warningDays int) {
+ // Startup delay: avoid notification bursts during frequent restarts
+ startupDelay := 5 * time.Minute
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(startupDelay):
+ }
+
+ // Add random jitter (0-60 minutes) to stagger checks across instances/restarts
+ jitter := time.Duration(rand.Int63n(int64(60 * time.Minute)))
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(jitter):
+ }
+
+ s.checkExpiry(notificationSvc, warningDays)
+
+ ticker := time.NewTicker(24 * time.Hour)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ s.checkExpiry(notificationSvc, warningDays)
+ }
+ }
+}
```
-CrowdSec grpc already at v1.80.0 — no change needed.
+**Configuration**: `warningDays` is read from `CHARON_CERT_EXPIRY_WARNING_DAYS` environment variable at startup (default: `30`). The startup wiring reads the config value and passes it to `StartExpiryChecker`.
-### 3.4 Example Workflow Fix
+The checker:
-**File**: `.github/skills/examples/gorm-scanner-ci-workflow.yml` (line 28)
+1. Queries all custom certificates
+2. For certs expiring within `warningDays` days: create warning notification + send external notification (rate-limited per cert per 24h)
+3. For expired certs: update status to "expired" + send critical notification
-```yaml
-# Before:
- go-version: "1.26.1"
-# After:
- go-version: "1.26.2"
+---
+
+## 5. Frontend Design
+
+### 5.1 New/Modified Components
+
+| Component | Type | Path | Description |
+|-----------|------|------|-------------|
+| `CertificateUploadDialog` | Modified | `frontend/src/components/dialogs/CertificateUploadDialog.tsx` | Extract from `Certificates.tsx`; add drag-and-drop, format detection, chain file, PFX password, validation preview |
+| `CertificateDetailDialog` | New | `frontend/src/components/dialogs/CertificateDetailDialog.tsx` | Full metadata view, chain visualization, assigned hosts list, export button |
+| `CertificateExportDialog` | New | `frontend/src/components/dialogs/CertificateExportDialog.tsx` | Format selector (PEM/PFX/DER), include-key toggle, PFX password field |
+| `CertificateValidationPreview` | New | `frontend/src/components/CertificateValidationPreview.tsx` | Shows parsed cert info before upload confirmation |
+| `CertificateChainViewer` | New | `frontend/src/components/CertificateChainViewer.tsx` | Visual chain display (leaf then intermediate then root) |
+| `FileDropZone` | New | `frontend/src/components/ui/FileDropZone.tsx` | Reusable drag-and-drop file upload component |
+| `CertificateList` | Modified | `frontend/src/components/CertificateList.tsx` | Add detail view button, export button, expiry warning badges, use UUID for actions |
+
+### 5.2 API Client Updates (`frontend/src/api/certificates.ts`)
+
+```typescript
+export interface Certificate {
+ uuid: string
+ name?: string
+ common_name?: string
+ domains: string
+ issuer: string
+ issuer_org?: string
+ fingerprint?: string
+ serial_number?: string
+ key_type?: string
+ expires_at: string
+ not_before?: string
+ status: 'valid' | 'expiring' | 'expired' | 'untrusted'
+ provider: string
+ chain_depth?: number
+ has_key: boolean
+ in_use: boolean
+}
+
+export interface CertificateDetail extends Certificate {
+ assigned_hosts: { uuid: string; name: string; domain_names: string }[]
+ chain: { subject: string; issuer: string; expires_at: string }[]
+ auto_renew: boolean
+ created_at: string
+ updated_at: string
+}
+
+export interface ValidationResult {
+ valid: boolean
+ common_name: string
+ domains: string[]
+ issuer_org: string
+ expires_at: string
+ key_match: boolean
+ chain_valid: boolean
+ chain_depth: number
+ warnings: string[]
+ errors: string[]
+}
+
+export async function getCertificateDetail(uuid: string): Promise
+export async function uploadCertificate(
+ name: string, certFile: File, keyFile?: File, chainFile?: File, pfxPassword?: string
+): Promise
+export async function updateCertificate(uuid: string, name: string): Promise
+export async function deleteCertificate(uuid: string): Promise
+export async function exportCertificate(
+ uuid: string, format: string, includeKey: boolean, pfxPassword?: string
+): Promise
+export async function validateCertificate(
+ certFile: File, keyFile?: File, chainFile?: File, pfxPassword?: string
+): Promise
```
-### 3.5 Go Stdlib CVEs (nightly branch — no code change needed)
+### 5.3 Hook Updates (`frontend/src/hooks/useCertificates.ts`)
+
+```typescript
+export function useCertificates(options?: UseCertificatesOptions)
+export function useCertificateDetail(uuid: string | null)
+export function useUploadCertificate()
+export function useUpdateCertificate()
+export function useDeleteCertificate()
+export function useExportCertificate()
+export function useValidateCertificate()
+```
-The nightly workflow syncs `development → nightly` via `git merge --ff-only`. Since `development` already has Go 1.26.2 everywhere:
-- Dockerfile `ARG GO_VERSION=1.26.2` ✓
-- All CI workflows `GO_VERSION: '1.26.2'` ✓
-- `backend/go.mod` `go 1.26.2` ✓
+### 5.4 Upload Flow UX
-The next nightly run at 09:00 UTC will automatically propagate Go 1.26.2 to the nightly branch and rebuild the image.
+1. User clicks "Add Certificate"
+2. **Upload Dialog** opens with:
+ - Name input field
+ - File drop zones (certificate file, key file, optional chain file)
+ - Auto-format detection on file drop/select (show detected format badge: PEM/PFX/DER)
+ - If PFX detected: show password field, hide key file input
+ - "Validate" button calls `/certificates/validate` and shows `CertificateValidationPreview`
+3. Validation preview shows: CN, SANs, issuer, expiry, chain depth, key-match status, warnings
+4. User confirms and submits to `POST /certificates`
+5. On success: toast + refresh list + close dialog
+
+### 5.5 Expiry Warning Display
+
+- Certificates expiring in 30 days or less: yellow warning badge + tooltip with days remaining
+- Expired certificates: red expired badge
+- The existing `status` field already provides `"expiring"` and `"expired"` values — the UI enhancement adds visual prominence
---
-## 4. Implementation Plan
+## 6. Security Considerations
-### Phase 1: Playwright Tests (N/A)
+### 6.1 Private Key Encryption
-No UI/UX changes — this is a dependency-only update. Existing E2E tests validate runtime behavior.
+- **🔴 ACTIVE VULNERABILITY FIX**: The current Upload handler (`certificate_handler.go:137`) returns `c.JSON(http.StatusCreated, cert)` where `cert` is the full `*SSLCertificate` struct. Because `PrivateKey` currently has `json:"private_key"`, the raw PEM private key is disclosed to the client in every upload response. **Commit 1 fixes this** by changing the tag to `json:"-"`, immediately closing this private key disclosure vulnerability.
+- All private keys encrypted at rest using AES-256-GCM
+- Encryption uses the same `CHARON_ENCRYPTION_KEY` and rotation infrastructure as DNS provider credentials
+- Keys are decrypted only in-memory when needed (Caddy reload, export)
+- The `PrivateKey` field is hidden from JSON serialization (`json:"-"`) and excluded from GORM queries (`gorm:"-"`)
+- The `PrivateKeyEncrypted` field is also hidden from JSON (`json:"-"`)
-### Phase 2: Backend Implementation
+### 6.2 File Upload Security
-| Task | File(s) | Action |
-|------|---------|--------|
-| 2.1 | `backend/go.mod`, `backend/go.sum` | Run `go get` commands from §3.1 |
-| 2.2 | Verify build | `cd backend && go build ./cmd/api` |
-| 2.3 | Verify vet | `cd backend && go vet ./...` |
-| 2.4 | Verify tests | `cd backend && go test ./...` |
-| 2.5 | Verify vulns | `cd backend && govulncheck ./...` |
+- Maximum file size: 1MB per file (enforced in handler)
+- File content validated (must parse as valid certificate/key/PFX)
+- No path traversal risk: files are read into memory, never written to arbitrary paths
+- Content-Type and extension validation (`.pem`, `.crt`, `.cer`, `.key`, `.pfx`, `.p12`, `.der`)
+- PFX password is not stored; used only during parsing
-### Phase 3: Dockerfile Implementation
+### 6.3 GORM Model Security
-| Task | File(s) | Action |
-|------|---------|--------|
-| 3.1 | `Dockerfile` (caddy-builder, ~L258-280) | Add go-jose v3/v4, OTel SDK, OTel exporter patches per §3.2 |
-| 3.2 | `Dockerfile` (caddy-builder, ~L270) | Update grpc patch v1.79.3 → v1.80.0 |
-| 3.3 | `Dockerfile` (crowdsec-builder, ~L360-370) | Add pgx, AWS SDK patches per §3.3 |
-| 3.3a | CrowdSec binaries | After patching deps, run `go build` on CrowdSec binaries before full Docker build for faster compilation feedback |
-| 3.4 | `Dockerfile` | Verify `docker build .` completes successfully (amd64) |
+- `SSLCertificate.ID` uses `json:"-"` (numeric ID hidden)
+- `SSLCertificate.Certificate` uses `json:"-"` (PEM content hidden from list)
+- `SSLCertificate.PrivateKey` uses `json:"-"` (transient, not persisted)
+- `SSLCertificate.PrivateKeyEncrypted` uses `json:"-"` (encrypted, hidden)
+- All API endpoints use UUID for identification
+- `CertificateInfo` no longer exposes numeric `ID`
-### Phase 4: CI / Misc Fixes
+### 6.4 Export Security
-| Task | File(s) | Action |
-|------|---------|--------|
-| 4.1 | `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Bump Go version 1.26.2 → 1.26.2 |
+- Export endpoint requires authentication (existing middleware)
+- `include_key: true` requires **password re-confirmation** — the user must supply their current password in the request body; the handler validates it before decrypting the key (implements R-EXP-04)
+- PFX export requires a password (enforced)
+- Audit log entry for key exports (via notification service)
-### Phase 5: Validation
+---
-| Task | Validation |
-|------|------------|
-| 5.1 | `cd backend && go build ./cmd/api` — compiles cleanly |
-| 5.2 | `cd backend && go test ./...` — all tests pass |
-| 5.3 | `cd backend && go vet ./...` — no issues |
-| 5.4 | `cd backend && govulncheck ./...` — 0 findings |
-| 5.5 | `docker build -t charon:vuln-fix .` — image builds for amd64 |
-| 5.6 | Trivy scan on built image: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --severity CRITICAL,HIGH charon:vuln-fix` — 0 HIGH (pgproto3/v2 excepted) |
-| 5.7 | Container health: `docker run -d -p 8080:8080 charon:vuln-fix && curl -f http://localhost:8080/health` |
-| 5.8 | E2E Playwright tests pass against rebuilt container |
+## 7. Implementation Phases (Tasks)
+
+### Phase 1: Backend Foundation — Model, Encryption, Validation (Commit 1)
+
+| # | Task | File(s) | Size | Description |
+|---|------|---------|------|-------------|
+| 1.1 | Add new fields to SSLCertificate model | `backend/internal/models/ssl_certificate.go` | S | Add `CommonName`, `CertificateChain`, `PrivateKeyEncrypted`, `KeyVersion`, `Fingerprint`, `SerialNumber`, `IssuerOrg`, `KeyType`, `NotBefore`. Hide sensitive fields from JSON. |
+| 1.2 | Update AutoMigrate | `backend/internal/api/routes/routes.go` | S | Already migrates `SSLCertificate`; GORM auto-adds new columns. |
+| 1.3 | Create certificate validator | `backend/internal/services/certificate_validator.go` | L | `ParseCertificateInput()`, `ValidateKeyMatch()`, `ValidateChain()`, `DetectFormat()`, format conversion functions. |
+| 1.4 | Add `go-pkcs12` dependency | `backend/go.mod` | S | `go get software.sslmate.com/src/go-pkcs12` |
+| 1.5 | Write private key migration function | `backend/internal/services/certificate_service.go` | M | `MigratePrivateKeys()` — encrypts existing plaintext keys. |
+| 1.6 | Modify `UploadCertificate()` | `backend/internal/services/certificate_service.go` | L | Full validation pipeline, encrypt key, store chain, extract metadata. |
+| 1.7 | Add `GetCertificate()` | `backend/internal/services/certificate_service.go` | M | Get single cert by UUID with full detail (assigned hosts, chain). |
+| 1.8 | Add `ValidateCertificate()` | `backend/internal/services/certificate_service.go` | M | Validate without storing. |
+| 1.9 | Modify `DeleteCertificate()` | `backend/internal/services/certificate_service.go` | S | Accept UUID instead of numeric ID. |
+| 1.10 | Add `ExportCertificate()` | `backend/internal/services/certificate_service.go` | M | Decrypt key, convert to requested format. |
+| 1.11 | Add `GetDecryptedPrivateKey()` | `backend/internal/services/certificate_service.go` | S | Internal decrypt helper. |
+| 1.12 | Update `CertificateInfo` | `backend/internal/services/certificate_service.go` | S | Remove numeric ID, add new metadata fields. |
+| 1.13 | Update `refreshCacheFromDB()` | `backend/internal/services/certificate_service.go` | M | Populate new fields (fingerprint, chain depth, has_key, in_use). |
+| 1.14 | Add constructor changes | `backend/internal/services/certificate_service.go` | S | Accept `*crypto.EncryptionService` in `NewCertificateService`. |
+| 1.15 | Unit tests for validator | `backend/internal/services/certificate_validator_test.go` | L | PEM/DER/PFX parsing, key match, chain validation, format detection. |
+| 1.16 | Unit tests for upload | `backend/internal/services/certificate_service_test.go` | L | Upload with encryption, migration, export. |
+| 1.17 | GORM security scan | — | S | Run `./scripts/scan-gorm-security.sh --check` on new model fields. |
+
+### Phase 2: Backend API — Handlers, Routes, Caddy (Commit 2)
+
+| # | Task | File(s) | Size | Description |
+|---|------|---------|------|-------------|
+| 2.1 | Update Upload handler | `backend/internal/api/handlers/certificate_handler.go` | L | Accept chain file, PFX password, detect format, call enhanced service. **Fix unsafe read**: replace `certSrc.Read(certBytes)` with `io.ReadAll(io.LimitReader(src, 1<<20))` for safe bounded reads (see Section 2.2 gaps). |
+| 2.2 | Add Get handler | `backend/internal/api/handlers/certificate_handler.go` | M | `GET /certificates/:uuid` calls `GetCertificate()`. |
+| 2.3 | Add Export handler | `backend/internal/api/handlers/certificate_handler.go` | M | `POST /certificates/:uuid/export` streams file download. |
+| 2.4 | Add Update handler | `backend/internal/api/handlers/certificate_handler.go` | S | `PUT /certificates/:uuid` updates name. |
+| 2.5 | Add Validate handler | `backend/internal/api/handlers/certificate_handler.go` | M | `POST /certificates/validate` validation-only endpoint. |
+| 2.6 | Modify Delete handler | `backend/internal/api/handlers/certificate_handler.go` | S | Use UUID param instead of numeric ID. |
+| 2.7 | Register new routes | `backend/internal/api/routes/routes.go` | S | Add new routes, pass encryption service. |
+| 2.8 | Update Caddy config generation | `backend/internal/caddy/config.go` | M | Decrypt keys, include chains, skip ACME for custom cert domains. |
+| 2.9 | Call migration on startup | `backend/internal/api/routes/routes.go` | S | Call `MigratePrivateKeys()` after service init. |
+| 2.10 | Handler unit tests | `backend/internal/api/handlers/certificate_handler_test.go` | L | Test all new endpoints. |
+| 2.11 | Caddy config tests | `backend/internal/caddy/config_test.go` | M | Update existing tests, add encrypted key test. |
+
+### Phase 3: Expiry Warnings & Notifications (within Commit 2)
+
+| # | Task | File(s) | Size | Description |
+|---|------|---------|------|-------------|
+| 3.1 | Add `CheckExpiringCertificates()` | `backend/internal/services/certificate_service.go` | M | Query custom certs expiring in 30 days or less. |
+| 3.2 | Add `StartExpiryChecker()` | `backend/internal/services/certificate_service.go` | M | Background goroutine, daily tick, rate-limited notifications. |
+| 3.3 | Wire scheduler on startup | `backend/internal/api/routes/routes.go` | S | Start goroutine with context from server. |
+| 3.4 | Unit tests for expiry checker | `backend/internal/services/certificate_service_test.go` | M | Mock time, verify notification calls. |
+
+### Phase 4: Frontend — Enhanced Upload, Detail, Export (Commit 3)
+
+| # | Task | File(s) | Size | Description |
+|---|------|---------|------|-------------|
+| 4.1 | Create `FileDropZone` component | `frontend/src/components/ui/FileDropZone.tsx` | M | Reusable drag-and-drop with format badge. |
+| 4.2 | Create `CertificateUploadDialog` | `frontend/src/components/dialogs/CertificateUploadDialog.tsx` | L | Full upload dialog with validation preview, chain, PFX. |
+| 4.3 | Create `CertificateValidationPreview` | `frontend/src/components/CertificateValidationPreview.tsx` | M | Parsed cert preview before upload. |
+| 4.4 | Create `CertificateDetailDialog` | `frontend/src/components/dialogs/CertificateDetailDialog.tsx` | L | Full metadata, chain, assigned hosts, export action. |
+| 4.5 | Create `CertificateChainViewer` | `frontend/src/components/CertificateChainViewer.tsx` | M | Visual chain display. |
+| 4.6 | Create `CertificateExportDialog` | `frontend/src/components/dialogs/CertificateExportDialog.tsx` | M | Format + key options. |
+| 4.7 | Update `CertificateList` | `frontend/src/components/CertificateList.tsx` | M | Add detail/export buttons, use UUID, expiry badges. |
+| 4.8 | Refactor `Certificates` page | `frontend/src/pages/Certificates.tsx` | M | Use new dialog components. |
+| 4.9 | Update API client | `frontend/src/api/certificates.ts` | M | New functions, updated types. |
+| 4.10 | Update hooks | `frontend/src/hooks/useCertificates.ts` | M | New hooks for detail, export, validate, update. |
+| 4.11 | Add translations | `frontend/src/locales/en/translation.json` (+ other locales) | S | New keys for chain, export, validation messages. |
+| 4.12 | Frontend unit tests | `frontend/src/components/__tests__/` | L | Tests for new components. |
+| 4.13 | Vitest coverage | — | M | Ensure 85% coverage on new code. |
+
+### Phase 5: E2E Tests & Hardening (Commit 4)
+
+| # | Task | File(s) | Size | Description |
+|---|------|---------|------|-------------|
+| 5.1 | E2E: Certificate upload flow | `tests/certificate-upload.spec.ts` | L | Upload PEM cert + key, validate preview, verify list. |
+| 5.2 | E2E: Certificate detail view | `tests/certificate-detail.spec.ts` | M | Open detail dialog, verify metadata, chain view. |
+| 5.3 | E2E: Certificate export | `tests/certificate-export.spec.ts` | M | Export PEM, verify download blob. |
+| 5.4 | E2E: Certificate assignment | `tests/certificate-assignment.spec.ts` | M | Assign cert to proxy host, verify Caddy reload. |
+| 5.5 | Update existing delete tests | `tests/certificate-delete.spec.ts` | S | Use UUID instead of numeric ID. |
+| 5.6 | CodeQL scans | — | S | Run Go + JS security scans. |
+| 5.7 | GORM security scan | — | S | Final scan on all model changes. |
+| 5.8 | Update documentation | `docs/features.md`, `CHANGELOG.md` | S | Document new capabilities. |
---
-## 5. Risk Assessment
+## 8. Commit Slicing Strategy
-### Low Risk
+### Decision: 1 PR with 5 logical commits
-| Change | Risk | Rationale |
-|--------|------|-----------|
-| `go-jose/v3` v3.0.4 → v3.0.5 | Low | Security patch release only |
-| `go-jose/v4` v4.1.3 → v4.1.4 | Low | Security patch release only |
-| `otel/sdk` v1.40.0 → v1.43.0 (Caddy) | Low | Minor bumps, backwards compatible |
-| `otlptracehttp` v1.42.0 → v1.43.0 (backend) | Low | Minor bump |
-| OTel exporters (Caddy) | Low | Minor/patch bumps |
-| Go version example fix | None | Non-runtime file |
+**Rationale**: Single feature = single PR. Charon is a self-hosted tool where users track merged PRs to know when features are available. Merging partial PRs (e.g., backend-only) creates false confidence that a feature is complete, leading to user-filed issues and discussions asking why the feature is missing or broken. A single PR ensures the feature ships atomically — users see one merge and get the full capability.
-### Medium Risk
+Each commit maps to an implementation phase; this keeps the diff reviewable by walking through commits sequentially while guaranteeing the feature is never partially deployed.
-| Change | Risk | Mitigation |
-|--------|------|------------|
-| `grpc` v1.79.3 → v1.80.0 | Medium | Minor version bump. gRPC is indirect — Charon doesn't use gRPC directly. Run full test suite. Verify Caddy and CrowdSec still compile. |
-| AWS SDK major bumps (s3 v1.87→v1.99, cloudwatchlogs v1.57→v1.68, kinesis v1.40→v1.43) | Medium | CrowdSec build may fail if internal APIs changed between versions. Mitigate: run `go mod tidy` after patches and verify CrowdSec binaries compile. **Note:** AWS SDK Go v2 packages use independent semver within the `v1.x.x` line — these are minor version bumps, not major API breaks. |
-| `pgx/v4` v4.18.2 → v4.18.3 | Medium | Patch release should be safe. May not fully resolve pgproto3/v2 since no patched v2 exists. |
+### Commit Structure
-### Known Limitation: pgproto3/v2 (CVE-2026-32286)
+| Commit | Phase | Scope | Key Files |
+|--------|-------|-------|-----------|
+| **Commit 1** | Backend Foundation | Tasks 1.1–1.17 | `backend/internal/models/ssl_certificate.go`, `backend/internal/services/certificate_validator.go`, `backend/internal/services/certificate_service.go`, `backend/go.mod`, `backend/go.sum`, test files |
+| **Commit 2** | Backend API + Caddy + Expiry | Tasks 2.1–2.11, 3.1–3.4 | `backend/internal/api/handlers/certificate_handler.go`, `backend/internal/api/routes/routes.go`, `backend/internal/caddy/config.go`, test files |
+| **Commit 3** | Frontend | Tasks 4.1–4.13 | `frontend/src/` components, pages, API client, hooks, locales |
+| **Commit 4** | E2E Tests & Hardening | Tasks 5.1–5.7 | `tests/` E2E specs, CodeQL/GORM scans |
+| **Commit 5** | Documentation | Task 5.8 | `docs/features.md`, `CHANGELOG.md` |
-The `pgproto3/v2` module has **no patched release** — the fix exists only in `pgproto3/v3` (used by `pgx/v5`). CrowdSec v1.7.7 uses `pgx/v4` which depends on `pgproto3/v2`. Remediation:
+### Commit Descriptions
-1. Bump `pgx/v4` to v4.18.3 (latest v4 patch) — may transitively resolve the issue
-2. If scanner still flags pgproto3/v2 after the bump: document as **accepted risk with upstream tracking**
-3. Monitor CrowdSec releases for `pgx/v5` migration
-4. Consider upgrading `CROWDSEC_VERSION` ARG if a newer CrowdSec release ships with pgx/v5
+#### Commit 1: Backend Foundation (Model + Validator + Encryption)
+- SSLCertificate model with all new fields and correct JSON tags
+- Certificate validator: PEM, DER, PFX parsing, key-cert match, chain validation
+- Private key encryption/decryption via `CHARON_ENCRYPTION_KEY`
+- Migration function for existing plaintext keys
+- Unit tests with 85% coverage on new code
+- GORM security scan clean
----
+#### Commit 2: Backend API (Handlers + Routes + Caddy + Expiry Checker)
+- Upload endpoint accepts PEM/PFX/DER with safe bounded reads
+- Get/Export/Validate/Update endpoints (UUID-based)
+- Delete uses UUID instead of numeric ID
+- Caddy loads encrypted custom certs with chain support
+- Expiry checker: background goroutine, daily tick, notifications for certs expiring within 30 days
+- Handler and Caddy config unit tests
-## 6. Acceptance Criteria
+#### Commit 3: Frontend (Upload + Detail + Export + UI Enhancements)
+- Enhanced upload dialog with drag-and-drop, format detection, chain file, PFX password
+- Validation preview before upload
+- Certificate detail dialog with chain viewer
+- Export dialog with format selection and key password confirmation
+- List uses UUID for all operations, expiry warning badges
+- Vitest coverage at 85% on new components
-- [ ] `cd backend && go build ./cmd/api` succeeds with zero warnings
-- [ ] `cd backend && go test ./...` passes with zero failures
-- [ ] `cd backend && go vet ./...` reports zero issues
-- [ ] `cd backend && govulncheck ./...` reports zero findings
-- [ ] Docker image builds successfully for amd64
-- [ ] Trivy/Grype scan of built image shows 0 new HIGH findings (pgproto3/v2 excepted if upstream unpatched)
-- [ ] Container starts, health check passes on port 8080
-- [ ] Existing E2E Playwright tests pass against rebuilt container
-- [ ] No new compile errors in Caddy or CrowdSec builder stages
-- [ ] `backend/go.mod` shows updated versions for grpc, otlptracehttp
+#### Commit 4: E2E Tests & Hardening
+- E2E tests covering upload, detail, export, assignment flows
+- Existing delete tests updated for UUID
+- CodeQL Go + JS scans clean (no HIGH/CRITICAL)
+- GORM security scan clean
----
+#### Commit 5: Documentation
+- `docs/features.md` updated with certificate management capabilities
+- `CHANGELOG.md` updated
+
+### PR-Level Validation Gates
-## 7. Commit Slicing Strategy
+The PR is merged only when **all** of the following pass:
-### Decision: Single PR
+- [ ] All backend unit tests pass with 85% coverage on new code
+- [ ] All frontend Vitest tests pass with 85% coverage on new code
+- [ ] All E2E tests pass (Firefox, Chromium, WebKit)
+- [ ] GORM security scan clean (`./scripts/scan-gorm-security.sh --check`)
+- [ ] CodeQL Go + JS scans: no HIGH/CRITICAL findings
+- [ ] staticcheck pass
+- [ ] TypeScript check pass
+- [ ] Local patch coverage report generated and reviewed
+- [ ] Documentation updated
-**Rationale**: All changes are dependency version bumps with no feature or behavioral changes. They address a single concern (security vulnerability remediation) and should be reviewed and merged atomically to avoid partial-fix states.
+### Rollback
-**Trigger reasons for single PR**:
-- All changes are security patches — cannot ship partial fixes
-- Changes span backend + Dockerfile + CI config — logically coupled
-- No risk of one slice breaking another
-- Total diff is small (go.mod/go.sum + Dockerfile patch lines + 1 YAML fix)
+Revert the single PR. All changes are additive (new columns, new endpoints, new components). Reverting removes the feature atomically with no partial state left in production.
+
+---
-### PR-1: Nightly Build Vulnerability Remediation
+## 9. Testing Strategy
-**Scope**: All changes in §3.1–§3.4
+### 9.1 Backend Unit Tests
-**Files modified**:
+| Test File | Coverage |
+|-----------|----------|
+| `backend/internal/services/certificate_validator_test.go` | PEM/DER/PFX parsing, key match (RSA + ECDSA), chain validation (valid/invalid/self-signed), format detection, error cases |
+| `backend/internal/services/certificate_service_test.go` | Upload (all formats), encryption/decryption, migration, list (with new fields), get detail, export (all formats), delete by UUID, expiry checker, cache invalidation |
+| `backend/internal/api/handlers/certificate_handler_test.go` | All endpoints: upload (multipart), get, export (file download), validate, update, delete; error cases (invalid format, missing key, expired cert) |
+| `backend/internal/caddy/config_test.go` | Custom cert with encrypted key, chain inclusion, ACME skip for custom cert domains |
-| File | Change Type |
-|------|-------------|
-| `backend/go.mod` | Dependency version bumps (grpc, otlptracehttp) |
-| `backend/go.sum` | Auto-generated checksum updates |
-| `Dockerfile` | Add `go get` patches in caddy-builder and crowdsec-builder stages |
-| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Go version 1.26.2 → 1.26.2 |
+### 9.2 Frontend Unit Tests
-**Dependencies**: None (standalone)
+| Test File | Coverage |
+|-----------|----------|
+| `frontend/src/components/__tests__/FileDropZone.test.tsx` | Drag-and-drop, file selection, format detection badge |
+| `frontend/src/components/__tests__/CertificateUploadDialog.test.tsx` | Full upload flow, PFX mode toggle, validation preview |
+| `frontend/src/components/__tests__/CertificateDetailDialog.test.tsx` | Metadata display, chain viewer, export action |
+| `frontend/src/components/__tests__/CertificateExportDialog.test.tsx` | Format selection, key toggle, PFX password |
+| `frontend/src/components/__tests__/CertificateList.test.tsx` | Updated: UUID-based actions, expiry badges, detail button |
+| `frontend/src/hooks/__tests__/useCertificates.test.ts` | New hooks: detail, export, validate |
-**Validation gates**:
-1. `go build` / `go test` / `go vet` / `govulncheck` pass
-2. Docker image builds for amd64
-3. Trivy/Grype scan passes (0 new HIGH)
-4. E2E tests pass
+### 9.3 E2E Playwright Tests
-**Rollback**: Revert PR. All changes are version pins — reverting restores previous state with no data migration needed.
+| Spec File | Scenarios |
+|-----------|-----------|
+| `tests/certificate-upload.spec.ts` | Upload PEM cert + key, validate preview, verify list |
+| `tests/certificate-detail.spec.ts` | Open detail dialog, verify metadata, chain view |
+| `tests/certificate-export.spec.ts` | Export PEM, verify download blob |
+| `tests/certificate-assignment.spec.ts` | Assign cert to proxy host, verify Caddy reload |
+| `tests/certificate-delete.spec.ts` | Updated: UUID-based deletion |
+| `tests/certificate-bulk-delete.spec.ts` | Updated: UUID-based bulk deletion |
-### Post-merge Actions
+#### Negative / Error Scenarios (Commit 4)
-1. Nightly build will automatically sync development → nightly and rebuild the image with all patches
-2. Monitor next nightly scan for zero HIGH findings
-3. If pgproto3/v2 still flagged: open tracking issue for CrowdSec pgx/v5 upstream migration
-4. If any AWS SDK bump breaks CrowdSec compilation: pin to intermediate version and document
+| Spec File | Scenarios |
+|-----------|-----------|
+| `tests/certificate-upload-errors.spec.ts` | Mismatched key/cert upload (expect error), invalid file upload (non-cert file), expired cert upload (expect warning + accept), oversized file upload (expect 413) |
+| `tests/certificate-export-auth.spec.ts` | Export with `include_key: true` flow — verify password confirmation required, verify incorrect password rejected, verify export without key does not require password |
+
+### 9.4 Security Scans
+
+- GORM security scan (`./scripts/scan-gorm-security.sh --check`) — after Phase 1
+- CodeQL Go scan — after Phase 2
+- CodeQL JS scan — after Phase 3
+- Trivy container scan — after final build
---
-## 8. CI Failure Amendment: pgx/v4 Module Path Mismatch
+## 10. Config/Infrastructure Changes
-**Date**: 2026-04-09
-**Failure**: PR #921 `build-and-push` job, step `crowdsec-builder 7/11`
-**Error**: `go: github.com/jackc/pgx/v4@v5.9.1: invalid version: go.mod has non-.../v4 module path "github.com/jackc/pgx/v5" (and .../v4/go.mod does not exist) at revision v5.9.1`
+### 10.1 No Changes Required
-### Root Cause
+| File | Reason |
+|------|--------|
+| `.gitignore` | Uploaded certificates stored in database, not on disk. Existing `/data/` ignore covers Caddy runtime data. |
+| `codecov.yml` | Existing configuration covers `backend/` and `frontend/src/`. |
+| `.dockerignore` | No new file types to ignore. |
+| `Dockerfile` | `go-pkcs12` dependency is a Go module pulled during build automatically. |
-Dockerfile line 386 specifies `go get github.com/jackc/pgx/v4@v5.9.1`. This mixes the v4 module path with a v5 version tag. Go's semantic import versioning rejects this because tag `v5.9.1` declares module path `github.com/jackc/pgx/v5` in its go.mod.
+### 10.2 Environment Variables
-### Fix
+| Variable | Status | Description |
+|----------|--------|-------------|
+| `CHARON_ENCRYPTION_KEY` | Existing | Required for private key encryption. Already used for DNS provider credentials. |
+| `CHARON_ENCRYPTION_KEY_NEXT` | Existing | Used during key rotation. Rotation service already handles re-encryption. |
+| `CHARON_CERT_EXPIRY_WARNING_DAYS` | New (optional) | Override default 30-day warning threshold. Default: `30`. Wired into `StartExpiryChecker()` at startup — see Section 4.7. |
-**Dockerfile line 386** — change:
-```dockerfile
-go get github.com/jackc/pgx/v4@v5.9.1 && \
-```
-to:
-```dockerfile
-go get github.com/jackc/pgx/v4@v4.18.3 && \
-```
+### 10.3 Database Migration
-No changes needed to the Renovate annotation (line 385) or the CVE comment (line 384) — both are already correct.
+GORM `AutoMigrate` handles additive column changes automatically. The private key migration from plaintext to encrypted is a one-time startup operation handled in code (see section 4.5).
-### Why v4.18.3
+**Migration sequence**:
-- CrowdSec v1.7.7 uses `github.com/jackc/pgx/v4 v4.18.2` (direct dependency)
-- v4.18.3 is the latest and likely final v4 release
-- pgproto3/v2 is archived at v2.3.3 (July 2025) — no fix will be released in the v2 line
-- The CVE (pgproto3/v2 buffer overflow) can only be fully resolved by CrowdSec migrating to pgx/v5 upstream
-- Bumping pgx/v4 to v4.18.3 gets the latest v4 maintenance patch; the CVE remains an accepted risk per §5
+1. GORM adds new columns (`common_name`, `certificate_chain`, `private_key_enc`, `key_version`, `fingerprint`, `serial_number`, `issuer_org`, `key_type`, `not_before`)
+2. `MigratePrivateKeys()` runs once: reads `private_key`, encrypts to `private_key_enc`, clears `private_key`
+3. Subsequent starts skip migration (checks if any rows have `private_key` non-empty and `private_key_enc` empty)
-### Validation
+---
+
+## 11. Risks and Mitigations
-The same `docker build` that previously failed at step 7/11 should now pass through the CrowdSec dependency patching stage and proceed to compilation (steps 8-11).
+| Risk | Likelihood | Impact | Mitigation |
+|------|-----------|--------|------------|
+| **Private key migration fails mid-way** | Low | High | Migration is transactional per-row. Idempotent — can be re-run. Original `private_key` column kept until migration verified complete. |
+| **`CHARON_ENCRYPTION_KEY` not set** | Medium | High | Graceful degradation: upload/export of custom certs disabled when key not set. Clear error message in UI. ACME certs unaffected. |
+| **PFX parsing edge cases** | Medium | Medium | Use well-maintained `go-pkcs12` library. Comprehensive test suite with real-world PFX files. Fall back to descriptive error messages. |
+| **Caddy reload failure with bad cert** | Low | High | Caddy config generation validates cert/key pairing before including in config. Caddy itself validates on load and reports errors. Rollback logic already exists in Caddy manager. |
+| **Breaking API change (numeric ID to UUID)** | Medium | Medium | Frontend and backend changes in separate PRs but deployed together. No external API consumers currently (self-hosted tool). Existing E2E tests catch regressions. |
+| **Performance impact of encryption/decryption** | Low | Low | AES-256-GCM is hardware-accelerated on modern CPUs. Only custom certs are encrypted (typically fewer than 10 per instance). Caddy config generation is not a hot path. |
+| **Large file upload DoS** | Low | Medium | 1MB file size limit enforced in handler. Gin's `MaxMultipartMemory` also provides protection. |
---
-## 9. Commands Reference
-
-```bash
-# === Backend dependency upgrades ===
-cd /projects/Charon/backend
-
-go get google.golang.org/grpc@v1.80.0
-go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0
-go mod tidy
-
-# === Validate backend ===
-go build ./cmd/api
-go test ./...
-go vet ./...
-govulncheck ./...
-
-# === Docker build (after Dockerfile edits) ===
-cd /projects/Charon
-docker build -t charon:vuln-fix .
-
-# === Scan built image ===
-docker run --rm \
- -v /var/run/docker.sock:/var/run/docker.sock \
- aquasec/trivy:latest image \
- --severity CRITICAL,HIGH \
- charon:vuln-fix
-
-# === Quick container health check ===
-docker run -d --name charon-vuln-test -p 8080:8080 charon:vuln-fix
-sleep 10
-curl -f http://localhost:8080/health
-docker stop charon-vuln-test && docker rm charon-vuln-test
-```
+## 12. Acceptance Criteria (Definition of Done)
+
+- [ ] Can upload custom certificates in PEM, PFX, and DER formats
+- [ ] Certificate and key are validated before acceptance (format, key match, chain)
+- [ ] Private keys are encrypted at rest using `CHARON_ENCRYPTION_KEY`
+- [ ] Certificate detail view shows full metadata (CN, SANs, issuer, chain, fingerprint)
+- [ ] Certificates can be assigned to proxy hosts
+- [ ] Caddy serves custom certificates for assigned domains
+- [ ] Expiry warnings fire as in-app and external notifications at 30 days
+- [ ] Certificates can be exported in PEM, PFX, and DER formats
+- [ ] All API endpoints use UUID (no numeric ID exposure)
+- [ ] 85% test coverage on all new backend and frontend code
+- [ ] E2E tests pass for upload, detail, export, assignment flows
+- [ ] GORM security scan reports zero CRITICAL/HIGH findings
+- [ ] CodeQL scans report zero HIGH/CRITICAL findings
+- [ ] No plaintext private keys in database after migration
From bb99dacecd56ca63e0c235b719fb99bb5c35f92c Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Sat, 11 Apr 2026 17:33:44 +0000
Subject: [PATCH 06/57] fix: update zlib and add libcrypto3 and libssl3 for
improved security
---
Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Dockerfile b/Dockerfile
index 7a31c7f47..bae1962ba 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -469,7 +469,7 @@ WORKDIR /app
RUN apk add --no-cache \
bash ca-certificates sqlite-libs sqlite tzdata gettext libcap libcap-utils \
c-ares busybox-extras \
- && apk upgrade --no-cache zlib
+ && apk upgrade --no-cache zlib libcrypto3 libssl3
# Copy gosu binary from gosu-builder (built with Go 1.26+ to avoid stdlib CVEs)
COPY --from=gosu-builder /gosu-out/gosu /usr/sbin/gosu
From b15f7c3fbcf918c7003ca3e5a696f23b91f8d0c5 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Sat, 11 Apr 2026 17:47:55 +0000
Subject: [PATCH 07/57] fix(deps): update non-major-updates
---
frontend/package-lock.json | 24 ++++++++++++------------
frontend/package.json | 4 ++--
2 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a69ca32fd..f56434345 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,7 +14,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
- "@tanstack/react-query": "^5.97.0",
+ "@tanstack/react-query": "^5.98.0",
"axios": "1.15.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -70,7 +70,7 @@
"eslint-plugin-unicorn": "^64.0.0",
"eslint-plugin-unused-imports": "^4.4.1",
"jsdom": "29.0.2",
- "knip": "^6.3.1",
+ "knip": "^6.4.0",
"postcss": "^8.5.9",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
@@ -3288,9 +3288,9 @@
}
},
"node_modules/@tanstack/query-core": {
- "version": "5.97.0",
- "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz",
- "integrity": "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg==",
+ "version": "5.98.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.98.0.tgz",
+ "integrity": "sha512-v1g54qnirFVWMicVO6hurxez+YrJG3h9AQ6RZqlBhUZfNLRJjjxjjYcriL1s2xLGGNvE2IXpYlUzm22GXa1BQA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3298,12 +3298,12 @@
}
},
"node_modules/@tanstack/react-query": {
- "version": "5.97.0",
- "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz",
- "integrity": "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==",
+ "version": "5.98.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.98.0.tgz",
+ "integrity": "sha512-HnXce1ckrBH+ORRFtP5YQOYSFzb91KOhKw+UUSxI+sDr89wMfwUmyG0Ayc5EGRVJld40h65F6YzXaMucUXyU6g==",
"license": "MIT",
"dependencies": {
- "@tanstack/query-core": "5.97.0"
+ "@tanstack/query-core": "5.98.0"
},
"funding": {
"type": "github",
@@ -8055,9 +8055,9 @@
}
},
"node_modules/knip": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/knip/-/knip-6.3.1.tgz",
- "integrity": "sha512-22kLJloVcOVOAudCxlFOC0ICAMme7dKsS7pVTEnrmyKGpswb8ieznvAiSKUeFVDJhb01ect6dkDc1Ha1g1sPpg==",
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/knip/-/knip-6.4.0.tgz",
+ "integrity": "sha512-SAEeggehgkPdoLZWVEcFKzPw+vNlnrUBDqcX8cOcHGydRInSn5pnn9LN3dDJ8SkDHKXR7xYzNq3HtRJaYmxOHg==",
"dev": true,
"funding": [
{
diff --git a/frontend/package.json b/frontend/package.json
index 85a8587eb..f0b55df13 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -33,7 +33,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
- "@tanstack/react-query": "^5.97.0",
+ "@tanstack/react-query": "^5.98.0",
"axios": "1.15.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -89,7 +89,7 @@
"eslint-plugin-unicorn": "^64.0.0",
"eslint-plugin-unused-imports": "^4.4.1",
"jsdom": "29.0.2",
- "knip": "^6.3.1",
+ "knip": "^6.4.0",
"postcss": "^8.5.9",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
From 42bc8976104578b214d4b0d01b0783f6e968022e Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Sat, 11 Apr 2026 17:54:42 +0000
Subject: [PATCH 08/57] fix: enhance certificate deletion handling with UUID
validation and logging improvements
---
.../api/handlers/certificate_handler.go | 41 ++++++++++++++-----
.../certificate_handler_coverage_test.go | 10 ++---
.../certificate_handler_security_test.go | 6 +--
.../api/handlers/certificate_handler_test.go | 24 +++++------
4 files changed, 50 insertions(+), 31 deletions(-)
diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go
index e5a1f1ddf..ed2c630d6 100644
--- a/backend/internal/api/handlers/certificate_handler.go
+++ b/backend/internal/api/handlers/certificate_handler.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/gin-gonic/gin"
+ "github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/logger"
@@ -367,7 +368,7 @@ func (h *CertificateHandler) Export(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
- logger.Log().WithError(err).Error("failed to export certificate")
+ logger.Log().WithError(fmt.Errorf("%s", util.SanitizeForLog(err.Error()))).Error("failed to export certificate")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export certificate"})
return
}
@@ -423,19 +424,21 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
return
}
- // UUID path - value isn't numeric, validate it looks like a UUID
- if idStr == "" || idStr == "0" || len(idStr) < 32 {
+ // UUID path - parse to validate format and produce a canonical, safe string
+ parsedUUID, parseErr := uuid.Parse(idStr)
+ if parseErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
+ certUUID := parsedUUID.String()
- inUse, err := h.service.IsCertificateInUseByUUID(idStr)
+ inUse, err := h.service.IsCertificateInUseByUUID(certUUID)
if err != nil {
if err == services.ErrCertNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
- logger.Log().WithError(err).WithField("certificate_uuid", idStr).Error("failed to check certificate usage")
+ logger.Log().WithError(err).WithField("certificate_uuid", util.SanitizeForLog(certUUID)).Error("failed to check certificate usage")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"})
return
}
@@ -459,7 +462,7 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
}
}
- if err := h.service.DeleteCertificate(idStr); err != nil {
+ if err := h.service.DeleteCertificate(certUUID); err != nil {
if err == services.ErrCertInUse {
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
return
@@ -468,12 +471,12 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
- logger.Log().WithError(err).WithField("certificate_uuid", idStr).Error("failed to delete certificate")
+ logger.Log().WithError(err).WithField("certificate_uuid", util.SanitizeForLog(certUUID)).Error("failed to delete certificate")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"})
return
}
- h.sendDeleteNotification(c, idStr)
+ h.sendDeleteNotification(c, certUUID)
c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
}
@@ -482,11 +485,15 @@ func (h *CertificateHandler) sendDeleteNotification(c *gin.Context, certRef stri
return
}
+ // Re-validate to produce a CodeQL-safe value (breaks taint from user input).
+ // Callers already pass validated data; this is defense-in-depth.
+ safeRef := sanitizeCertRef(certRef)
+
h.notificationMu.Lock()
lastTime, exists := h.lastNotificationTime[certRef]
if exists && time.Since(lastTime) < 10*time.Second {
h.notificationMu.Unlock()
- logger.Log().WithField("certificate_ref", certRef).Debug("notification rate limited")
+ logger.Log().WithField("certificate_ref", safeRef).Debug("notification rate limited")
return
}
h.lastNotificationTime[certRef] = time.Now()
@@ -495,10 +502,22 @@ func (h *CertificateHandler) sendDeleteNotification(c *gin.Context, certRef stri
h.notificationService.SendExternal(c.Request.Context(),
"cert",
"Certificate Deleted",
- fmt.Sprintf("Certificate %s deleted", certRef),
+ fmt.Sprintf("Certificate %s deleted", safeRef),
map[string]any{
- "Ref": certRef,
+ "Ref": safeRef,
"Action": "deleted",
},
)
}
+
+// sanitizeCertRef re-validates a certificate reference (UUID or numeric ID)
+// and returns a safe string representation. Returns a placeholder if invalid.
+func sanitizeCertRef(ref string) string {
+ if parsed, err := uuid.Parse(ref); err == nil {
+ return parsed.String()
+ }
+ if n, err := strconv.ParseUint(ref, 10, 64); err == nil {
+ return strconv.FormatUint(n, 10)
+ }
+ return "[invalid-ref]"
+}
diff --git a/backend/internal/api/handlers/certificate_handler_coverage_test.go b/backend/internal/api/handlers/certificate_handler_coverage_test.go
index 146fd1584..30131600a 100644
--- a/backend/internal/api/handlers/certificate_handler_coverage_test.go
+++ b/backend/internal/api/handlers/certificate_handler_coverage_test.go
@@ -36,7 +36,7 @@ func TestCertificateHandler_Delete_InvalidID(t *testing.T) {
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/invalid", http.NoBody)
w := httptest.NewRecorder()
@@ -52,7 +52,7 @@ func TestCertificateHandler_Delete_NotFound(t *testing.T) {
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/9999", http.NoBody)
w := httptest.NewRecorder()
@@ -74,7 +74,7 @@ func TestCertificateHandler_Delete_NoBackupService(t *testing.T) {
// No backup service
h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
@@ -97,7 +97,7 @@ func TestCertificateHandler_Delete_CheckUsageDBError(t *testing.T) {
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
@@ -137,7 +137,7 @@ func TestCertificateHandler_Delete_ZeroID(t *testing.T) {
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/0", http.NoBody)
w := httptest.NewRecorder()
diff --git a/backend/internal/api/handlers/certificate_handler_security_test.go b/backend/internal/api/handlers/certificate_handler_security_test.go
index 08a062ad1..95f77f69a 100644
--- a/backend/internal/api/handlers/certificate_handler_security_test.go
+++ b/backend/internal/api/handlers/certificate_handler_security_test.go
@@ -32,7 +32,7 @@ func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) {
})
svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/1", http.NoBody)
w := httptest.NewRecorder()
@@ -135,7 +135,7 @@ func TestCertificateHandler_Delete_DiskSpaceCheck(t *testing.T) {
}
h := NewCertificateHandler(svc, mockBackup, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert.ID), http.NoBody)
w := httptest.NewRecorder()
@@ -186,7 +186,7 @@ func TestCertificateHandler_Delete_NotificationRateLimiting(t *testing.T) {
}
h := NewCertificateHandler(svc, mockBackup, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
// Delete first cert
req1 := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert1.ID), http.NoBody)
diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go
index f8b23797b..fc45f0efe 100644
--- a/backend/internal/api/handlers/certificate_handler_test.go
+++ b/backend/internal/api/handlers/certificate_handler_test.go
@@ -41,7 +41,7 @@ func setupCertTestRouter(t *testing.T, db *gorm.DB) *gin.Engine {
svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
return r
}
@@ -123,7 +123,7 @@ func TestDeleteCertificate_CreatesBackup(t *testing.T) {
}
h := NewCertificateHandler(svc, mockBackupService, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
@@ -174,7 +174,7 @@ func TestDeleteCertificate_BackupFailure(t *testing.T) {
}
h := NewCertificateHandler(svc, mockBackupService, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
@@ -229,7 +229,7 @@ func TestDeleteCertificate_InUse_NoBackup(t *testing.T) {
}
h := NewCertificateHandler(svc, mockBackupService, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
@@ -557,7 +557,7 @@ func TestDeleteCertificate_InvalidID(t *testing.T) {
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/invalid", http.NoBody)
w := httptest.NewRecorder()
@@ -582,7 +582,7 @@ func TestDeleteCertificate_ZeroID(t *testing.T) {
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/0", http.NoBody)
w := httptest.NewRecorder()
@@ -621,7 +621,7 @@ func TestDeleteCertificate_LowDiskSpace(t *testing.T) {
}
h := NewCertificateHandler(svc, mockBackupService, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
@@ -672,7 +672,7 @@ func TestDeleteCertificate_DiskSpaceCheckError(t *testing.T) {
}
h := NewCertificateHandler(svc, mockBackupService, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
@@ -726,7 +726,7 @@ func TestDeleteCertificate_ExpiredLetsEncrypt_NotInUse(t *testing.T) {
}
h := NewCertificateHandler(svc, mockBS, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
@@ -784,7 +784,7 @@ func TestDeleteCertificate_ValidLetsEncrypt_NotInUse(t *testing.T) {
}
h := NewCertificateHandler(svc, mockBS, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
@@ -822,7 +822,7 @@ func TestDeleteCertificate_UsageCheckError(t *testing.T) {
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db, nil)
h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
@@ -867,7 +867,7 @@ func TestDeleteCertificate_NotificationRateLimit(t *testing.T) {
}
h := NewCertificateHandler(svc, mockBackupService, ns)
- r.DELETE("/api/certificates/:id", h.Delete)
+ r.DELETE("/api/certificates/:uuid", h.Delete)
// Delete first certificate
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert1.ID), http.NoBody)
From 77f15a225f5094bc86f5799b4800248e1d9b752e Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Sun, 12 Apr 2026 16:50:55 +0000
Subject: [PATCH 09/57] fix(deps): update non-major-updates
---
.github/workflows/auto-versioning.yml | 2 +-
frontend/package-lock.json | 24 ++++++++++++------------
frontend/package.json | 4 ++--
package-lock.json | 8 ++++----
package.json | 2 +-
5 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/auto-versioning.yml b/.github/workflows/auto-versioning.yml
index 42786152a..211ec087a 100644
--- a/.github/workflows/auto-versioning.yml
+++ b/.github/workflows/auto-versioning.yml
@@ -89,7 +89,7 @@ jobs:
- name: Create GitHub Release (creates tag via API)
if: ${{ steps.semver.outputs.changed == 'true' && steps.check_release.outputs.exists == 'false' }}
- uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
+ uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: ${{ steps.determine_tag.outputs.tag }}
name: Release ${{ steps.determine_tag.outputs.tag }}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index f56434345..cec9b3fba 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,7 +14,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
- "@tanstack/react-query": "^5.98.0",
+ "@tanstack/react-query": "^5.99.0",
"axios": "1.15.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -70,7 +70,7 @@
"eslint-plugin-unicorn": "^64.0.0",
"eslint-plugin-unused-imports": "^4.4.1",
"jsdom": "29.0.2",
- "knip": "^6.4.0",
+ "knip": "^6.4.1",
"postcss": "^8.5.9",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
@@ -3288,9 +3288,9 @@
}
},
"node_modules/@tanstack/query-core": {
- "version": "5.98.0",
- "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.98.0.tgz",
- "integrity": "sha512-v1g54qnirFVWMicVO6hurxez+YrJG3h9AQ6RZqlBhUZfNLRJjjxjjYcriL1s2xLGGNvE2IXpYlUzm22GXa1BQA==",
+ "version": "5.99.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz",
+ "integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3298,12 +3298,12 @@
}
},
"node_modules/@tanstack/react-query": {
- "version": "5.98.0",
- "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.98.0.tgz",
- "integrity": "sha512-HnXce1ckrBH+ORRFtP5YQOYSFzb91KOhKw+UUSxI+sDr89wMfwUmyG0Ayc5EGRVJld40h65F6YzXaMucUXyU6g==",
+ "version": "5.99.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz",
+ "integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==",
"license": "MIT",
"dependencies": {
- "@tanstack/query-core": "5.98.0"
+ "@tanstack/query-core": "5.99.0"
},
"funding": {
"type": "github",
@@ -8055,9 +8055,9 @@
}
},
"node_modules/knip": {
- "version": "6.4.0",
- "resolved": "https://registry.npmjs.org/knip/-/knip-6.4.0.tgz",
- "integrity": "sha512-SAEeggehgkPdoLZWVEcFKzPw+vNlnrUBDqcX8cOcHGydRInSn5pnn9LN3dDJ8SkDHKXR7xYzNq3HtRJaYmxOHg==",
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/knip/-/knip-6.4.1.tgz",
+ "integrity": "sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw==",
"dev": true,
"funding": [
{
diff --git a/frontend/package.json b/frontend/package.json
index f0b55df13..cd001cc3b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -33,7 +33,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
- "@tanstack/react-query": "^5.98.0",
+ "@tanstack/react-query": "^5.99.0",
"axios": "1.15.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -89,7 +89,7 @@
"eslint-plugin-unicorn": "^64.0.0",
"eslint-plugin-unused-imports": "^4.4.1",
"jsdom": "29.0.2",
- "knip": "^6.4.0",
+ "knip": "^6.4.1",
"postcss": "^8.5.9",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
diff --git a/package-lock.json b/package-lock.json
index ecc37df53..423fc1c77 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,7 +14,7 @@
"@playwright/test": "^1.59.1",
"@types/eslint-plugin-jsx-a11y": "^6.10.1",
"@types/node": "^25.6.0",
- "dotenv": "^17.4.1",
+ "dotenv": "^17.4.2",
"markdownlint-cli2": "^0.22.0",
"prettier": "^3.8.2",
"prettier-plugin-tailwindcss": "^0.7.2",
@@ -1155,9 +1155,9 @@
}
},
"node_modules/dotenv": {
- "version": "17.4.1",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
- "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
diff --git a/package.json b/package.json
index 86758e437..a9e6501ce 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"@bgotink/playwright-coverage": "^0.3.2",
"@playwright/test": "^1.59.1",
"@types/node": "^25.6.0",
- "dotenv": "^17.4.1",
+ "dotenv": "^17.4.2",
"markdownlint-cli2": "^0.22.0",
"prettier": "^3.8.2",
"prettier-plugin-tailwindcss": "^0.7.2",
From 01e3d910f1bc17ecb73e7df810b355fdd5c146ec Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 13 Apr 2026 01:12:42 +0000
Subject: [PATCH 10/57] chore(deps): update softprops/action-gh-release action
to v3
---
.github/workflows/auto-versioning.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/auto-versioning.yml b/.github/workflows/auto-versioning.yml
index 211ec087a..b1a394a60 100644
--- a/.github/workflows/auto-versioning.yml
+++ b/.github/workflows/auto-versioning.yml
@@ -89,7 +89,7 @@ jobs:
- name: Create GitHub Release (creates tag via API)
if: ${{ steps.semver.outputs.changed == 'true' && steps.check_release.outputs.exists == 'false' }}
- uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
+ uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
with:
tag_name: ${{ steps.determine_tag.outputs.tag }}
name: Release ${{ steps.determine_tag.outputs.tag }}
From 5c50d8b3149aa1ea44fc9a4cfb2fa541a16ec9a3 Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Sat, 11 Apr 2026 17:58:03 +0000
Subject: [PATCH 11/57] fix: update brace-expansion version to 1.1.14 for
improved compatibility
---
frontend/package-lock.json | 50 +++++++++++++++++++-------------------
package-lock.json | 6 ++---
2 files changed, 28 insertions(+), 28 deletions(-)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index cec9b3fba..c640baa52 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -101,9 +101,9 @@
}
},
"node_modules/@asamuzakjp/css-color": {
- "version": "5.1.9",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz",
- "integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==",
+ "version": "5.1.10",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz",
+ "integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -868,9 +868,9 @@
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
- "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3298,12 +3298,12 @@
}
},
"node_modules/@tanstack/react-query": {
- "version": "5.99.0",
- "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz",
- "integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==",
+ "version": "5.97.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz",
+ "integrity": "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==",
"license": "MIT",
"dependencies": {
- "@tanstack/query-core": "5.99.0"
+ "@tanstack/query-core": "5.97.0"
},
"funding": {
"type": "github",
@@ -3609,9 +3609,9 @@
"license": "MIT"
},
"node_modules/@types/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
- "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4952,9 +4952,9 @@
}
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.17",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz",
- "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==",
+ "version": "2.10.18",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz",
+ "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -5777,9 +5777,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.334",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz",
- "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
+ "version": "1.5.335",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz",
+ "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==",
"dev": true,
"license": "ISC"
},
@@ -6204,9 +6204,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
- "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8055,9 +8055,9 @@
}
},
"node_modules/knip": {
- "version": "6.4.1",
- "resolved": "https://registry.npmjs.org/knip/-/knip-6.4.1.tgz",
- "integrity": "sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw==",
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/knip/-/knip-6.3.1.tgz",
+ "integrity": "sha512-22kLJloVcOVOAudCxlFOC0ICAMme7dKsS7pVTEnrmyKGpswb8ieznvAiSKUeFVDJhb01ect6dkDc1Ha1g1sPpg==",
"dev": true,
"funding": [
{
diff --git a/package-lock.json b/package-lock.json
index 423fc1c77..c1a3c73e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -869,9 +869,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
- "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
From e49ea7061a1f04096ecaa113636fd1c9f347af4d Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Sat, 11 Apr 2026 17:58:55 +0000
Subject: [PATCH 12/57] fix: add go-pkcs12 v0.7.1 for PKCS#12 support
---
backend/go.mod | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/go.mod b/backend/go.mod
index 349628689..65039b36b 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -23,6 +23,7 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
+ software.sslmate.com/src/go-pkcs12 v0.7.1
)
require (
@@ -99,5 +100,4 @@ require (
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.2 // indirect
- software.sslmate.com/src/go-pkcs12 v0.7.1 // indirect
)
From 30c9d735aabcac5e951b871b42865ae5bdd8cdb5 Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Sat, 11 Apr 2026 23:32:22 +0000
Subject: [PATCH 13/57] feat: add certificate export and upload dialogs
- Implemented CertificateExportDialog for exporting certificates in various formats (PEM, PFX, DER) with options to include private keys and set passwords.
- Created CertificateUploadDialog for uploading certificates, including validation and support for multiple file types (certificates, private keys, chain files).
- Updated DeleteCertificateDialog to use 'domains' instead of 'domain' for consistency.
- Refactored BulkDeleteCertificateDialog and DeleteCertificateDialog tests to accommodate changes in certificate structure.
- Added FileDropZone component for improved file upload experience.
- Enhanced translation files with new keys for certificate management features.
- Updated Certificates page to utilize the new CertificateUploadDialog and clean up the upload logic.
- Adjusted Dashboard and ProxyHosts pages to reflect changes in certificate data structure.
---
.../src/api/__tests__/certificates.test.ts | 10 +-
frontend/src/api/certificates.ts | 126 +++++++--
.../src/components/CertificateChainViewer.tsx | 72 +++++
frontend/src/components/CertificateList.tsx | 263 ++++++++++--------
.../src/components/CertificateStatusCard.tsx | 6 +-
.../CertificateValidationPreview.tsx | 107 +++++++
frontend/src/components/ProxyHostForm.tsx | 4 +-
.../__tests__/CertificateList.test.tsx | 186 ++++---------
.../__tests__/CertificateStatusCard.test.tsx | 26 +-
.../dialogs/BulkDeleteCertificateDialog.tsx | 4 +-
.../dialogs/CertificateCleanupDialog.tsx | 4 +-
.../dialogs/CertificateDetailDialog.tsx | 143 ++++++++++
.../dialogs/CertificateExportDialog.tsx | 187 +++++++++++++
.../dialogs/CertificateUploadDialog.tsx | 205 ++++++++++++++
.../dialogs/DeleteCertificateDialog.tsx | 4 +-
.../BulkDeleteCertificateDialog.test.tsx | 14 +-
.../DeleteCertificateDialog.test.tsx | 6 +-
frontend/src/components/ui/FileDropZone.tsx | 135 +++++++++
frontend/src/hooks/useCertificates.ts | 114 +++++++-
frontend/src/locales/en/translation.json | 63 ++++-
frontend/src/pages/Certificates.tsx | 101 +------
frontend/src/pages/Dashboard.tsx | 4 +-
frontend/src/pages/ProxyHosts.tsx | 20 +-
.../src/pages/__tests__/Certificates.test.tsx | 144 ++--------
.../__tests__/ProxyHosts-coverage.test.tsx | 4 +-
.../pages/__tests__/ProxyHosts-extra.test.tsx | 5 +-
26 files changed, 1427 insertions(+), 530 deletions(-)
create mode 100644 frontend/src/components/CertificateChainViewer.tsx
create mode 100644 frontend/src/components/CertificateValidationPreview.tsx
create mode 100644 frontend/src/components/dialogs/CertificateDetailDialog.tsx
create mode 100644 frontend/src/components/dialogs/CertificateExportDialog.tsx
create mode 100644 frontend/src/components/dialogs/CertificateUploadDialog.tsx
create mode 100644 frontend/src/components/ui/FileDropZone.tsx
diff --git a/frontend/src/api/__tests__/certificates.test.ts b/frontend/src/api/__tests__/certificates.test.ts
index 751e82b3f..5777290ff 100644
--- a/frontend/src/api/__tests__/certificates.test.ts
+++ b/frontend/src/api/__tests__/certificates.test.ts
@@ -17,12 +17,14 @@ describe('certificates API', () => {
});
const mockCert: Certificate = {
- id: 1,
- domain: 'example.com',
+ uuid: 'abc-123',
+ domains: 'example.com',
issuer: 'Let\'s Encrypt',
expires_at: '2023-01-01',
status: 'valid',
provider: 'letsencrypt',
+ has_key: true,
+ in_use: false,
};
it('getCertificates calls client.get', async () => {
@@ -47,7 +49,7 @@ describe('certificates API', () => {
it('deleteCertificate calls client.delete', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: {} });
- await deleteCertificate(1);
- expect(client.delete).toHaveBeenCalledWith('/certificates/1');
+ await deleteCertificate('abc-123');
+ expect(client.delete).toHaveBeenCalledWith('/certificates/abc-123');
});
});
diff --git a/frontend/src/api/certificates.ts b/frontend/src/api/certificates.ts
index 154726ee3..ff5bcd514 100644
--- a/frontend/src/api/certificates.ts
+++ b/frontend/src/api/certificates.ts
@@ -1,53 +1,123 @@
import client from './client'
-/** Represents an SSL/TLS certificate. */
export interface Certificate {
- id?: number
+ uuid: string
name?: string
- domain: string
+ common_name?: string
+ domains: string
issuer: string
+ issuer_org?: string
+ fingerprint?: string
+ serial_number?: string
+ key_type?: string
expires_at: string
+ not_before?: string
status: 'valid' | 'expiring' | 'expired' | 'untrusted'
provider: string
+ chain_depth?: number
+ has_key: boolean
+ in_use: boolean
+ /** @deprecated Use uuid instead */
+ id?: number
+}
+
+export interface AssignedHost {
+ uuid: string
+ name: string
+ domain_names: string
+}
+
+export interface ChainEntry {
+ subject: string
+ issuer: string
+ expires_at: string
+}
+
+export interface CertificateDetail extends Certificate {
+ assigned_hosts: AssignedHost[]
+ chain: ChainEntry[]
+ auto_renew: boolean
+ created_at: string
+ updated_at: string
+}
+
+export interface ValidationResult {
+ valid: boolean
+ common_name: string
+ domains: string[]
+ issuer_org: string
+ expires_at: string
+ key_match: boolean
+ chain_valid: boolean
+ chain_depth: number
+ warnings: string[]
+ errors: string[]
}
-/**
- * Fetches all SSL certificates.
- * @returns Promise resolving to array of Certificate objects
- * @throws {AxiosError} If the request fails
- */
export async function getCertificates(): Promise {
const response = await client.get('/certificates')
return response.data
}
-/**
- * Uploads a new SSL certificate with its private key.
- * @param name - Display name for the certificate
- * @param certFile - The certificate file (PEM format)
- * @param keyFile - The private key file (PEM format)
- * @returns Promise resolving to the created Certificate
- * @throws {AxiosError} If upload fails or certificate is invalid
- */
-export async function uploadCertificate(name: string, certFile: File, keyFile: File): Promise {
+export async function getCertificateDetail(uuid: string): Promise {
+ const response = await client.get(`/certificates/${uuid}`)
+ return response.data
+}
+
+export async function uploadCertificate(
+ name: string,
+ certFile: File,
+ keyFile?: File,
+ chainFile?: File,
+): Promise {
const formData = new FormData()
formData.append('name', name)
formData.append('certificate_file', certFile)
- formData.append('key_file', keyFile)
+ if (keyFile) formData.append('key_file', keyFile)
+ if (chainFile) formData.append('chain_file', chainFile)
const response = await client.post('/certificates', formData, {
- headers: {
- 'Content-Type': 'multipart/form-data',
- },
+ headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
}
-/**
- * Deletes an SSL certificate.
- * @param id - The ID of the certificate to delete
- * @throws {AxiosError} If deletion fails or certificate not found
- */
-export async function deleteCertificate(id: number): Promise {
- await client.delete(`/certificates/${id}`)
+export async function updateCertificate(uuid: string, name: string): Promise {
+ const response = await client.put(`/certificates/${uuid}`, { name })
+ return response.data
+}
+
+export async function deleteCertificate(uuid: string): Promise {
+ await client.delete(`/certificates/${uuid}`)
+}
+
+export async function exportCertificate(
+ uuid: string,
+ format: string,
+ includeKey: boolean,
+ password?: string,
+ pfxPassword?: string,
+): Promise {
+ const response = await client.post(
+ `/certificates/${uuid}/export`,
+ { format, include_key: includeKey, password, pfx_password: pfxPassword },
+ { responseType: 'blob' },
+ )
+ return response.data as Blob
+}
+
+export async function validateCertificate(
+ certFile: File,
+ keyFile?: File,
+ chainFile?: File,
+): Promise {
+ const formData = new FormData()
+ formData.append('certificate_file', certFile)
+ if (keyFile) formData.append('key_file', keyFile)
+ if (chainFile) formData.append('chain_file', chainFile)
+
+ const response = await client.post('/certificates/validate', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ })
+ return response.data
}
diff --git a/frontend/src/components/CertificateChainViewer.tsx b/frontend/src/components/CertificateChainViewer.tsx
new file mode 100644
index 000000000..02e5aa806
--- /dev/null
+++ b/frontend/src/components/CertificateChainViewer.tsx
@@ -0,0 +1,72 @@
+import { Link2, ShieldCheck } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+import type { ChainEntry } from '../api/certificates'
+
+interface CertificateChainViewerProps {
+ chain: ChainEntry[]
+}
+
+function getChainLabel(index: number, total: number, t: (key: string) => string): string {
+ if (index === 0) return t('certificates.chainLeaf')
+ if (index === total - 1 && total > 1) return t('certificates.chainRoot')
+ return t('certificates.chainIntermediate')
+}
+
+export default function CertificateChainViewer({ chain }: CertificateChainViewerProps) {
+ const { t } = useTranslation()
+
+ if (!chain || chain.length === 0) {
+ return (
+ {t('certificates.noChainData')}
+ )
+ }
+
+ return (
+
+ {chain.map((entry, index) => {
+ const label = getChainLabel(index, chain.length, t)
+ const isLast = index === chain.length - 1
+
+ return (
+
+
+
+
+ {index === 0 ? (
+
+ ) : (
+
+ )}
+
+ {!isLast && (
+
+ )}
+
+
+
+
+ {label}
+
+
+
+ {entry.subject}
+
+
+ {t('certificates.issuerOrg')}: {entry.issuer}
+
+
+ {t('certificates.expiresAt')}: {new Date(entry.expires_at).toLocaleDateString()}
+
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/components/CertificateList.tsx b/frontend/src/components/CertificateList.tsx
index 8866fa2e6..1fc79921d 100644
--- a/frontend/src/components/CertificateList.tsx
+++ b/frontend/src/components/CertificateList.tsx
@@ -1,32 +1,28 @@
-import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { Trash2, ChevronUp, ChevronDown } from 'lucide-react'
+import { Download, Eye, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import { useState, useMemo, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import BulkDeleteCertificateDialog from './dialogs/BulkDeleteCertificateDialog'
+import CertificateDetailDialog from './dialogs/CertificateDetailDialog'
+import CertificateExportDialog from './dialogs/CertificateExportDialog'
import DeleteCertificateDialog from './dialogs/DeleteCertificateDialog'
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
import { Button } from './ui/Button'
import { Checkbox } from './ui/Checkbox'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/Tooltip'
-import { deleteCertificate, type Certificate } from '../api/certificates'
-import { useCertificates } from '../hooks/useCertificates'
-import { useProxyHosts } from '../hooks/useProxyHosts'
+import { type Certificate } from '../api/certificates'
+import { useCertificates, useDeleteCertificate, useBulkDeleteCertificates } from '../hooks/useCertificates'
import { toast } from '../utils/toast'
-import type { ProxyHost } from '../api/proxyHosts'
-
type SortColumn = 'name' | 'expires'
type SortDirection = 'asc' | 'desc'
-export function isInUse(cert: Certificate, hosts: ProxyHost[]): boolean {
- if (!cert.id) return false
- return hosts.some(h => (h.certificate_id ?? h.certificate?.id) === cert.id)
+export function isInUse(cert: Certificate): boolean {
+ return cert.in_use
}
-export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean {
- if (!cert.id) return false
- if (isInUse(cert, hosts)) return false
+export function isDeletable(cert: Certificate): boolean {
+ if (cert.in_use) return false
return (
cert.provider === 'custom' ||
cert.provider === 'letsencrypt-staging' ||
@@ -35,65 +31,48 @@ export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean {
)
}
+function daysUntilExpiry(expiresAt: string): number {
+ const now = new Date()
+ const expiry = new Date(expiresAt)
+ return Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
+}
+
export default function CertificateList() {
const { certificates, isLoading, error } = useCertificates()
- const { hosts } = useProxyHosts()
- const queryClient = useQueryClient()
const { t } = useTranslation()
const [sortColumn, setSortColumn] = useState('name')
const [sortDirection, setSortDirection] = useState('asc')
const [certToDelete, setCertToDelete] = useState(null)
- const [selectedIds, setSelectedIds] = useState>(new Set())
+ const [certToView, setCertToView] = useState(null)
+ const [certToExport, setCertToExport] = useState(null)
+ const [selectedIds, setSelectedIds] = useState>(new Set())
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false)
+ const deleteMutation = useDeleteCertificate()
+
useEffect(() => {
setSelectedIds(prev => {
- const validIds = new Set(certificates.map(c => c.id).filter((id): id is number => id != null))
+ const validIds = new Set(certificates.map(c => c.uuid).filter(Boolean))
const reconciled = new Set([...prev].filter(id => validIds.has(id)))
if (reconciled.size === prev.size) return prev
return reconciled
})
}, [certificates])
- const deleteMutation = useMutation({
- mutationFn: async (id: number) => {
- await deleteCertificate(id)
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['certificates'] })
- queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
- toast.success(t('certificates.deleteSuccess'))
- setCertToDelete(null)
- },
- onError: (error: Error) => {
- toast.error(`${t('certificates.deleteFailed')}: ${error.message}`)
- setCertToDelete(null)
- },
- })
+ const handleDelete = (cert: Certificate) => {
+ deleteMutation.mutate(cert.uuid, {
+ onSuccess: () => {
+ toast.success(t('certificates.deleteSuccess'))
+ setCertToDelete(null)
+ },
+ onError: (error: Error) => {
+ toast.error(`${t('certificates.deleteFailed')}: ${error.message}`)
+ setCertToDelete(null)
+ },
+ })
+ }
- const bulkDeleteMutation = useMutation({
- mutationFn: async (ids: number[]) => {
- const results = await Promise.allSettled(ids.map(id => deleteCertificate(id)))
- const failed = results.filter(r => r.status === 'rejected').length
- const succeeded = results.filter(r => r.status === 'fulfilled').length
- return { succeeded, failed }
- },
- onSuccess: ({ succeeded, failed }) => {
- queryClient.invalidateQueries({ queryKey: ['certificates'] })
- queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
- setSelectedIds(new Set())
- setShowBulkDeleteDialog(false)
- if (failed > 0) {
- toast.error(t('certificates.bulkDeletePartial', { deleted: succeeded, failed }))
- } else {
- toast.success(t('certificates.bulkDeleteSuccess', { count: succeeded }))
- }
- },
- onError: () => {
- toast.error(t('certificates.bulkDeleteFailed'))
- setShowBulkDeleteDialog(false)
- },
- })
+ const bulkDeleteMutation = useBulkDeleteCertificates()
const sortedCertificates = useMemo(() => {
return [...certificates].sort((a, b) => {
@@ -101,8 +80,8 @@ export default function CertificateList() {
switch (sortColumn) {
case 'name': {
- const aName = (a.name || a.domain || '').toLowerCase()
- const bName = (b.name || b.domain || '').toLowerCase()
+ const aName = (a.name || a.domains || '').toLowerCase()
+ const bName = (b.name || b.domains || '').toLowerCase()
comparison = aName.localeCompare(bName)
break
}
@@ -118,15 +97,15 @@ export default function CertificateList() {
})
}, [certificates, sortColumn, sortDirection])
- const selectableCertIds = useMemo>(() => {
- const ids = new Set()
+ const selectableCertIds = useMemo>(() => {
+ const ids = new Set()
for (const cert of sortedCertificates) {
- if (isDeletable(cert, hosts) && cert.id) {
- ids.add(cert.id)
+ if (isDeletable(cert) && cert.uuid) {
+ ids.add(cert.uuid)
}
}
return ids
- }, [sortedCertificates, hosts])
+ }, [sortedCertificates])
const allSelectableSelected =
selectableCertIds.size > 0 && selectedIds.size === selectableCertIds.size
@@ -141,12 +120,12 @@ export default function CertificateList() {
}
}
- const handleSelectRow = (id: number) => {
+ const handleSelectRow = (uuid: string) => {
const next = new Set(selectedIds)
- if (next.has(id)) {
- next.delete(id)
+ if (next.has(uuid)) {
+ next.delete(uuid)
} else {
- next.add(id)
+ next.add(uuid)
}
setSelectedIds(next)
}
@@ -243,18 +222,19 @@ export default function CertificateList() {
) : (
sortedCertificates.map((cert) => {
- const inUse = isInUse(cert, hosts)
- const deletable = isDeletable(cert, hosts)
+ const inUse = isInUse(cert)
+ const deletable = isDeletable(cert)
const isInUseDeletableCategory = inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired' || cert.status === 'expiring')
+ const days = daysUntilExpiry(cert.expires_at)
return (
-
+
{deletable && !inUse ? (
|
handleSelectRow(cert.id!)}
- aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })}
+ checked={selectedIds.has(cert.uuid)}
+ onCheckedChange={() => handleSelectRow(cert.uuid)}
+ aria-label={t('certificates.selectCert', { name: cert.name || cert.domains })}
/>
|
) : isInUseDeletableCategory ? (
@@ -267,7 +247,7 @@ export default function CertificateList() {
checked={false}
disabled
aria-disabled="true"
- aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })}
+ aria-label={t('certificates.selectCert', { name: cert.name || cert.domains })}
/>
@@ -279,7 +259,7 @@ export default function CertificateList() {
|
)}
{cert.name || '-'} |
- {cert.domain} |
+ {cert.domains} |
{cert.issuer}
@@ -291,49 +271,80 @@ export default function CertificateList() {
|
- {new Date(cert.expires_at).toLocaleDateString()}
+
+
+
+
+ {new Date(cert.expires_at).toLocaleDateString()}
+
+
+
+ {days > 0
+ ? t('certificates.expiresInDays', { days })
+ : t('certificates.expiredAgo', { days: Math.abs(days) })}
+
+
+
|
|
- {(() => {
- if (cert.id && inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
- return (
-
-
-
-
-
-
- {t('certificates.deleteInUse')}
-
-
-
- )
- }
+
+
+
+ {(() => {
+ if (inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
+ return (
+
+
+
+
+
+
+ {t('certificates.deleteInUse')}
+
+
+
+ )
+ }
- if (deletable) {
- return (
-
- )
- }
+ if (deletable) {
+ return (
+
+ )
+ }
- return null
- })()}
+ return null
+ })()}
+
|
)
@@ -347,20 +358,44 @@ export default function CertificateList() {
certificate={certToDelete}
open={certToDelete !== null}
onConfirm={() => {
- if (certToDelete?.id) {
- deleteMutation.mutate(certToDelete.id)
+ if (certToDelete?.uuid) {
+ handleDelete(certToDelete)
}
}}
onCancel={() => setCertToDelete(null)}
isDeleting={deleteMutation.isPending}
/>
c.id && selectedIds.has(c.id))}
+ certificates={sortedCertificates.filter(c => selectedIds.has(c.uuid))}
open={showBulkDeleteDialog}
- onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds))}
+ onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds), {
+ onSuccess: ({ succeeded, failed }) => {
+ setSelectedIds(new Set())
+ setShowBulkDeleteDialog(false)
+ if (failed > 0) {
+ toast.error(t('certificates.bulkDeletePartial', { deleted: succeeded, failed }))
+ } else {
+ toast.success(t('certificates.bulkDeleteSuccess', { count: succeeded }))
+ }
+ },
+ onError: () => {
+ toast.error(t('certificates.bulkDeleteFailed'))
+ setShowBulkDeleteDialog(false)
+ },
+ })}
onCancel={() => setShowBulkDeleteDialog(false)}
isDeleting={bulkDeleteMutation.isPending}
/>
+ { if (!open) setCertToView(null) }}
+ />
+ { if (!open) setCertToExport(null) }}
+ />
>
)
}
diff --git a/frontend/src/components/CertificateStatusCard.tsx b/frontend/src/components/CertificateStatusCard.tsx
index 65f987294..ac4cc00fe 100644
--- a/frontend/src/components/CertificateStatusCard.tsx
+++ b/frontend/src/components/CertificateStatusCard.tsx
@@ -25,9 +25,9 @@ export default function CertificateStatusCard({ certificates, hosts, isLoading }
const domains = new Set()
for (const cert of certificates) {
// Handle missing or undefined domain field
- if (!cert.domain) continue
- // Certificate domain field can be comma-separated
- for (const d of cert.domain.split(',')) {
+ if (!cert.domains) continue
+ // Certificate domains field can be comma-separated
+ for (const d of cert.domains.split(',')) {
const trimmed = d.trim().toLowerCase()
if (trimmed) domains.add(trimmed)
}
diff --git a/frontend/src/components/CertificateValidationPreview.tsx b/frontend/src/components/CertificateValidationPreview.tsx
new file mode 100644
index 000000000..9e5055d55
--- /dev/null
+++ b/frontend/src/components/CertificateValidationPreview.tsx
@@ -0,0 +1,107 @@
+import { AlertTriangle, CheckCircle, XCircle } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+import type { ValidationResult } from '../api/certificates'
+
+interface CertificateValidationPreviewProps {
+ result: ValidationResult
+}
+
+export default function CertificateValidationPreview({
+ result,
+}: CertificateValidationPreviewProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {result.valid ? (
+
+ ) : (
+
+ )}
+
+ {result.valid
+ ? t('certificates.validCertificate')
+ : t('certificates.invalidCertificate')}
+
+
+
+
+ - {t('certificates.commonName')}
+ - {result.common_name || '-'}
+
+ - {t('certificates.domains')}
+ -
+ {result.domains?.length ? result.domains.join(', ') : '-'}
+
+
+ - {t('certificates.issuerOrg')}
+ - {result.issuer_org || '-'}
+
+ - {t('certificates.expiresAt')}
+ -
+ {result.expires_at ? new Date(result.expires_at).toLocaleDateString() : '-'}
+
+
+ - {t('certificates.keyMatch')}
+ -
+ {result.key_match ? (
+ Yes
+ ) : (
+ No key provided
+ )}
+
+
+ - {t('certificates.chainValid')}
+ -
+ {result.chain_valid ? (
+ Yes
+ ) : (
+ Not verified
+ )}
+
+
+ {result.chain_depth > 0 && (
+ <>
+ - {t('certificates.chainDepth')}
+ - {result.chain_depth}
+ >
+ )}
+
+
+ {result.warnings.length > 0 && (
+
+
+
+
{t('certificates.warnings')}
+
+ {result.warnings.map((w, i) => (
+ - {w}
+ ))}
+
+
+
+ )}
+
+ {result.errors.length > 0 && (
+
+
+
+
{t('certificates.errors')}
+
+ {result.errors.map((e, i) => (
+ - {e}
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx
index 0a77144f9..4cd0181fa 100644
--- a/frontend/src/components/ProxyHostForm.tsx
+++ b/frontend/src/components/ProxyHostForm.tsx
@@ -917,8 +917,8 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
Auto-manage with Let's Encrypt (recommended)
{certificates.map(cert => (
-
- {(cert.name || cert.domain)}
+
+ {(cert.name || cert.domains)}
{cert.provider ? ` (${cert.provider})` : ''}
))}
diff --git a/frontend/src/components/__tests__/CertificateList.test.tsx b/frontend/src/components/__tests__/CertificateList.test.tsx
index ea63b910b..7548f5a92 100644
--- a/frontend/src/components/__tests__/CertificateList.test.tsx
+++ b/frontend/src/components/__tests__/CertificateList.test.tsx
@@ -3,16 +3,18 @@ import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { useCertificates } from '../../hooks/useCertificates'
-import { useProxyHosts } from '../../hooks/useProxyHosts'
+import { useCertificates, useDeleteCertificate, useBulkDeleteCertificates } from '../../hooks/useCertificates'
import { createTestQueryClient } from '../../test/createTestQueryClient'
import CertificateList, { isDeletable, isInUse } from '../CertificateList'
import type { Certificate } from '../../api/certificates'
-import type { ProxyHost } from '../../api/proxyHosts'
vi.mock('../../hooks/useCertificates', () => ({
useCertificates: vi.fn(),
+ useCertificateDetail: vi.fn(() => ({ detail: null, isLoading: false })),
+ useDeleteCertificate: vi.fn(),
+ useBulkDeleteCertificates: vi.fn(),
+ useExportCertificate: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
}))
vi.mock('../../api/certificates', () => ({
@@ -30,10 +32,6 @@ vi.mock('react-i18next', () => ({
}),
}))
-vi.mock('../../hooks/useProxyHosts', () => ({
- useProxyHosts: vi.fn(),
-}))
-
vi.mock('../../utils/toast', () => ({
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}))
@@ -43,14 +41,26 @@ function renderWithClient(ui: React.ReactNode) {
return render({ui})
}
+const makeCert = (overrides: Partial = {}): Certificate => ({
+ uuid: 'cert-1',
+ domains: 'example.com',
+ issuer: 'Custom CA',
+ expires_at: '2026-03-01T00:00:00Z',
+ status: 'valid',
+ provider: 'custom',
+ has_key: true,
+ in_use: false,
+ ...overrides,
+})
+
const createCertificatesValue = (overrides: Partial> = {}) => {
const certificates: Certificate[] = [
- { id: 1, name: 'CustomCert', domain: 'example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'expired', provider: 'custom' },
- { id: 2, name: 'LE Staging', domain: 'staging.example.com', issuer: "Let's Encrypt Staging", expires_at: '2026-04-01T00:00:00Z', status: 'untrusted', provider: 'letsencrypt-staging' },
- { id: 3, name: 'ActiveCert', domain: 'active.example.com', issuer: 'Custom CA', expires_at: '2026-02-01T00:00:00Z', status: 'valid', provider: 'custom' },
- { id: 4, name: 'UnusedValidCert', domain: 'unused.example.com', issuer: 'Custom CA', expires_at: '2026-05-01T00:00:00Z', status: 'valid', provider: 'custom' },
- { id: 5, name: 'ExpiredLE', domain: 'expired-le.example.com', issuer: "Let's Encrypt", expires_at: '2025-01-01T00:00:00Z', status: 'expired', provider: 'letsencrypt' },
- { id: 6, name: 'ValidLE', domain: 'valid-le.example.com', issuer: "Let's Encrypt", expires_at: '2026-12-01T00:00:00Z', status: 'valid', provider: 'letsencrypt' },
+ makeCert({ uuid: 'cert-1', name: 'CustomCert', domains: 'example.com', status: 'expired', in_use: false }),
+ makeCert({ uuid: 'cert-2', name: 'LE Staging', domains: 'staging.example.com', issuer: "Let's Encrypt Staging", status: 'untrusted', provider: 'letsencrypt-staging', in_use: false }),
+ makeCert({ uuid: 'cert-3', name: 'ActiveCert', domains: 'active.example.com', status: 'valid', in_use: true }),
+ makeCert({ uuid: 'cert-4', name: 'UnusedValidCert', domains: 'unused.example.com', status: 'valid', in_use: false }),
+ makeCert({ uuid: 'cert-5', name: 'ExpiredLE', domains: 'expired-le.example.com', issuer: "Let's Encrypt", expires_at: '2025-01-01T00:00:00Z', status: 'expired', provider: 'letsencrypt', in_use: false }),
+ makeCert({ uuid: 'cert-6', name: 'ValidLE', domains: 'valid-le.example.com', issuer: "Let's Encrypt", expires_at: '2026-12-01T00:00:00Z', status: 'valid', provider: 'letsencrypt', in_use: false }),
]
return {
@@ -62,126 +72,68 @@ const createCertificatesValue = (overrides: Partial = {}): ProxyHost => ({
- uuid: 'h1',
- name: 'Host1',
- domain_names: 'host1.example.com',
- forward_scheme: 'http',
- forward_host: '127.0.0.1',
- forward_port: 80,
- ssl_forced: false,
- http2_support: true,
- hsts_enabled: false,
- hsts_subdomains: false,
- block_exploits: false,
- websocket_support: false,
- application: 'none',
- locations: [],
- enabled: true,
- created_at: '2026-02-01T00:00:00Z',
- updated_at: '2026-02-01T00:00:00Z',
- certificate_id: 3,
- ...overrides,
-})
-
-const createProxyHostsValue = (overrides: Partial> = {}): ReturnType => ({
- hosts: [
- createProxyHost(),
- ],
- loading: false,
- isFetching: false,
- error: null,
- createHost: vi.fn(),
- updateHost: vi.fn(),
- deleteHost: vi.fn(),
- bulkUpdateACL: vi.fn(),
- bulkUpdateSecurityHeaders: vi.fn(),
- isCreating: false,
- isUpdating: false,
- isDeleting: false,
- isBulkUpdating: false,
- ...overrides,
-})
-
const getRowNames = () =>
screen
.getAllByRole('row')
.slice(1)
.map(row => row.querySelectorAll('td')[1]?.textContent?.trim() ?? '')
+let deleteMutateFn: ReturnType
+let bulkDeleteMutateFn: ReturnType
+
beforeEach(() => {
vi.clearAllMocks()
+ deleteMutateFn = vi.fn()
+ bulkDeleteMutateFn = vi.fn()
vi.mocked(useCertificates).mockReturnValue(createCertificatesValue())
- vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsValue())
+ vi.mocked(useDeleteCertificate).mockReturnValue({
+ mutate: deleteMutateFn,
+ isPending: false,
+ } as unknown as ReturnType)
+ vi.mocked(useBulkDeleteCertificates).mockReturnValue({
+ mutate: bulkDeleteMutateFn,
+ isPending: false,
+ } as unknown as ReturnType)
})
describe('CertificateList', () => {
describe('isDeletable', () => {
- const noHosts: ProxyHost[] = []
- const withHost = (certId: number): ProxyHost[] => [createProxyHost({ certificate_id: certId })]
-
it('returns true for custom cert not in use', () => {
- const cert: Certificate = { id: 1, name: 'C', domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
- expect(isDeletable(cert, noHosts)).toBe(true)
+ expect(isDeletable(makeCert({ provider: 'custom', in_use: false }))).toBe(true)
})
it('returns true for staging cert not in use', () => {
- const cert: Certificate = { id: 2, name: 'S', domain: 'd', issuer: 'X', expires_at: '', status: 'untrusted', provider: 'letsencrypt-staging' }
- expect(isDeletable(cert, noHosts)).toBe(true)
+ expect(isDeletable(makeCert({ provider: 'letsencrypt-staging', in_use: false }))).toBe(true)
})
it('returns true for expired LE cert not in use', () => {
- const cert: Certificate = { id: 3, name: 'E', domain: 'd', issuer: 'LE', expires_at: '', status: 'expired', provider: 'letsencrypt' }
- expect(isDeletable(cert, noHosts)).toBe(true)
+ expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expired', in_use: false }))).toBe(true)
})
it('returns false for valid LE cert not in use', () => {
- const cert: Certificate = { id: 4, name: 'V', domain: 'd', issuer: 'LE', expires_at: '', status: 'valid', provider: 'letsencrypt' }
- expect(isDeletable(cert, noHosts)).toBe(false)
+ expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'valid', in_use: false }))).toBe(false)
})
it('returns false for cert in use', () => {
- const cert: Certificate = { id: 5, name: 'U', domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
- expect(isDeletable(cert, withHost(5))).toBe(false)
- })
-
- it('returns false for cert without id', () => {
- const cert: Certificate = { domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
- expect(isDeletable(cert, noHosts)).toBe(false)
+ expect(isDeletable(makeCert({ provider: 'custom', in_use: true }))).toBe(false)
})
it('returns true for expiring LE cert not in use', () => {
- const cert: Certificate = { id: 7, name: 'Exp', domain: 'd', issuer: 'LE', expires_at: '', status: 'expiring', provider: 'letsencrypt' }
- expect(isDeletable(cert, noHosts)).toBe(true)
+ expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expiring', in_use: false }))).toBe(true)
})
it('returns false for expiring LE cert that is in use', () => {
- const cert: Certificate = { id: 7, name: 'Exp', domain: 'd', issuer: 'LE', expires_at: '', status: 'expiring', provider: 'letsencrypt' }
- expect(isDeletable(cert, withHost(7))).toBe(false)
+ expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expiring', in_use: true }))).toBe(false)
})
})
describe('isInUse', () => {
- it('returns true when host references cert by certificate_id', () => {
- const cert: Certificate = { id: 10, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
- expect(isInUse(cert, [createProxyHost({ certificate_id: 10 })])).toBe(true)
- })
-
- it('returns true when host references cert via certificate.id', () => {
- const cert: Certificate = { id: 10, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
- const host = createProxyHost({ certificate_id: undefined, certificate: { id: 10, uuid: 'u', name: 'c', provider: 'custom', domains: 'd', expires_at: '' } })
- expect(isInUse(cert, [host])).toBe(true)
- })
-
- it('returns false when no host references cert', () => {
- const cert: Certificate = { id: 99, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
- expect(isInUse(cert, [createProxyHost({ certificate_id: 3 })])).toBe(false)
+ it('returns true when cert.in_use is true', () => {
+ expect(isInUse(makeCert({ in_use: true }))).toBe(true)
})
- it('returns false when cert.id is undefined even if a host has certificate_id undefined', () => {
- const cert: Certificate = { domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
- const host = createProxyHost({ certificate_id: undefined })
- expect(isInUse(cert, [host])).toBe(false)
+ it('returns false when cert.in_use is false', () => {
+ expect(isInUse(makeCert({ in_use: false }))).toBe(false)
})
})
@@ -215,7 +167,6 @@ describe('CertificateList', () => {
})
it('opens dialog and deletes cert on confirm', async () => {
- const { deleteCertificate } = await import('../../api/certificates')
const user = userEvent.setup()
renderWithClient()
@@ -228,7 +179,7 @@ describe('CertificateList', () => {
expect(within(dialog).getByText('certificates.deleteTitle')).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
- await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(1))
+ await waitFor(() => expect(deleteMutateFn).toHaveBeenCalledWith('cert-1', expect.any(Object)))
})
it('does not call createBackup on delete (server handles it)', async () => {
@@ -257,23 +208,6 @@ describe('CertificateList', () => {
expect(await screen.findByText('Failed to load certificates')).toBeInTheDocument()
})
- it('shows error toast when delete mutation fails', async () => {
- const { deleteCertificate } = await import('../../api/certificates')
- const { toast } = await import('../../utils/toast')
- vi.mocked(deleteCertificate).mockRejectedValueOnce(new Error('Network error'))
- const user = userEvent.setup()
-
- renderWithClient()
- const rows = await screen.findAllByRole('row')
- const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
- await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
-
- const dialog = await screen.findByRole('dialog')
- await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
-
- await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.deleteFailed: Network error'))
- })
-
it('clicking disabled delete button for in-use cert does not open dialog', async () => {
const user = userEvent.setup()
renderWithClient()
@@ -299,7 +233,7 @@ describe('CertificateList', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
- it('renders enabled checkboxes for deletable not-in-use certs (ids 1, 2, 4, 5)', async () => {
+ it('renders enabled checkboxes for deletable not-in-use certs', async () => {
renderWithClient()
const rows = await screen.findAllByRole('row')
for (const name of ['CustomCert', 'LE Staging', 'UnusedValidCert', 'ExpiredLE']) {
@@ -310,7 +244,7 @@ describe('CertificateList', () => {
}
})
- it('renders disabled checkbox for in-use cert (id 3)', async () => {
+ it('renders disabled checkbox for in-use cert', async () => {
renderWithClient()
const rows = await screen.findAllByRole('row')
const activeRow = rows.find(r => r.textContent?.includes('ActiveCert'))!
@@ -320,7 +254,7 @@ describe('CertificateList', () => {
expect(rowCheckbox).toHaveAttribute('aria-disabled', 'true')
})
- it('renders no checkbox in valid production LE cert row (id 6)', async () => {
+ it('renders no checkbox in valid production LE cert row', async () => {
renderWithClient()
const rows = await screen.findAllByRole('row')
const validLeRow = rows.find(r => r.textContent?.includes('ValidLE'))!
@@ -360,8 +294,7 @@ describe('CertificateList', () => {
expect(await screen.findByRole('dialog')).toBeInTheDocument()
})
- it('confirming in the bulk dialog calls deleteCertificate for each selected ID', async () => {
- const { deleteCertificate } = await import('../../api/certificates')
+ it('confirming in the bulk dialog calls bulk delete for selected UUIDs', async () => {
const user = userEvent.setup()
renderWithClient()
const rows = await screen.findAllByRole('row')
@@ -373,16 +306,17 @@ describe('CertificateList', () => {
const dialog = await screen.findByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
await waitFor(() => {
- expect(deleteCertificate).toHaveBeenCalledWith(1)
- expect(deleteCertificate).toHaveBeenCalledWith(2)
+ expect(bulkDeleteMutateFn).toHaveBeenCalledWith(
+ expect.arrayContaining(['cert-1', 'cert-2']),
+ expect.any(Object),
+ )
})
})
it('shows partial failure toast when some bulk deletes fail', async () => {
- const { deleteCertificate } = await import('../../api/certificates')
const { toast } = await import('../../utils/toast')
- vi.mocked(deleteCertificate).mockImplementation(async (id: number) => {
- if (id === 2) throw new Error('network error')
+ bulkDeleteMutateFn.mockImplementation((_uuids: string[], { onSuccess }: { onSuccess: (data: { succeeded: number; failed: number }) => void }) => {
+ onSuccess({ succeeded: 1, failed: 1 })
})
const user = userEvent.setup()
renderWithClient()
@@ -410,8 +344,8 @@ describe('CertificateList', () => {
it('sorts certificates by name and expiry when headers are clicked', async () => {
const certificates: Certificate[] = [
- { id: 10, name: 'Zulu', domain: 'z.example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'valid', provider: 'custom' },
- { id: 11, name: 'Alpha', domain: 'a.example.com', issuer: 'Custom CA', expires_at: '2026-01-01T00:00:00Z', status: 'valid', provider: 'custom' },
+ makeCert({ uuid: 'cert-z', name: 'Zulu', domains: 'z.example.com', expires_at: '2026-03-01T00:00:00Z' }),
+ makeCert({ uuid: 'cert-a', name: 'Alpha', domains: 'a.example.com', expires_at: '2026-01-01T00:00:00Z' }),
]
const user = userEvent.setup()
diff --git a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx
index 6055672a7..fb02d346f 100644
--- a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx
+++ b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx
@@ -8,13 +8,15 @@ import type { Certificate } from '../../api/certificates'
import type { ProxyHost } from '../../api/proxyHosts'
const mockCert: Certificate = {
- id: 1,
+ uuid: 'cert-1',
name: 'Test Cert',
- domain: 'example.com',
+ domains: 'example.com',
issuer: "Let's Encrypt",
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
status: 'valid',
provider: 'letsencrypt',
+ has_key: true,
+ in_use: false,
}
const mockHost: ProxyHost = {
@@ -42,13 +44,15 @@ const mockHost: ProxyHost = {
// Helper to create a certificate with a specific domain
function mockCertWithDomain(domain: string, status: 'valid' | 'expiring' | 'expired' | 'untrusted' = 'valid'): Certificate {
return {
- id: Math.floor(Math.random() * 10000),
+ uuid: `cert-${Math.random().toString(36).slice(2, 8)}`,
name: domain,
- domain: domain,
+ domains: domain,
issuer: "Let's Encrypt",
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
status,
provider: 'letsencrypt',
+ has_key: true,
+ in_use: false,
}
}
@@ -58,7 +62,7 @@ function renderWithRouter(ui: React.ReactNode) {
describe('CertificateStatusCard', () => {
it('shows total certificate count', () => {
- const certs: Certificate[] = [mockCert, { ...mockCert, id: 2 }, { ...mockCert, id: 3 }]
+ const certs: Certificate[] = [mockCert, { ...mockCert, uuid: 'cert-2' }, { ...mockCert, uuid: 'cert-3' }]
renderWithRouter()
expect(screen.getByText('3')).toBeInTheDocument()
@@ -68,8 +72,8 @@ describe('CertificateStatusCard', () => {
it('shows valid certificate count', () => {
const certs: Certificate[] = [
{ ...mockCert, status: 'valid' },
- { ...mockCert, id: 2, status: 'valid' },
- { ...mockCert, id: 3, status: 'expired' },
+ { ...mockCert, uuid: 'cert-2', status: 'valid' },
+ { ...mockCert, uuid: 'cert-3', status: 'expired' },
]
renderWithRouter()
@@ -79,7 +83,7 @@ describe('CertificateStatusCard', () => {
it('shows expiring count when certificates are expiring', () => {
const certs: Certificate[] = [
{ ...mockCert, status: 'expiring' },
- { ...mockCert, id: 2, status: 'valid' },
+ { ...mockCert, uuid: 'cert-2', status: 'valid' },
]
renderWithRouter()
@@ -96,7 +100,7 @@ describe('CertificateStatusCard', () => {
it('shows staging count for untrusted certificates', () => {
const certs: Certificate[] = [
{ ...mockCert, status: 'untrusted' },
- { ...mockCert, id: 2, status: 'untrusted' },
+ { ...mockCert, uuid: 'cert-2', status: 'untrusted' },
]
renderWithRouter()
@@ -206,7 +210,7 @@ describe('CertificateStatusCard - Domain Matching', () => {
it('handles comma-separated certificate domains', () => {
const certs: Certificate[] = [{
...mockCertWithDomain('example.com'),
- domain: 'example.com, www.example.com'
+ domains: 'example.com, www.example.com'
}]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'www.example.com', ssl_forced: true, certificate_id: null, enabled: true }
@@ -295,7 +299,7 @@ describe('CertificateStatusCard - Domain Matching', () => {
it('handles whitespace in certificate domains', () => {
const certs: Certificate[] = [{
...mockCertWithDomain('example.com'),
- domain: ' example.com '
+ domains: ' example.com '
}]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true }
diff --git a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx
index 17f867ac5..458cb782e 100644
--- a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx
+++ b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx
@@ -64,10 +64,10 @@ export default function BulkDeleteCertificateDialog({
>
{certificates.map((cert) => (
- {cert.name || cert.domain}
+ {cert.name || cert.domains}
{providerLabel(cert, t)}
))}
diff --git a/frontend/src/components/dialogs/CertificateCleanupDialog.tsx b/frontend/src/components/dialogs/CertificateCleanupDialog.tsx
index 210849b16..594ed9d38 100644
--- a/frontend/src/components/dialogs/CertificateCleanupDialog.tsx
+++ b/frontend/src/components/dialogs/CertificateCleanupDialog.tsx
@@ -3,7 +3,7 @@ import { AlertTriangle } from 'lucide-react'
interface CertificateCleanupDialogProps {
onConfirm: (deleteCerts: boolean) => void
onCancel: () => void
- certificates: Array<{ id: number; name: string; domain: string }>
+ certificates: Array<{ uuid: string; name: string; domain: string }>
hostNames: string[]
isBulk?: boolean
}
@@ -82,7 +82,7 @@ export default function CertificateCleanupDialog({
{certificates.map((cert) => (
- -
+
-
→
{cert.name || cert.domain}
({cert.domain})
diff --git a/frontend/src/components/dialogs/CertificateDetailDialog.tsx b/frontend/src/components/dialogs/CertificateDetailDialog.tsx
new file mode 100644
index 000000000..a8b38385f
--- /dev/null
+++ b/frontend/src/components/dialogs/CertificateDetailDialog.tsx
@@ -0,0 +1,143 @@
+import { Loader2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+import type { Certificate } from '../../api/certificates'
+import { useCertificateDetail } from '../../hooks/useCertificates'
+import CertificateChainViewer from '../CertificateChainViewer'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '../ui'
+
+interface CertificateDetailDialogProps {
+ certificate: Certificate | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export default function CertificateDetailDialog({
+ certificate,
+ open,
+ onOpenChange,
+}: CertificateDetailDialogProps) {
+ const { t } = useTranslation()
+
+ const { detail, isLoading } = useCertificateDetail(
+ open && certificate ? certificate.uuid : null,
+ )
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/dialogs/CertificateExportDialog.tsx b/frontend/src/components/dialogs/CertificateExportDialog.tsx
new file mode 100644
index 000000000..fbe078238
--- /dev/null
+++ b/frontend/src/components/dialogs/CertificateExportDialog.tsx
@@ -0,0 +1,187 @@
+import { Download } from 'lucide-react'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import type { Certificate } from '../../api/certificates'
+import { useExportCertificate } from '../../hooks/useCertificates'
+import { toast } from '../../utils/toast'
+import {
+ Button,
+ Input,
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+ Label,
+} from '../ui'
+
+interface CertificateExportDialogProps {
+ certificate: Certificate | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+const FORMAT_OPTIONS = [
+ { value: 'pem', label: 'exportFormatPem' },
+ { value: 'pfx', label: 'exportFormatPfx' },
+ { value: 'der', label: 'exportFormatDer' },
+] as const
+
+export default function CertificateExportDialog({
+ certificate,
+ open,
+ onOpenChange,
+}: CertificateExportDialogProps) {
+ const { t } = useTranslation()
+
+ const [format, setFormat] = useState('pem')
+ const [includeKey, setIncludeKey] = useState(false)
+ const [password, setPassword] = useState('')
+ const [pfxPassword, setPfxPassword] = useState('')
+
+ const exportMutation = useExportCertificate()
+
+ function resetForm() {
+ setFormat('pem')
+ setIncludeKey(false)
+ setPassword('')
+ setPfxPassword('')
+ }
+
+ function handleClose(nextOpen: boolean) {
+ if (!nextOpen) resetForm()
+ onOpenChange(nextOpen)
+ }
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ if (!certificate) return
+
+ exportMutation.mutate(
+ {
+ uuid: certificate.uuid,
+ format,
+ includeKey,
+ password: includeKey ? password : undefined,
+ pfxPassword: format === 'pfx' ? pfxPassword : undefined,
+ },
+ {
+ onSuccess: (blob) => {
+ const ext = format === 'pfx' ? 'pfx' : format === 'der' ? 'der' : 'pem'
+ const filename = `${certificate.name || 'certificate'}.${ext}`
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ URL.revokeObjectURL(url)
+ a.remove()
+ toast.success(t('certificates.exportSuccess'))
+ handleClose(false)
+ },
+ onError: (error: Error) => {
+ toast.error(`${t('certificates.exportFailed')}: ${error.message}`)
+ },
+ },
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/dialogs/CertificateUploadDialog.tsx b/frontend/src/components/dialogs/CertificateUploadDialog.tsx
new file mode 100644
index 000000000..bad87f0c1
--- /dev/null
+++ b/frontend/src/components/dialogs/CertificateUploadDialog.tsx
@@ -0,0 +1,205 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import type { ValidationResult } from '../../api/certificates'
+import CertificateValidationPreview from '../CertificateValidationPreview'
+import {
+ Button,
+ Input,
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '../ui'
+import { FileDropZone } from '../ui/FileDropZone'
+
+import { useUploadCertificate, useValidateCertificate } from '../../hooks/useCertificates'
+import { toast } from '../../utils/toast'
+
+interface CertificateUploadDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+function detectFormat(file: File | null): string | null {
+ if (!file) return null
+ const ext = file.name.toLowerCase().split('.').pop()
+ if (ext === 'pfx' || ext === 'p12') return 'PFX/PKCS#12'
+ if (ext === 'pem' || ext === 'crt' || ext === 'cer') return 'PEM'
+ if (ext === 'der') return 'DER'
+ if (ext === 'key') return 'KEY'
+ return null
+}
+
+export default function CertificateUploadDialog({
+ open,
+ onOpenChange,
+}: CertificateUploadDialogProps) {
+ const { t } = useTranslation()
+
+ const [name, setName] = useState('')
+ const [certFile, setCertFile] = useState(null)
+ const [keyFile, setKeyFile] = useState(null)
+ const [chainFile, setChainFile] = useState(null)
+ const [validationResult, setValidationResult] = useState(null)
+
+ const uploadMutation = useUploadCertificate()
+ const validateMutation = useValidateCertificate()
+
+ const certFormat = detectFormat(certFile)
+ const isPfx = certFormat === 'PFX/PKCS#12'
+
+ function resetForm() {
+ setName('')
+ setCertFile(null)
+ setKeyFile(null)
+ setChainFile(null)
+ setValidationResult(null)
+ }
+
+ function handleClose(nextOpen: boolean) {
+ if (!nextOpen) resetForm()
+ onOpenChange(nextOpen)
+ }
+
+ function handleValidate() {
+ if (!certFile) return
+ validateMutation.mutate(
+ { certFile, keyFile: keyFile ?? undefined, chainFile: chainFile ?? undefined },
+ {
+ onSuccess: (result) => {
+ setValidationResult(result)
+ },
+ onError: (error: Error) => {
+ toast.error(error.message)
+ },
+ },
+ )
+ }
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ if (!certFile) return
+
+ uploadMutation.mutate(
+ {
+ name,
+ certFile,
+ keyFile: keyFile ?? undefined,
+ chainFile: chainFile ?? undefined,
+ },
+ {
+ onSuccess: () => {
+ toast.success(t('certificates.uploadSuccess'))
+ handleClose(false)
+ },
+ onError: (error: Error) => {
+ toast.error(`${t('certificates.uploadFailed')}: ${error.message}`)
+ },
+ },
+ )
+ }
+
+ const canValidate = !!certFile && !validateMutation.isPending
+ const canSubmit = !!certFile && !!name.trim()
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/dialogs/DeleteCertificateDialog.tsx b/frontend/src/components/dialogs/DeleteCertificateDialog.tsx
index 68491eb6d..14e4bf89c 100644
--- a/frontend/src/components/dialogs/DeleteCertificateDialog.tsx
+++ b/frontend/src/components/dialogs/DeleteCertificateDialog.tsx
@@ -45,7 +45,7 @@ export default function DeleteCertificateDialog({
{t('certificates.deleteTitle')}
- {certificate.name || certificate.domain}
+ {certificate.name || certificate.domains}
@@ -59,7 +59,7 @@ export default function DeleteCertificateDialog({
- {t('certificates.domain')}
- - {certificate.domain}
+ - {certificate.domains}
- {t('certificates.status')}
- {certificate.status}
- {t('certificates.provider')}
diff --git a/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx b/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx
index 535074f8d..20c7e56a0 100644
--- a/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx
+++ b/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx
@@ -7,20 +7,22 @@ import BulkDeleteCertificateDialog from '../../dialogs/BulkDeleteCertificateDial
import type { Certificate } from '../../../api/certificates'
const makeCert = (overrides: Partial): Certificate => ({
- id: 1,
+ uuid: 'cert-1',
name: 'Test Cert',
- domain: 'test.example.com',
+ domains: 'test.example.com',
issuer: 'Custom CA',
expires_at: '2026-01-01T00:00:00Z',
status: 'valid',
provider: 'custom',
+ has_key: true,
+ in_use: false,
...overrides,
})
const certs: Certificate[] = [
- makeCert({ id: 1, name: 'Cert One', domain: 'one.example.com' }),
- makeCert({ id: 2, name: 'Cert Two', domain: 'two.example.com', provider: 'letsencrypt-staging', status: 'untrusted' }),
- makeCert({ id: 3, name: 'Cert Three', domain: 'three.example.com', provider: 'letsencrypt', status: 'expired' }),
+ makeCert({ uuid: 'cert-1', name: 'Cert One', domains: 'one.example.com' }),
+ makeCert({ uuid: 'cert-2', name: 'Cert Two', domains: 'two.example.com', provider: 'letsencrypt-staging', status: 'untrusted' }),
+ makeCert({ uuid: 'cert-3', name: 'Cert Three', domains: 'three.example.com', provider: 'letsencrypt', status: 'expired' }),
]
describe('BulkDeleteCertificateDialog', () => {
@@ -121,7 +123,7 @@ describe('BulkDeleteCertificateDialog', () => {
})
it('renders "Expiring LE" label for a letsencrypt cert with status expiring', () => {
- const expiringCert = makeCert({ id: 4, name: 'Expiring Cert', domain: 'expiring.example.com', provider: 'letsencrypt', status: 'expiring' })
+ const expiringCert = makeCert({ uuid: 'cert-4', name: 'Expiring Cert', domains: 'expiring.example.com', provider: 'letsencrypt', status: 'expiring' })
render(
({
}))
const baseCert: Certificate = {
- id: 1,
+ uuid: 'cert-1',
name: 'Test Cert',
- domain: 'test.example.com',
+ domains: 'test.example.com',
issuer: 'Custom CA',
expires_at: '2026-01-01T00:00:00Z',
status: 'valid',
provider: 'custom',
+ has_key: true,
+ in_use: false,
}
describe('DeleteCertificateDialog', () => {
diff --git a/frontend/src/components/ui/FileDropZone.tsx b/frontend/src/components/ui/FileDropZone.tsx
new file mode 100644
index 000000000..19fb23b1a
--- /dev/null
+++ b/frontend/src/components/ui/FileDropZone.tsx
@@ -0,0 +1,135 @@
+import { Upload } from 'lucide-react'
+import { useCallback, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { cn } from '../../utils/cn'
+
+interface FileDropZoneProps {
+ id: string
+ label: string
+ accept?: string
+ file: File | null
+ onFileChange: (file: File | null) => void
+ disabled?: boolean
+ required?: boolean
+ formatBadge?: string | null
+}
+
+export function FileDropZone({
+ id,
+ label,
+ accept,
+ file,
+ onFileChange,
+ disabled = false,
+ required = false,
+ formatBadge,
+}: FileDropZoneProps) {
+ const { t } = useTranslation()
+ const inputRef = useRef(null)
+ const [isDragOver, setIsDragOver] = useState(false)
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault()
+ setIsDragOver(false)
+ if (disabled) return
+ const droppedFile = e.dataTransfer.files[0]
+ if (droppedFile) onFileChange(droppedFile)
+ },
+ [disabled, onFileChange],
+ )
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault()
+ if (!disabled) setIsDragOver(true)
+ },
+ [disabled],
+ )
+
+ const handleDragLeave = useCallback(() => {
+ setIsDragOver(false)
+ }, [])
+
+ const handleInputChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const selected = e.target.files?.[0] || null
+ onFileChange(selected)
+ },
+ [onFileChange],
+ )
+
+ const handleClick = useCallback(() => {
+ if (!disabled) inputRef.current?.click()
+ }, [disabled])
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
+ e.preventDefault()
+ inputRef.current?.click()
+ }
+ },
+ [disabled],
+ )
+
+ return (
+
+
+
+
+
+ {file ? (
+
+
+
+ {file.name}
+
+ {formatBadge && (
+
+ {formatBadge}
+
+ )}
+
+ ) : (
+
+
+ {t('certificates.dropFileHere')}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/hooks/useCertificates.ts b/frontend/src/hooks/useCertificates.ts
index 228c3c7d4..540280f06 100644
--- a/frontend/src/hooks/useCertificates.ts
+++ b/frontend/src/hooks/useCertificates.ts
@@ -1,6 +1,16 @@
-import { useQuery } from '@tanstack/react-query'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { getCertificates } from '../api/certificates'
+import {
+ getCertificates,
+ getCertificateDetail,
+ uploadCertificate,
+ updateCertificate,
+ deleteCertificate,
+ exportCertificate,
+ validateCertificate,
+} from '../api/certificates'
+
+import type { CertificateDetail } from '../api/certificates'
interface UseCertificatesOptions {
refetchInterval?: number | false
@@ -20,3 +30,103 @@ export function useCertificates(options?: UseCertificatesOptions) {
refetch,
}
}
+
+export function useCertificateDetail(uuid: string | null) {
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['certificates', uuid],
+ queryFn: () => getCertificateDetail(uuid!),
+ enabled: !!uuid,
+ })
+
+ return {
+ detail: data as CertificateDetail | undefined,
+ isLoading,
+ error,
+ }
+}
+
+export function useUploadCertificate() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (params: {
+ name: string
+ certFile: File
+ keyFile?: File
+ chainFile?: File
+ }) => uploadCertificate(params.name, params.certFile, params.keyFile, params.chainFile),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['certificates'] })
+ },
+ })
+}
+
+export function useUpdateCertificate() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (params: { uuid: string; name: string }) =>
+ updateCertificate(params.uuid, params.name),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['certificates'] })
+ },
+ })
+}
+
+export function useDeleteCertificate() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (uuid: string) => deleteCertificate(uuid),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['certificates'] })
+ queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
+ },
+ })
+}
+
+export function useExportCertificate() {
+ return useMutation({
+ mutationFn: (params: {
+ uuid: string
+ format: string
+ includeKey: boolean
+ password?: string
+ pfxPassword?: string
+ }) =>
+ exportCertificate(
+ params.uuid,
+ params.format,
+ params.includeKey,
+ params.password,
+ params.pfxPassword,
+ ),
+ })
+}
+
+export function useValidateCertificate() {
+ return useMutation({
+ mutationFn: (params: {
+ certFile: File
+ keyFile?: File
+ chainFile?: File
+ }) => validateCertificate(params.certFile, params.keyFile, params.chainFile),
+ })
+}
+
+export function useBulkDeleteCertificates() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (uuids: string[]) => {
+ const results = await Promise.allSettled(uuids.map(uuid => deleteCertificate(uuid)))
+ const failed = results.filter(r => r.status === 'rejected').length
+ const succeeded = results.filter(r => r.status === 'fulfilled').length
+ return { succeeded, failed }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['certificates'] })
+ queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
+ },
+ })
+}
diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json
index 592a502ff..56e5dce0b 100644
--- a/frontend/src/locales/en/translation.json
+++ b/frontend/src/locales/en/translation.json
@@ -207,7 +207,68 @@
"providerStaging": "Staging",
"providerCustom": "Custom",
"providerExpiredLE": "Expired LE",
- "providerExpiringLE": "Expiring LE"
+ "providerExpiringLE": "Expiring LE",
+
+ "certificateFile": "Certificate File",
+ "privateKeyFile": "Private Key File",
+ "chainFile": "Chain File (Optional)",
+ "dropFileHere": "Drag and drop a file here, or click to browse",
+ "formatDetected": "Detected: {{format}}",
+ "pfxDetected": "PFX/PKCS#12 detected — key is embedded, no separate key file needed.",
+ "pfxPassword": "PFX Password (if protected)",
+
+ "validate": "Validate",
+ "validating": "Validating...",
+ "validationPreview": "Validation Preview",
+ "commonName": "Common Name",
+ "domains": "Domains",
+ "issuerOrg": "Issuer",
+ "keyMatch": "Key Match",
+ "chainValid": "Chain Valid",
+ "chainDepth": "Chain Depth",
+ "warnings": "Warnings",
+ "errors": "Errors",
+ "validCertificate": "Valid certificate",
+ "invalidCertificate": "Certificate has errors",
+ "uploadAndSave": "Upload & Save",
+
+ "detailTitle": "Certificate Details",
+ "fingerprint": "Fingerprint",
+ "serialNumber": "Serial Number",
+ "keyType": "Key Type",
+ "notBefore": "Valid From",
+ "autoRenew": "Auto Renew",
+ "createdAt": "Created",
+ "updatedAt": "Last Updated",
+ "assignedHosts": "Assigned Hosts",
+ "noAssignedHosts": "Not assigned to any proxy host",
+ "certificateChain": "Certificate Chain",
+ "noChainData": "No chain data available",
+ "chainLeaf": "Leaf",
+ "chainIntermediate": "Intermediate",
+ "chainRoot": "Root",
+
+ "exportTitle": "Export Certificate",
+ "exportFormat": "Format",
+ "exportFormatPem": "PEM",
+ "exportFormatPfx": "PFX/PKCS#12",
+ "exportFormatDer": "DER",
+ "includePrivateKey": "Include Private Key",
+ "includePrivateKeyWarning": "Exporting the private key requires re-authentication.",
+ "exportPassword": "Account Password",
+ "exportPfxPassword": "PFX Password",
+ "exportButton": "Export",
+ "exportSuccess": "Certificate exported",
+ "exportFailed": "Failed to export certificate",
+
+ "expiresInDays": "Expires in {{days}} days",
+ "expiredAgo": "Expired {{days}} days ago",
+ "viewDetails": "View details",
+ "export": "Export",
+
+ "updateName": "Rename Certificate",
+ "updateSuccess": "Certificate renamed",
+ "updateFailed": "Failed to rename certificate"
},
"auth": {
"login": "Login",
diff --git a/frontend/src/pages/Certificates.tsx b/frontend/src/pages/Certificates.tsx
index 87f2bed7b..472c60392 100644
--- a/frontend/src/pages/Certificates.tsx
+++ b/frontend/src/pages/Certificates.tsx
@@ -1,59 +1,19 @@
-import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, ShieldCheck } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { uploadCertificate } from '../api/certificates'
import CertificateList from '../components/CertificateList'
+import CertificateUploadDialog from '../components/dialogs/CertificateUploadDialog'
import { PageShell } from '../components/layout/PageShell'
-import {
- Button,
- Input,
- Alert,
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogFooter,
- Label,
-} from '../components/ui'
-import { toast } from '../utils/toast'
+import { Button, Alert } from '../components/ui'
export default function Certificates() {
const { t } = useTranslation()
- const [isModalOpen, setIsModalOpen] = useState(false)
- const [name, setName] = useState('')
- const [certFile, setCertFile] = useState(null)
- const [keyFile, setKeyFile] = useState(null)
- const queryClient = useQueryClient()
+ const [isUploadOpen, setIsUploadOpen] = useState(false)
- const uploadMutation = useMutation({
- mutationFn: async () => {
- if (!certFile || !keyFile) throw new Error('Files required')
- await uploadCertificate(name, certFile, keyFile)
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['certificates'] })
- setIsModalOpen(false)
- setName('')
- setCertFile(null)
- setKeyFile(null)
- toast.success(t('certificates.uploadSuccess'))
- },
- onError: (error: Error) => {
- toast.error(`${t('certificates.uploadFailed')}: ${error.message}`)
- },
- })
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault()
- uploadMutation.mutate()
- }
-
- // Header actions
const headerActions = (
-