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 ? ( +
+ +
+
{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 && ( +
+
+ )} + + {result.errors.length > 0 && ( +
+
+ )} +
+ ) +} 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 ( + + + + {t('certificates.detailTitle')} + + + {isLoading && ( +
      +
      + )} + + {detail && ( +
      +
      +
      +
      {t('certificates.friendlyName')}
      +
      {detail.name || '-'}
      + +
      {t('certificates.commonName')}
      +
      {detail.common_name || '-'}
      + +
      {t('certificates.domains')}
      +
      {detail.domains || '-'}
      + +
      {t('certificates.issuerOrg')}
      +
      {detail.issuer_org || detail.issuer || '-'}
      + +
      {t('certificates.fingerprint')}
      +
      + {detail.fingerprint || '-'} +
      + +
      {t('certificates.serialNumber')}
      +
      + {detail.serial_number || '-'} +
      + +
      {t('certificates.keyType')}
      +
      {detail.key_type || '-'}
      + +
      {t('certificates.status')}
      +
      {detail.status}
      + +
      {t('certificates.provider')}
      +
      {detail.provider}
      + +
      {t('certificates.notBefore')}
      +
      + {detail.not_before ? new Date(detail.not_before).toLocaleDateString() : '-'} +
      + +
      {t('certificates.expiresAt')}
      +
      + {detail.expires_at ? new Date(detail.expires_at).toLocaleDateString() : '-'} +
      + +
      {t('certificates.autoRenew')}
      +
      + {detail.auto_renew ? t('common.yes') : t('common.no')} +
      + +
      {t('certificates.createdAt')}
      +
      + {detail.created_at ? new Date(detail.created_at).toLocaleDateString() : '-'} +
      + +
      {t('certificates.updatedAt')}
      +
      + {detail.updated_at ? new Date(detail.updated_at).toLocaleDateString() : '-'} +
      +
      +
      + +
      +

      + {t('certificates.assignedHosts')} +

      + {detail.assigned_hosts?.length > 0 ? ( +
        + {detail.assigned_hosts.map((host) => ( +
      • + {host.name} + {host.domain_names} +
      • + ))} +
      + ) : ( +

      + {t('certificates.noAssignedHosts')} +

      + )} +
      + +
      +

      + {t('certificates.certificateChain')} +

      + +
      +
      + )} +
      +
      + ) +} 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 ( + + + + + + + +
      +
      + +
      + {FORMAT_OPTIONS.map((opt) => ( + + ))} +
      +
      + + {certificate?.has_key && ( +
      + setIncludeKey(e.target.checked)} + className="mt-1 h-4 w-4 rounded border-gray-700 bg-surface-muted text-brand-500 focus:ring-brand-500" + /> +
      + + {includeKey && ( +

      + {t('certificates.includePrivateKeyWarning')} +

      + )} +
      +
      + )} + + {includeKey && ( + setPassword(e.target.value)} + required + aria-required="true" + autoComplete="current-password" + /> + )} + + {format === 'pfx' && ( + setPfxPassword(e.target.value)} + autoComplete="off" + /> + )} + + + + + +
      +
      +
      + ) +} 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 ( + + + + {t('certificates.uploadCertificate')} + +
      + setName(e.target.value)} + placeholder="e.g. My Custom Cert" + required + aria-required="true" + /> + + { + setCertFile(f) + setValidationResult(null) + }} + required + formatBadge={certFormat} + /> + + {isPfx && ( +

      + {t('certificates.pfxDetected')} +

      + )} + + {!isPfx && ( + <> + { + setKeyFile(f) + setValidationResult(null) + }} + /> + + { + setChainFile(f) + setValidationResult(null) + }} + /> + + )} + + {certFile && !validationResult && ( + + )} + + {validationResult && ( + + )} + + + + + + +
      +
      + ) +} 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 ? ( +
      +
      + ) : ( +
      +
      + )} +
      +
      + ) +} 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 = ( - ) @@ -70,56 +30,7 @@ export default function Certificates() { - {/* Upload Certificate Dialog */} - - - - {t('certificates.uploadCertificate')} - -
      - setName(e.target.value)} - placeholder="e.g. My Custom Cert" - required - /> -
      - - setCertFile(e.target.files?.[0] || null)} - className="mt-1.5 block w-full text-sm text-content-secondary file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-500 file:text-white hover:file:bg-brand-600 cursor-pointer" - required - /> -
      -
      - - setKeyFile(e.target.files?.[0] || null)} - className="mt-1.5 block w-full text-sm text-content-secondary file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-500 file:text-white hover:file:bg-brand-600 cursor-pointer" - required - /> -
      - - - - -
      -
      -
      + ) } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 878fee0c0..b5c2715e7 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -44,8 +44,8 @@ export default function Dashboard() { const certifiedDomains = new Set() for (const cert of certificates) { // Handle missing or undefined domain field - if (!cert.domain) continue - for (const d of cert.domain.split(',')) { + if (!cert.domains) continue + for (const d of cert.domains.split(',')) { const trimmed = d.trim().toLowerCase() if (trimmed) certifiedDomains.add(trimmed) } diff --git a/frontend/src/pages/ProxyHosts.tsx b/frontend/src/pages/ProxyHosts.tsx index 869f9957a..59d1873f5 100644 --- a/frontend/src/pages/ProxyHosts.tsx +++ b/frontend/src/pages/ProxyHosts.tsx @@ -57,7 +57,7 @@ export default function ProxyHosts() { const [certCleanupData, setCertCleanupData] = useState<{ hostUUIDs: string[] hostNames: string[] - certificates: Array<{ id: number; name: string; domain: string }> + certificates: Array<{ uuid: string; name: string; domain: string }> isBulk: boolean } | null>(null) const [selectedACLs, setSelectedACLs] = useState>(new Set()) @@ -103,7 +103,7 @@ export default function ProxyHosts() { const certStatusByDomain = useMemo(() => { const map: Record = {} for (const cert of certificates) { - const domains = cert.domain.split(',').map(d => d.trim().toLowerCase()) + const domains = cert.domains.split(',').map(d => d.trim().toLowerCase()) for (const domain of domains) { if (!map[domain]) { map[domain] = { status: cert.status, provider: cert.provider } @@ -148,7 +148,7 @@ export default function ProxyHosts() { const host = hostToDelete // Check for orphaned certificates that would need cleanup - const orphanedCerts: Array<{ id: number; name: string; domain: string }> = [] + const orphanedCerts: Array<{ uuid: string; name: string; domain: string }> = [] if (host.certificate_id && host.certificate) { const cert = host.certificate @@ -160,7 +160,7 @@ export default function ProxyHosts() { const isCustomOrStaging = cert.provider === 'custom' || cert.provider?.toLowerCase().includes('staging') if (isCustomOrStaging) { orphanedCerts.push({ - id: cert.id!, + uuid: cert.uuid, name: cert.name || '', domain: cert.domains }) @@ -237,7 +237,7 @@ export default function ProxyHosts() { for (const cert of certCleanupData.certificates) { try { - await deleteCertificate(cert.id) + await deleteCertificate(cert.uuid) certsDeleted++ } catch { certsFailed++ @@ -282,7 +282,7 @@ export default function ProxyHosts() { // Delete certificate if user confirmed if (deleteCerts && certCleanupData.certificates.length > 0) { try { - await deleteCertificate(certCleanupData.certificates[0].id) + await deleteCertificate(certCleanupData.certificates[0].uuid) toast.success('Proxy host and certificate deleted') } catch (err) { toast.error(`Proxy host deleted but failed to delete certificate: ${err instanceof Error ? err.message : 'Unknown error'}`) @@ -329,7 +329,7 @@ export default function ProxyHosts() { toast.success(`Backup created: ${backup.filename}`) // Collect certificates to potentially delete - const certsToConsider: Map = new Map() + const certsToConsider: Map = new Map() for (const uuid of hostUUIDs) { const host = hosts.find(h => h.uuid === uuid) @@ -343,9 +343,9 @@ export default function ProxyHosts() { h.certificate_id === host.certificate_id && !hostUUIDs.includes(h.uuid) ) - if (otherHosts.length === 0 && cert.id) { - certsToConsider.set(cert.id, { - id: cert.id, + if (otherHosts.length === 0 && cert.uuid) { + certsToConsider.set(cert.uuid, { + uuid: cert.uuid, name: cert.name || '', domain: cert.domains }) diff --git a/frontend/src/pages/__tests__/Certificates.test.tsx b/frontend/src/pages/__tests__/Certificates.test.tsx index c211726b5..d86bc817b 100644 --- a/frontend/src/pages/__tests__/Certificates.test.tsx +++ b/frontend/src/pages/__tests__/Certificates.test.tsx @@ -1,55 +1,27 @@ -import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { uploadCertificate, type Certificate } from '../../api/certificates' import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient' -import { toast } from '../../utils/toast' import Certificates from '../Certificates' - - -const translations: Record = { - 'certificates.addCertificate': 'Add Certificate', - 'certificates.uploadCertificate': 'Upload Certificate', - 'certificates.friendlyName': 'Friendly Name', - 'certificates.certificatePem': 'Certificate (PEM)', - 'certificates.privateKeyPem': 'Private Key (PEM)', - 'certificates.uploadSuccess': 'Certificate uploaded successfully', - 'certificates.uploadFailed': 'Failed to upload certificate', - 'common.upload': 'Upload', - 'common.cancel': 'Cancel', -} - -const t = (key: string, options?: Record) => { - const template = translations[key] ?? key - - if (!options) return template - - return Object.entries(options).reduce((acc, [optionKey, optionValue]) => { - return acc.replace(`{{${optionKey}}}`, String(optionValue)) - }, template) -} - vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t, + t: (key: string) => key, }), })) vi.mock('../../components/CertificateList', () => ({ - default: () =>
      CertificateList
      , -})) - -vi.mock('../../api/certificates', () => ({ - uploadCertificate: vi.fn(), + default: () =>
      CertificateList
      , })) -vi.mock('../../utils/toast', () => ({ - toast: { - success: vi.fn(), - error: vi.fn(), - }, +vi.mock('../../components/dialogs/CertificateUploadDialog', () => ({ + default: ({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) => + open ? ( +
      + +
      + ) : null, })) describe('Certificates', () => { @@ -57,93 +29,35 @@ describe('Certificates', () => { vi.clearAllMocks() }) - it('uploads certificate and closes dialog on success', async () => { - const certificate: Certificate = { - domain: 'example.com', - issuer: 'Test CA', - expires_at: '2026-03-01T00:00:00Z', - status: 'valid', - provider: 'custom', - } - vi.mocked(uploadCertificate).mockResolvedValue(certificate) + it('renders the page with certificate list and add button', () => { + renderWithQueryClient() + expect(screen.getByText('certificates.addCertificate')).toBeInTheDocument() + expect(screen.getByTestId('certificate-list')).toBeInTheDocument() + }) + it('opens upload dialog when add button is clicked', async () => { const user = userEvent.setup() - const { queryClient } = renderWithQueryClient() - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') - - await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') })) - - const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') }) - - const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement - await user.type(nameInput, 'My Cert') - await waitFor(() => { - expect(nameInput.value).toBe('My Cert') - }) - - const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' }) - const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' }) - - const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement - const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement - - await user.upload(certInput, certFile) - await user.upload(keyInput, keyFile) - - await waitFor(() => { - expect(certInput.files?.[0]).toBe(certFile) - expect(keyInput.files?.[0]).toBe(keyFile) - }) - - const form = dialog.querySelector('form') as HTMLFormElement - fireEvent.submit(form) + renderWithQueryClient() - await waitFor(() => { - expect(uploadCertificate).toHaveBeenCalledWith('My Cert', certFile, keyFile) - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['certificates'] }) - expect(toast.success).toHaveBeenCalledWith(t('certificates.uploadSuccess')) - }) + expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument() - await waitFor(() => { - expect(screen.queryByRole('dialog', { name: t('certificates.uploadCertificate') })).not.toBeInTheDocument() - }) + await user.click(screen.getByRole('button', { name: 'certificates.addCertificate' })) + expect(screen.getByTestId('upload-dialog')).toBeInTheDocument() }) - it('surfaces upload errors', async () => { - vi.mocked(uploadCertificate).mockRejectedValue(new Error('Upload failed')) - + it('closes upload dialog via onOpenChange callback', async () => { const user = userEvent.setup() renderWithQueryClient() - await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') })) - - const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') }) + await user.click(screen.getByRole('button', { name: 'certificates.addCertificate' })) + expect(screen.getByTestId('upload-dialog')).toBeInTheDocument() - const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement - await user.type(nameInput, 'My Cert') - await waitFor(() => { - expect(nameInput.value).toBe('My Cert') - }) - - const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' }) - const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' }) - - const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement - const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement - - await user.upload(certInput, certFile) - await user.upload(keyInput, keyFile) - - await waitFor(() => { - expect(certInput.files?.[0]).toBe(certFile) - expect(keyInput.files?.[0]).toBe(keyFile) - }) - - const form = dialog.querySelector('form') as HTMLFormElement - fireEvent.submit(form) + await user.click(screen.getByText('Close')) + expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument() + }) - await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith(`${t('certificates.uploadFailed')}: Upload failed`) - }) + it('renders info alert with note text', () => { + renderWithQueryClient() + expect(screen.getByText('certificates.noteText')).toBeInTheDocument() }) }) diff --git a/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx index 93647bb08..b25ed93ec 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx @@ -485,8 +485,8 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([hostCustom, hostStaging, hostAuto, hostLets]) vi.mocked(certificatesApi.getCertificates).mockResolvedValue([ - { domain: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' }, - { domain: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' }, + { uuid: 'cert-staging', domains: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01', has_key: false, in_use: true }, + { uuid: 'cert-lets', domains: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01', has_key: false, in_use: true }, ]) vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) diff --git a/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx index 8337fbc27..386c89e2d 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx @@ -190,12 +190,15 @@ describe('ProxyHosts page extra tests', () => { certificates: [ { id: 1, + uuid: 'cert-le-1', name: 'LE', - domain: 'valid.example.com', + domains: 'valid.example.com', issuer: 'letsencrypt', expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), status: 'valid', provider: 'letsencrypt', + has_key: false, + in_use: true, }, ], }), From 9dc55675ca0ac7a5112492cf77797372dfac6201 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 00:19:19 +0000 Subject: [PATCH 14/57] fix: update Coraza Caddy version to 2.5.0 for compatibility --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index bae1962ba..3c4f31c5f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,7 +45,7 @@ ARG CADDY_PATCH_SCENARIO=B # renovate: datasource=go depName=github.com/greenpau/caddy-security ARG CADDY_SECURITY_VERSION=1.1.61 # renovate: datasource=go depName=github.com/corazawaf/coraza-caddy -ARG CORAZA_CADDY_VERSION=2.4.0 +ARG CORAZA_CADDY_VERSION=2.5.0 ## When an official caddy image tag isn't available on the host, use a ## plain Alpine base image and overwrite its caddy binary with our ## xcaddy-built binary in the later COPY step. This avoids relying on From 9d8d97e5561e730b52adfb82df6c3d54bdf26eb2 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 01:15:41 +0000 Subject: [PATCH 15/57] fix: update @csstools/css-calc, @csstools/css-color-parser, @tanstack/query-core, globals, builtin-modules, knip, and undici to latest versions for improved functionality and security --- frontend/package-lock.json | 52 +++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c640baa52..257325635 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -584,9 +584,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", "dev": true, "funding": [ { @@ -608,9 +608,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", "dev": true, "funding": [ { @@ -625,7 +625,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" + "@csstools/css-calc": "^3.2.0" }, "engines": { "node": ">=20.19.0" @@ -659,9 +659,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", - "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", "dev": true, "funding": [ { @@ -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.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.97.0" + "@tanstack/query-core": "5.99.0" }, "funding": { "type": "github", @@ -7080,9 +7080,9 @@ } }, "node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", "dev": true, "license": "MIT", "engines": { @@ -7486,9 +7486,9 @@ } }, "node_modules/is-builtin-module/node_modules/builtin-modules": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz", - "integrity": "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.1.0.tgz", + "integrity": "sha512-c5JxaDrzwRjq3WyJkI1AGR5xy6Gr6udlt7sQPbl09+3ckB+Zo2qqQ2KhCTBr7Q8dHB43bENGYEk4xddrFH/b7A==", "dev": true, "license": "MIT", "engines": { @@ -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.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.4.1.tgz", + "integrity": "sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw==", "dev": true, "funding": [ { @@ -11341,9 +11341,9 @@ } }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", "dev": true, "license": "MIT", "engines": { From e1bc648dfc272ea940bfd5a04cf0774733545fae Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 01:55:40 +0000 Subject: [PATCH 16/57] test: add certificate feature unit tests and null-safety fix Add comprehensive unit tests for the certificate upload, export, and detail management feature: - CertificateExportDialog: 21 tests covering format selection, blob download, error handling, and password-protected exports - CertificateUploadDialog: 23 tests covering file validation, format detection, drag-and-drop, and upload flow - CertificateDetailDialog: 19 tests covering detail display, loading state, missing fields, and branch coverage - CertificateChainViewer: 8 tests covering chain visualization - CertificateValidationPreview: 16 tests covering validation display - FileDropZone: 18 tests covering drag-and-drop interactions - useCertificates hooks: 10 tests covering all React Query hooks - certificates API: 7 new tests for previously uncovered endpoints Fix null-safety issue in ProxyHosts where cert.domains could be undefined, causing a runtime error on split(). Frontend patch coverage: 90.6%, overall lines: 89.09% --- .../src/api/__tests__/certificates.test.ts | 82 ++++- .../__tests__/CertificateChainViewer.test.tsx | 71 ++++ .../CertificateValidationPreview.test.tsx | 135 ++++++++ .../CertificateDetailDialog.test.tsx | 247 ++++++++++++++ .../CertificateExportDialog.test.tsx | 275 ++++++++++++++++ .../CertificateUploadDialog.test.tsx | 309 ++++++++++++++++++ .../ui/__tests__/FileDropZone.test.tsx | 157 +++++++++ .../hooks/__tests__/useCertificates.test.tsx | 238 ++++++++++++++ frontend/src/pages/ProxyHosts.tsx | 2 +- 9 files changed, 1514 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/__tests__/CertificateChainViewer.test.tsx create mode 100644 frontend/src/components/__tests__/CertificateValidationPreview.test.tsx create mode 100644 frontend/src/components/dialogs/__tests__/CertificateDetailDialog.test.tsx create mode 100644 frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx create mode 100644 frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx create mode 100644 frontend/src/components/ui/__tests__/FileDropZone.test.tsx create mode 100644 frontend/src/hooks/__tests__/useCertificates.test.tsx diff --git a/frontend/src/api/__tests__/certificates.test.ts b/frontend/src/api/__tests__/certificates.test.ts index 5777290ff..140d70868 100644 --- a/frontend/src/api/__tests__/certificates.test.ts +++ b/frontend/src/api/__tests__/certificates.test.ts @@ -1,12 +1,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getCertificates, uploadCertificate, deleteCertificate, type Certificate } from '../certificates'; +import { + getCertificates, + getCertificateDetail, + uploadCertificate, + updateCertificate, + deleteCertificate, + exportCertificate, + validateCertificate, + type Certificate, + type CertificateDetail, +} from '../certificates'; import client from '../client'; vi.mock('../client', () => ({ default: { get: vi.fn(), post: vi.fn(), + put: vi.fn(), delete: vi.fn(), }, })); @@ -52,4 +63,73 @@ describe('certificates API', () => { await deleteCertificate('abc-123'); expect(client.delete).toHaveBeenCalledWith('/certificates/abc-123'); }); + + it('getCertificateDetail calls client.get with uuid', async () => { + const detail: CertificateDetail = { + ...mockCert, + assigned_hosts: [], + chain: [], + auto_renew: false, + created_at: '2023-01-01', + updated_at: '2023-01-01', + }; + vi.mocked(client.get).mockResolvedValue({ data: detail }); + const result = await getCertificateDetail('abc-123'); + expect(client.get).toHaveBeenCalledWith('/certificates/abc-123'); + expect(result).toEqual(detail); + }); + + it('updateCertificate calls client.put with name', async () => { + vi.mocked(client.put).mockResolvedValue({ data: mockCert }); + const result = await updateCertificate('abc-123', 'New Name'); + expect(client.put).toHaveBeenCalledWith('/certificates/abc-123', { name: 'New Name' }); + expect(result).toEqual(mockCert); + }); + + it('exportCertificate calls client.post with blob response type', async () => { + const blob = new Blob(['data']); + vi.mocked(client.post).mockResolvedValue({ data: blob }); + const result = await exportCertificate('abc-123', 'pem', true, 'pass', 'pfx-pass'); + expect(client.post).toHaveBeenCalledWith( + '/certificates/abc-123/export', + { format: 'pem', include_key: true, password: 'pass', pfx_password: 'pfx-pass' }, + { responseType: 'blob' }, + ); + expect(result).toEqual(blob); + }); + + it('validateCertificate calls client.post with FormData', async () => { + const validation = { valid: true, common_name: 'example.com', domains: ['example.com'], issuer_org: 'LE', expires_at: '2024-01-01', key_match: true, chain_valid: true, chain_depth: 1, warnings: [], errors: [] }; + vi.mocked(client.post).mockResolvedValue({ data: validation }); + const certFile = new File(['cert'], 'cert.pem', { type: 'text/plain' }); + const keyFile = new File(['key'], 'key.pem', { type: 'text/plain' }); + + const result = await validateCertificate(certFile, keyFile); + expect(client.post).toHaveBeenCalledWith('/certificates/validate', expect.any(FormData), { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + expect(result).toEqual(validation); + }); + + it('uploadCertificate includes chain file when provided', async () => { + vi.mocked(client.post).mockResolvedValue({ data: mockCert }); + const certFile = new File(['cert'], 'cert.pem'); + const keyFile = new File(['key'], 'key.pem'); + const chainFile = new File(['chain'], 'chain.pem'); + + await uploadCertificate('My Cert', certFile, keyFile, chainFile); + const formData = vi.mocked(client.post).mock.calls[0][1] as FormData; + expect(formData.get('chain_file')).toBeTruthy(); + }); + + it('validateCertificate includes chain file when provided', async () => { + vi.mocked(client.post).mockResolvedValue({ data: {} }); + const certFile = new File(['cert'], 'cert.pem'); + const chainFile = new File(['chain'], 'chain.pem'); + + await validateCertificate(certFile, undefined, chainFile); + const formData = vi.mocked(client.post).mock.calls[0][1] as FormData; + expect(formData.get('chain_file')).toBeTruthy(); + expect(formData.get('key_file')).toBeNull(); + }); }); diff --git a/frontend/src/components/__tests__/CertificateChainViewer.test.tsx b/frontend/src/components/__tests__/CertificateChainViewer.test.tsx new file mode 100644 index 000000000..cb88c7fd2 --- /dev/null +++ b/frontend/src/components/__tests__/CertificateChainViewer.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +import type { ChainEntry } from '../../api/certificates' +import CertificateChainViewer from '../CertificateChainViewer' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +function makeChain(count: number): ChainEntry[] { + return Array.from({ length: count }, (_, i) => ({ + subject: `Subject ${i}`, + issuer: `Issuer ${i}`, + expires_at: '2026-06-01T00:00:00Z', + })) +} + +describe('CertificateChainViewer', () => { + it('renders empty state when chain is empty', () => { + render() + expect(screen.getByText('certificates.noChainData')).toBeTruthy() + }) + + it('renders single entry as leaf', () => { + render() + expect(screen.getByText('certificates.chainLeaf')).toBeTruthy() + expect(screen.getByText('Subject 0')).toBeTruthy() + }) + + it('renders two entries as leaf + root', () => { + render() + expect(screen.getByText('certificates.chainLeaf')).toBeTruthy() + expect(screen.getByText('certificates.chainRoot')).toBeTruthy() + }) + + it('renders three entries as leaf + intermediate + root', () => { + render() + expect(screen.getByText('certificates.chainLeaf')).toBeTruthy() + expect(screen.getByText('certificates.chainIntermediate')).toBeTruthy() + expect(screen.getByText('certificates.chainRoot')).toBeTruthy() + }) + + it('displays issuer for each entry', () => { + render() + expect(screen.getByText(/Issuer 0/)).toBeTruthy() + expect(screen.getByText(/Issuer 1/)).toBeTruthy() + }) + + it('displays formatted expiration dates', () => { + render() + const dateStr = new Date('2026-06-01T00:00:00Z').toLocaleDateString() + expect(screen.getByText(new RegExp(dateStr))).toBeTruthy() + }) + + it('uses list role with list items', () => { + render() + expect(screen.getByRole('list')).toBeTruthy() + expect(screen.getAllByRole('listitem')).toHaveLength(2) + }) + + it('has aria-label on list', () => { + render() + expect(screen.getByRole('list').getAttribute('aria-label')).toBe( + 'certificates.certificateChain', + ) + }) +}) diff --git a/frontend/src/components/__tests__/CertificateValidationPreview.test.tsx b/frontend/src/components/__tests__/CertificateValidationPreview.test.tsx new file mode 100644 index 000000000..5f97a2886 --- /dev/null +++ b/frontend/src/components/__tests__/CertificateValidationPreview.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +import type { ValidationResult } from '../../api/certificates' +import CertificateValidationPreview from '../CertificateValidationPreview' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +function makeResult(overrides: Partial = {}): ValidationResult { + return { + valid: true, + common_name: 'example.com', + domains: ['example.com', 'www.example.com'], + issuer_org: 'Test CA', + expires_at: '2026-06-01T00:00:00Z', + key_match: true, + chain_valid: true, + chain_depth: 2, + warnings: [], + errors: [], + ...overrides, + } +} + +describe('CertificateValidationPreview', () => { + it('renders valid certificate state', () => { + render() + expect(screen.getByText('certificates.validCertificate')).toBeTruthy() + expect(screen.getByTestId('certificate-validation-preview')).toBeTruthy() + }) + + it('renders invalid certificate state', () => { + render( + , + ) + expect(screen.getByText('certificates.invalidCertificate')).toBeTruthy() + }) + + it('displays common name', () => { + render() + expect(screen.getByText('example.com')).toBeTruthy() + }) + + it('displays domains joined by comma', () => { + render() + expect(screen.getByText('example.com, www.example.com')).toBeTruthy() + }) + + it('displays dash when no domains provided', () => { + render( + , + ) + const dashes = screen.getAllByText('-') + expect(dashes.length).toBeGreaterThan(0) + }) + + it('displays issuer organization', () => { + render() + expect(screen.getByText('Test CA')).toBeTruthy() + }) + + it('displays formatted expiration date', () => { + render() + const dateStr = new Date('2026-06-01T00:00:00Z').toLocaleDateString() + expect(screen.getByText(dateStr)).toBeTruthy() + }) + + it('shows Yes for key match', () => { + render() + expect(screen.getByText('Yes')).toBeTruthy() + }) + + it('shows No key provided when no key match', () => { + render( + , + ) + expect(screen.getByText('No key provided')).toBeTruthy() + }) + + it('shows chain depth when > 0', () => { + render( + , + ) + expect(screen.getByText('3')).toBeTruthy() + }) + + it('does not show chain depth when 0', () => { + render( + , + ) + expect(screen.queryByText('certificates.chainDepth')).toBeFalsy() + }) + + it('renders warnings when present', () => { + render( + , + ) + expect(screen.getByText('certificates.warnings')).toBeTruthy() + expect(screen.getByText('Expiring soon')).toBeTruthy() + expect(screen.getByText('Weak key')).toBeTruthy() + }) + + it('does not render warnings section when empty', () => { + render() + expect(screen.queryByText('certificates.warnings')).toBeFalsy() + }) + + it('renders errors when present', () => { + render( + , + ) + expect(screen.getByText('certificates.errors')).toBeTruthy() + expect(screen.getByText('Certificate revoked')).toBeTruthy() + }) + + it('does not render errors section when empty', () => { + render() + expect(screen.queryByText('certificates.errors')).toBeFalsy() + }) + + it('has correct region role and aria-label', () => { + render() + const region = screen.getByRole('region') + expect(region.getAttribute('aria-label')).toBe('certificates.validationPreview') + }) +}) diff --git a/frontend/src/components/dialogs/__tests__/CertificateDetailDialog.test.tsx b/frontend/src/components/dialogs/__tests__/CertificateDetailDialog.test.tsx new file mode 100644 index 000000000..c2ced1569 --- /dev/null +++ b/frontend/src/components/dialogs/__tests__/CertificateDetailDialog.test.tsx @@ -0,0 +1,247 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import type { Certificate, CertificateDetail } from '../../../api/certificates' +import { useCertificateDetail } from '../../../hooks/useCertificates' +import { createTestQueryClient } from '../../../test/createTestQueryClient' +import CertificateDetailDialog from '../CertificateDetailDialog' + +const mockDetail: CertificateDetail = { + uuid: 'cert-1', + name: 'My Cert', + common_name: 'app.example.com', + domains: 'app.example.com, api.example.com', + issuer: 'Test CA', + issuer_org: 'Test Org', + fingerprint: 'AA:BB:CC:DD', + serial_number: '1234567890', + key_type: 'RSA 2048', + expires_at: '2026-06-01T00:00:00Z', + not_before: '2024-03-15T00:00:00Z', + status: 'valid', + provider: 'custom', + has_key: true, + in_use: true, + auto_renew: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-08-20T00:00:00Z', + assigned_hosts: [ + { uuid: 'host-1', name: 'Web Server', domain_names: 'web.example.com' }, + ], + chain: [ + { subject: 'app.example.com', issuer: 'Test CA', expires_at: '2026-06-01T00:00:00Z' }, + { subject: 'Test CA', issuer: 'Root CA', expires_at: '2030-01-01T00:00:00Z' }, + ], +} + +vi.mock('../../../hooks/useCertificates', () => ({ + useCertificateDetail: vi.fn((uuid: string | null) => { + if (!uuid) return { detail: undefined, isLoading: false } + return { detail: mockDetail, isLoading: false } + }), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +const baseCert: Certificate = { + uuid: 'cert-1', + name: 'My Cert', + domains: 'example.com', + issuer: 'Test CA', + expires_at: '2026-06-01T00:00:00Z', + status: 'valid', + provider: 'custom', + has_key: true, + in_use: true, +} + +function renderDialog( + certificate: Certificate | null = baseCert, + open = true, + onOpenChange = vi.fn(), +) { + const qc = createTestQueryClient() + return { + onOpenChange, + ...render( + + + , + ), + } +} + +describe('CertificateDetailDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders dialog with title when open', () => { + renderDialog() + expect(screen.getByTestId('certificate-detail-dialog')).toBeTruthy() + expect(screen.getByText('certificates.detailTitle')).toBeTruthy() + }) + + it('does not render when closed', () => { + renderDialog(baseCert, false) + expect(screen.queryByTestId('certificate-detail-dialog')).toBeFalsy() + }) + + it('displays certificate name', () => { + renderDialog() + expect(screen.getByText('My Cert')).toBeTruthy() + }) + + it('displays common name', () => { + renderDialog() + const matches = screen.getAllByText(/app\.example\.com/) + expect(matches.length).toBeGreaterThanOrEqual(1) + }) + + it('displays fingerprint', () => { + renderDialog() + expect(screen.getByText('AA:BB:CC:DD')).toBeTruthy() + }) + + it('displays serial number', () => { + renderDialog() + expect(screen.getByText('1234567890')).toBeTruthy() + }) + + it('displays key type', () => { + renderDialog() + expect(screen.getByText('RSA 2048')).toBeTruthy() + }) + + it('displays status', () => { + renderDialog() + expect(screen.getByText('valid')).toBeTruthy() + }) + + it('displays provider', () => { + renderDialog() + expect(screen.getByText('custom')).toBeTruthy() + }) + + it('displays assigned hosts section', () => { + renderDialog() + expect(screen.getByText('certificates.assignedHosts')).toBeTruthy() + expect(screen.getByText('Web Server')).toBeTruthy() + }) + + it('displays certificate chain section', () => { + renderDialog() + expect(screen.getByText('certificates.certificateChain')).toBeTruthy() + }) + + it('shows auto renew status', () => { + renderDialog() + expect(screen.getByText('common.no')).toBeTruthy() + }) + + it('shows formatted dates', () => { + renderDialog() + const notBeforeDate = new Date('2024-03-15T00:00:00Z').toLocaleDateString() + const updatedDate = new Date('2024-08-20T00:00:00Z').toLocaleDateString() + expect(screen.getByText(notBeforeDate)).toBeTruthy() + expect(screen.getByText(updatedDate)).toBeTruthy() + }) + + it('shows loading state', () => { + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: undefined as unknown as CertificateDetail, + isLoading: true, + }) + renderDialog() + expect(screen.getByTestId('certificate-detail-dialog')).toBeTruthy() + // Detail content should not be rendered while loading + expect(screen.queryByText('My Cert')).toBeFalsy() + }) + + it('shows dash for missing optional fields', () => { + const sparseDetail: CertificateDetail = { + ...mockDetail, + name: '', + common_name: '', + domains: '', + issuer_org: '', + issuer: '', + fingerprint: '', + serial_number: '', + key_type: '', + not_before: '', + expires_at: '', + created_at: '', + updated_at: '', + chain: [], + assigned_hosts: [], + } + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: sparseDetail, + isLoading: false, + }) + renderDialog() + const dashes = screen.getAllByText('-') + // Many fields should fall back to '-' when empty + expect(dashes.length).toBeGreaterThanOrEqual(8) + }) + + it('shows no assigned hosts message when empty', () => { + const noHostDetail: CertificateDetail = { + ...mockDetail, + assigned_hosts: [], + } + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: noHostDetail, + isLoading: false, + }) + renderDialog() + expect(screen.getByText('certificates.noAssignedHosts')).toBeTruthy() + }) + + it('shows auto renew yes when enabled', () => { + const autoRenewDetail: CertificateDetail = { + ...mockDetail, + auto_renew: true, + } + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: autoRenewDetail, + isLoading: false, + }) + renderDialog() + expect(screen.getByText('common.yes')).toBeTruthy() + }) + + it('falls back to issuer when issuer_org is missing', () => { + const noOrgDetail: CertificateDetail = { + ...mockDetail, + issuer_org: '', + issuer: 'Fallback Issuer', + } + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: noOrgDetail, + isLoading: false, + }) + renderDialog() + expect(screen.getByText('Fallback Issuer')).toBeTruthy() + }) + + it('renders nothing when certificate is null', () => { + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: undefined as unknown as CertificateDetail, + isLoading: false, + }) + renderDialog(null) + expect(screen.queryByText('My Cert')).toBeFalsy() + }) +}) diff --git a/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx b/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx new file mode 100644 index 000000000..dc6b140ae --- /dev/null +++ b/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx @@ -0,0 +1,275 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import type { Certificate } from '../../../api/certificates' +import { createTestQueryClient } from '../../../test/createTestQueryClient' +import CertificateExportDialog from '../CertificateExportDialog' + +const exportMutateFn = vi.fn() + +vi.mock('../../../hooks/useCertificates', () => ({ + useExportCertificate: vi.fn(() => ({ + mutate: exportMutateFn, + isPending: false, + })), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +vi.mock('../../../utils/toast', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const baseCert: Certificate = { + uuid: 'cert-1', + name: 'Test Cert', + domains: 'example.com', + issuer: 'Test CA', + expires_at: '2026-06-01T00:00:00Z', + status: 'valid', + provider: 'custom', + has_key: true, + in_use: false, +} + +function renderDialog( + certificate: Certificate | null = baseCert, + open = true, + onOpenChange = vi.fn(), +) { + const qc = createTestQueryClient() + return { + onOpenChange, + ...render( + + + , + ), + } +} + +describe('CertificateExportDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders dialog when open', () => { + renderDialog() + expect(screen.getByTestId('certificate-export-dialog')).toBeTruthy() + expect(screen.getByText('certificates.exportTitle')).toBeTruthy() + }) + + it('does not render when closed', () => { + renderDialog(baseCert, false) + expect(screen.queryByTestId('certificate-export-dialog')).toBeFalsy() + }) + + it('shows format radio options', () => { + renderDialog() + expect(screen.getByText('certificates.exportFormatPem')).toBeTruthy() + expect(screen.getByText('certificates.exportFormatPfx')).toBeTruthy() + expect(screen.getByText('certificates.exportFormatDer')).toBeTruthy() + }) + + it('shows include private key checkbox', () => { + renderDialog() + expect(screen.getByText('certificates.includePrivateKey')).toBeTruthy() + }) + + it('shows export button', () => { + renderDialog() + expect(screen.getByTestId('export-certificate-submit')).toBeTruthy() + }) + + it('shows cancel button', () => { + renderDialog() + expect(screen.getByText('common.cancel')).toBeTruthy() + }) + + it('calls onOpenChange(false) on cancel', async () => { + const onOpenChange = vi.fn() + renderDialog(baseCert, true, onOpenChange) + await userEvent.click(screen.getByText('common.cancel')) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('selects PEM format by default', () => { + renderDialog() + const pemRadio = screen.getByRole('radio', { name: 'certificates.exportFormatPem' }) + expect(pemRadio).toHaveAttribute('aria-checked', 'true') + }) + + it('can select PFX format', async () => { + renderDialog() + await userEvent.click(screen.getByText('certificates.exportFormatPfx')) + const pfxRadio = screen.getByRole('radio', { name: 'certificates.exportFormatPfx' }) + expect(pfxRadio).toHaveAttribute('aria-checked', 'true') + }) + + it('shows PFX password when PFX format selected', async () => { + renderDialog() + await userEvent.click(screen.getByText('certificates.exportFormatPfx')) + expect(screen.getByText('certificates.exportPfxPassword')).toBeTruthy() + }) + + it('shows private key warning when include key is checked', async () => { + renderDialog() + const checkbox = screen.getByRole('checkbox') + await userEvent.click(checkbox) + expect(screen.getByText('certificates.includePrivateKeyWarning')).toBeTruthy() + }) + + it('shows password field when include key is checked', async () => { + renderDialog() + const checkbox = screen.getByRole('checkbox') + await userEvent.click(checkbox) + expect(screen.getByText('certificates.exportPassword')).toBeTruthy() + }) + + it('calls export mutation on submit', async () => { + renderDialog() + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(exportMutateFn).toHaveBeenCalledTimes(1) + expect(exportMutateFn.mock.calls[0][0]).toMatchObject({ + uuid: 'cert-1', + format: 'pem', + includeKey: false, + }) + }) + + it('sends include key and password when checked', async () => { + renderDialog() + const checkbox = screen.getByRole('checkbox') + await userEvent.click(checkbox) + + const pwInput = document.getElementById('export-password') as HTMLInputElement + await userEvent.type(pwInput, 'secret123') + + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(exportMutateFn.mock.calls[0][0]).toMatchObject({ + uuid: 'cert-1', + format: 'pem', + includeKey: true, + password: 'secret123', + }) + }) + + it('hides include key checkbox when cert has no key', () => { + const certNoKey = { ...baseCert, has_key: false } + renderDialog(certNoKey) + expect(screen.queryByRole('checkbox')).toBeFalsy() + }) + + it('triggers blob download on export success', async () => { + const fakeBlob = new Blob(['cert-data'], { type: 'application/x-pem-file' }) + const revokeURL = vi.fn() + const createURL = vi.fn(() => 'blob:http://localhost/fake') + global.URL.createObjectURL = createURL + global.URL.revokeObjectURL = revokeURL + + const appendSpy = vi.spyOn(document.body, 'appendChild') + const removeSpy = vi.fn() + + exportMutateFn.mockImplementation( + (_params: unknown, opts: { onSuccess: (b: Blob) => void }) => { + const origCreate = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementationOnce((tag: string) => { + const el = origCreate(tag) as HTMLAnchorElement + el.remove = removeSpy + return el + }) + opts.onSuccess(fakeBlob) + }, + ) + + renderDialog() + await userEvent.click(screen.getByTestId('export-certificate-submit')) + + expect(createURL).toHaveBeenCalledWith(fakeBlob) + expect(appendSpy).toHaveBeenCalled() + expect(revokeURL).toHaveBeenCalledWith('blob:http://localhost/fake') + expect(removeSpy).toHaveBeenCalled() + appendSpy.mockRestore() + }) + + it('shows toast error on export failure', async () => { + const { toast: mockToast } = await import('../../../utils/toast') + exportMutateFn.mockImplementation( + (_params: unknown, opts: { onError: (e: Error) => void }) => { + opts.onError(new Error('Export failed')) + }, + ) + + renderDialog() + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(mockToast.error).toHaveBeenCalled() + }) + + it('selects DER format and submits', async () => { + renderDialog() + await userEvent.click(screen.getByText('certificates.exportFormatDer')) + const derRadio = screen.getByRole('radio', { name: 'certificates.exportFormatDer' }) + expect(derRadio).toHaveAttribute('aria-checked', 'true') + + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(exportMutateFn.mock.calls[0][0]).toMatchObject({ + format: 'der', + }) + }) + + it('sends pfxPassword when PFX format selected', async () => { + renderDialog() + await userEvent.click(screen.getByText('certificates.exportFormatPfx')) + + const pfxInput = document.getElementById('pfx-password') as HTMLInputElement + await userEvent.type(pfxInput, 'pfx-secret') + + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(exportMutateFn.mock.calls[0][0]).toMatchObject({ + format: 'pfx', + pfxPassword: 'pfx-secret', + }) + }) + + it('returns early from submit when certificate is null', async () => { + renderDialog(null) + // Dialog doesn't render without open+cert, so no submit button to click + // Just verify no calls + expect(exportMutateFn).not.toHaveBeenCalled() + }) + + it('uses certificate name in download filename on success', async () => { + const fakeBlob = new Blob(['data']) + global.URL.createObjectURL = vi.fn(() => 'blob:fake') + global.URL.revokeObjectURL = vi.fn() + + let capturedAnchor: HTMLAnchorElement | null = null + exportMutateFn.mockImplementation( + (_params: unknown, opts: { onSuccess: (b: Blob) => void }) => { + const origCreate = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementationOnce((tag: string) => { + const el = origCreate(tag) as HTMLAnchorElement + el.remove = vi.fn() + capturedAnchor = el + return el + }) + opts.onSuccess(fakeBlob) + }, + ) + + renderDialog() + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(capturedAnchor!.download).toBe('Test Cert.pem') + }) +}) diff --git a/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx b/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx new file mode 100644 index 000000000..80f1448af --- /dev/null +++ b/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx @@ -0,0 +1,309 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { createTestQueryClient } from '../../../test/createTestQueryClient' +import CertificateUploadDialog from '../CertificateUploadDialog' +import { toast } from '../../../utils/toast' + +const uploadMutateFn = vi.fn() +const validateMutateFn = vi.fn() + +vi.mock('../../../hooks/useCertificates', () => ({ + useUploadCertificate: vi.fn(() => ({ + mutate: uploadMutateFn, + isPending: false, + })), + useValidateCertificate: vi.fn(() => ({ + mutate: validateMutateFn, + isPending: false, + })), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +vi.mock('../../../utils/toast', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +function renderDialog(open = true, onOpenChange = vi.fn()) { + const qc = createTestQueryClient() + return { + onOpenChange, + ...render( + + + , + ), + } +} + +function createFile(name = 'test.pem'): File { + return new File(['cert-content'], name, { type: 'application/x-pem-file' }) +} + +describe('CertificateUploadDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders dialog when open', () => { + renderDialog() + expect(screen.getByTestId('certificate-upload-dialog')).toBeTruthy() + expect(screen.getByText('certificates.uploadCertificate')).toBeTruthy() + }) + + it('does not render when closed', () => { + renderDialog(false) + expect(screen.queryByTestId('certificate-upload-dialog')).toBeFalsy() + }) + + it('shows certificate file drop zone', () => { + renderDialog() + expect(screen.getByText('certificates.certificateFile')).toBeTruthy() + }) + + it('shows private key and chain file zones for non-PFX', () => { + renderDialog() + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + expect(screen.getByText('certificates.chainFile')).toBeTruthy() + }) + + it('shows name input', () => { + renderDialog() + expect(screen.getByText('certificates.friendlyName')).toBeTruthy() + }) + + it('has cancel and submit buttons', () => { + renderDialog() + expect(screen.getByText('common.cancel')).toBeTruthy() + expect(screen.getByText('certificates.uploadAndSave')).toBeTruthy() + }) + + it('shows validate button after cert file is selected', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = createFile() + fireEvent.change(certInput, { target: { files: [file] } }) + expect(await screen.findByTestId('validate-certificate-btn')).toBeTruthy() + }) + + it('calls validate mutation on validate click', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = createFile() + fireEvent.change(certInput, { target: { files: [file] } }) + + const validateBtn = await screen.findByTestId('validate-certificate-btn') + await userEvent.click(validateBtn) + + expect(validateMutateFn).toHaveBeenCalledTimes(1) + expect(validateMutateFn.mock.calls[0][0]).toMatchObject({ + certFile: file, + }) + }) + + it('calls upload mutation on form submit with name and cert', async () => { + renderDialog() + + const nameInput = screen.getByPlaceholderText('e.g. My Custom Cert') + await userEvent.type(nameInput, 'My Cert') + + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = createFile() + fireEvent.change(certInput, { target: { files: [file] } }) + + const submitBtn = screen.getByTestId('upload-certificate-submit') + await userEvent.click(submitBtn) + + expect(uploadMutateFn).toHaveBeenCalledTimes(1) + expect(uploadMutateFn.mock.calls[0][0]).toMatchObject({ + name: 'My Cert', + certFile: file, + }) + }) + + it('calls onOpenChange(false) on cancel click', async () => { + const onOpenChange = vi.fn() + renderDialog(true, onOpenChange) + const cancelBtn = screen.getByText('common.cancel') + await userEvent.click(cancelBtn) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('shows PFX message when PFX file is selected', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['pfx-content'], 'cert.pfx', { type: 'application/x-pkcs12' }) + fireEvent.change(certInput, { target: { files: [file] } }) + expect(await screen.findByText('certificates.pfxDetected')).toBeTruthy() + }) + + it('hides key and chain drop zones for PFX files', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['pfx-content'], 'cert.pfx', { type: 'application/x-pkcs12' }) + fireEvent.change(certInput, { target: { files: [file] } }) + + await waitFor(() => { + expect(screen.queryByText('certificates.privateKeyFile')).toBeFalsy() + expect(screen.queryByText('certificates.chainFile')).toBeFalsy() + }) + }) + + it('shows toast on upload success', async () => { + uploadMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess() + }) + renderDialog() + + const nameInput = screen.getByPlaceholderText('e.g. My Custom Cert') + await userEvent.type(nameInput, 'Cert') + + const certInput = document.getElementById('cert-file') as HTMLInputElement + fireEvent.change(certInput, { target: { files: [createFile()] } }) + + await userEvent.click(screen.getByTestId('upload-certificate-submit')) + expect(toast.success).toHaveBeenCalledWith('certificates.uploadSuccess') + }) + + it('shows toast on upload error', async () => { + uploadMutateFn.mockImplementation((_params: unknown, opts: { onError: (e: Error) => void }) => { + opts.onError(new Error('Upload failed')) + }) + renderDialog() + + const nameInput = screen.getByPlaceholderText('e.g. My Custom Cert') + await userEvent.type(nameInput, 'Cert') + + const certInput = document.getElementById('cert-file') as HTMLInputElement + fireEvent.change(certInput, { target: { files: [createFile()] } }) + + await userEvent.click(screen.getByTestId('upload-certificate-submit')) + expect(toast.error).toHaveBeenCalled() + }) + + it('shows validation preview after successful validation', async () => { + const mockResult = { + valid: true, + common_name: 'test.com', + domains: ['test.com'], + issuer_org: 'CA', + expires_at: '2026-01-01', + key_match: false, + chain_valid: false, + chain_depth: 0, + warnings: [], + errors: [], + } + validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => { + opts.onSuccess(mockResult) + }) + + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + fireEvent.change(certInput, { target: { files: [createFile()] } }) + + await userEvent.click(await screen.findByTestId('validate-certificate-btn')) + expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy() + }) + + it('shows toast on validate error', async () => { + validateMutateFn.mockImplementation((_params: unknown, opts: { onError: (e: Error) => void }) => { + opts.onError(new Error('Validation failed')) + }) + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + fireEvent.change(certInput, { target: { files: [createFile()] } }) + + await userEvent.click(await screen.findByTestId('validate-certificate-btn')) + expect(toast.error).toHaveBeenCalledWith('Validation failed') + }) + + it('detects .p12 as PFX format', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['pkcs12'], 'bundle.p12', { type: 'application/x-pkcs12' }) + fireEvent.change(certInput, { target: { files: [file] } }) + expect(await screen.findByText('certificates.pfxDetected')).toBeTruthy() + }) + + it('detects .crt as PEM format', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['cert'], 'my.crt', { type: 'application/x-x509' }) + fireEvent.change(certInput, { target: { files: [file] } }) + // PEM does not hide key/chain zones + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + }) + + it('detects .cer as PEM format', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['cert'], 'my.cer', { type: 'application/x-x509' }) + fireEvent.change(certInput, { target: { files: [file] } }) + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + }) + + it('detects .der format', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['der'], 'cert.der', { type: 'application/x-x509' }) + fireEvent.change(certInput, { target: { files: [file] } }) + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + }) + + it('detects .key format', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['key'], 'private.key', { type: 'application/x-pem-file' }) + fireEvent.change(certInput, { target: { files: [file] } }) + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + }) + + it('handles unknown file extension gracefully', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['data'], 'cert.xyz', { type: 'application/octet-stream' }) + fireEvent.change(certInput, { target: { files: [file] } }) + // Should still show key/chain zones (not PFX) + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + }) + + it('resets validation when cert file changes', async () => { + const mockResult = { + valid: true, + common_name: 'test.com', + domains: ['test.com'], + issuer_org: 'CA', + expires_at: '2026-01-01', + key_match: false, + chain_valid: false, + chain_depth: 0, + warnings: [], + errors: [], + } + validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => { + opts.onSuccess(mockResult) + }) + + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + fireEvent.change(certInput, { target: { files: [createFile()] } }) + await userEvent.click(await screen.findByTestId('validate-certificate-btn')) + expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy() + + // Change cert file — validation result should disappear + const newFile = new File(['new-cert'], 'new.pem', { type: 'application/x-pem-file' }) + fireEvent.change(certInput, { target: { files: [newFile] } }) + await waitFor(() => { + expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy() + }) + }) +}) diff --git a/frontend/src/components/ui/__tests__/FileDropZone.test.tsx b/frontend/src/components/ui/__tests__/FileDropZone.test.tsx new file mode 100644 index 000000000..bbe99ae03 --- /dev/null +++ b/frontend/src/components/ui/__tests__/FileDropZone.test.tsx @@ -0,0 +1,157 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { FileDropZone } from '../FileDropZone' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +const defaultProps = { + id: 'cert-file', + label: 'Certificate File', + file: null as File | null, + onFileChange: vi.fn(), +} + +function createFile(name = 'test.pem', type = 'application/x-pem-file'): File { + return new File(['cert-content'], name, { type }) +} + +describe('FileDropZone', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders label and empty drop zone', () => { + render() + expect(screen.getByText('Certificate File')).toBeTruthy() + expect(screen.getByText('certificates.dropFileHere')).toBeTruthy() + }) + + it('shows required asterisk when required', () => { + render() + expect(screen.getByText('*')).toBeTruthy() + }) + + it('displays file name when a file is provided', () => { + const file = createFile('my-cert.pem') + render() + expect(screen.getByText('my-cert.pem')).toBeTruthy() + }) + + it('displays format badge when file is provided', () => { + const file = createFile('my-cert.pem') + render() + expect(screen.getByText('PEM')).toBeTruthy() + }) + + it('triggers file input on click', async () => { + render() + const dropZone = screen.getByRole('button') + await userEvent.click(dropZone) + // The hidden file input should exist + const input = document.getElementById('cert-file') as HTMLInputElement + expect(input).toBeTruthy() + expect(input.type).toBe('file') + }) + + it('calls onFileChange when a file is selected via input', () => { + render() + const input = document.getElementById('cert-file') as HTMLInputElement + const file = createFile() + fireEvent.change(input, { target: { files: [file] } }) + expect(defaultProps.onFileChange).toHaveBeenCalledWith(file) + }) + + it('calls onFileChange on drop', () => { + render() + const dropZone = screen.getByRole('button') + const file = createFile() + + fireEvent.dragOver(dropZone, { dataTransfer: { files: [file] } }) + fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }) + + expect(defaultProps.onFileChange).toHaveBeenCalledWith(file) + }) + + it('does not call onFileChange on drop when disabled', () => { + render() + const dropZone = screen.getByRole('button') + const file = createFile() + + fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }) + + expect(defaultProps.onFileChange).not.toHaveBeenCalled() + }) + + it('activates via keyboard Enter', async () => { + render() + const dropZone = screen.getByRole('button') + fireEvent.keyDown(dropZone, { key: 'Enter' }) + // Should not throw; input ref click would be called + }) + + it('activates via keyboard Space', async () => { + render() + const dropZone = screen.getByRole('button') + fireEvent.keyDown(dropZone, { key: ' ' }) + }) + + it('does not activate via keyboard when disabled', () => { + render() + const dropZone = screen.getByRole('button') + fireEvent.keyDown(dropZone, { key: 'Enter' }) + // No crash, no file change + expect(defaultProps.onFileChange).not.toHaveBeenCalled() + }) + + it('sets aria-disabled when disabled', () => { + render() + const dropZone = screen.getByRole('button') + expect(dropZone.getAttribute('aria-disabled')).toBe('true') + }) + + it('has tabIndex=-1 when disabled', () => { + render() + const dropZone = screen.getByRole('button') + expect(dropZone.tabIndex).toBe(-1) + }) + + it('has tabIndex=0 when not disabled', () => { + render() + const dropZone = screen.getByRole('button') + expect(dropZone.tabIndex).toBe(0) + }) + + it('has appropriate aria-label when file is selected', () => { + const file = createFile('cert.pem') + render() + const dropZone = screen.getByRole('button') + expect(dropZone.getAttribute('aria-label')).toBe('Certificate File: cert.pem') + }) + + it('handles dragLeave event', () => { + render() + const dropZone = screen.getByRole('button') + fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } }) + fireEvent.dragLeave(dropZone) + // No crash; drag state should reset + }) + + it('sets accept attribute on input', () => { + render() + const input = document.getElementById('cert-file') as HTMLInputElement + expect(input.getAttribute('accept')).toBe('.pem,.crt') + }) + + it('sets aria-required on input when required', () => { + render() + const input = document.getElementById('cert-file') as HTMLInputElement + expect(input.getAttribute('aria-required')).toBe('true') + }) +}) diff --git a/frontend/src/hooks/__tests__/useCertificates.test.tsx b/frontend/src/hooks/__tests__/useCertificates.test.tsx new file mode 100644 index 000000000..94bbe92d3 --- /dev/null +++ b/frontend/src/hooks/__tests__/useCertificates.test.tsx @@ -0,0 +1,238 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import * as api from '../../api/certificates'; +import type { Certificate, CertificateDetail } from '../../api/certificates'; +import { + useCertificates, + useCertificateDetail, + useUploadCertificate, + useUpdateCertificate, + useDeleteCertificate, + useExportCertificate, + useValidateCertificate, + useBulkDeleteCertificates, +} from '../useCertificates'; + +vi.mock('../../api/certificates'); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +const mockCert: Certificate = { + uuid: 'abc-123', + domains: 'example.com', + issuer: "Let's Encrypt", + expires_at: '2025-01-01', + status: 'valid', + provider: 'letsencrypt', + has_key: true, + in_use: false, +}; + +const mockDetail: CertificateDetail = { + ...mockCert, + assigned_hosts: [], + chain: [], + auto_renew: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', +}; + +describe('useCertificates hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('useCertificates', () => { + it('fetches certificate list', async () => { + vi.mocked(api.getCertificates).mockResolvedValue([mockCert]); + + const { result } = renderHook(() => useCertificates(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.certificates).toEqual([mockCert]); + }); + + it('returns empty array when no data', async () => { + vi.mocked(api.getCertificates).mockResolvedValue([]); + + const { result } = renderHook(() => useCertificates(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.certificates).toEqual([]); + }); + }); + + describe('useCertificateDetail', () => { + it('fetches certificate detail by uuid', async () => { + vi.mocked(api.getCertificateDetail).mockResolvedValue(mockDetail); + + const { result } = renderHook(() => useCertificateDetail('abc-123'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.detail).toEqual(mockDetail); + }); + + it('does not fetch when uuid is null', () => { + const { result } = renderHook(() => useCertificateDetail(null), { + wrapper: createWrapper(), + }); + + expect(api.getCertificateDetail).not.toHaveBeenCalled(); + expect(result.current.detail).toBeUndefined(); + }); + }); + + describe('useUploadCertificate', () => { + it('uploads certificate and invalidates cache', async () => { + vi.mocked(api.uploadCertificate).mockResolvedValue(mockCert); + + const { result } = renderHook(() => useUploadCertificate(), { + wrapper: createWrapper(), + }); + + result.current.mutate({ + name: 'My Cert', + certFile: new File(['cert'], 'cert.pem'), + keyFile: new File(['key'], 'key.pem'), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.uploadCertificate).toHaveBeenCalledWith( + 'My Cert', + expect.any(File), + expect.any(File), + undefined, + ); + }); + }); + + describe('useUpdateCertificate', () => { + it('updates certificate name', async () => { + vi.mocked(api.updateCertificate).mockResolvedValue(mockCert); + + const { result } = renderHook(() => useUpdateCertificate(), { + wrapper: createWrapper(), + }); + + result.current.mutate({ uuid: 'abc-123', name: 'Updated' }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.updateCertificate).toHaveBeenCalledWith('abc-123', 'Updated'); + }); + }); + + describe('useDeleteCertificate', () => { + it('deletes certificate and invalidates cache', async () => { + vi.mocked(api.deleteCertificate).mockResolvedValue(undefined); + + const { result } = renderHook(() => useDeleteCertificate(), { + wrapper: createWrapper(), + }); + + result.current.mutate('abc-123'); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.deleteCertificate).toHaveBeenCalledWith('abc-123'); + }); + }); + + describe('useExportCertificate', () => { + it('exports certificate as blob', async () => { + const blob = new Blob(['data']); + vi.mocked(api.exportCertificate).mockResolvedValue(blob); + + const { result } = renderHook(() => useExportCertificate(), { + wrapper: createWrapper(), + }); + + result.current.mutate({ + uuid: 'abc-123', + format: 'pem', + includeKey: true, + password: 'pass', + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.exportCertificate).toHaveBeenCalledWith('abc-123', 'pem', true, 'pass', undefined); + }); + }); + + describe('useValidateCertificate', () => { + it('validates certificate files', async () => { + const validation = { + valid: true, + common_name: 'example.com', + domains: ['example.com'], + issuer_org: 'LE', + expires_at: '2025-01-01', + key_match: true, + chain_valid: true, + chain_depth: 1, + warnings: [], + errors: [], + }; + vi.mocked(api.validateCertificate).mockResolvedValue(validation); + + const { result } = renderHook(() => useValidateCertificate(), { + wrapper: createWrapper(), + }); + + result.current.mutate({ + certFile: new File(['cert'], 'cert.pem'), + keyFile: new File(['key'], 'key.pem'), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.validateCertificate).toHaveBeenCalled(); + }); + }); + + describe('useBulkDeleteCertificates', () => { + it('deletes multiple certificates', async () => { + vi.mocked(api.deleteCertificate).mockResolvedValue(undefined); + + const { result } = renderHook(() => useBulkDeleteCertificates(), { + wrapper: createWrapper(), + }); + + result.current.mutate(['uuid-1', 'uuid-2']); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.deleteCertificate).toHaveBeenCalledTimes(2); + expect(result.current.data).toEqual({ succeeded: 2, failed: 0 }); + }); + + it('reports partial failures', async () => { + vi.mocked(api.deleteCertificate) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('fail')); + + const { result } = renderHook(() => useBulkDeleteCertificates(), { + wrapper: createWrapper(), + }); + + result.current.mutate(['uuid-1', 'uuid-2']); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ succeeded: 1, failed: 1 }); + }); + }); +}); diff --git a/frontend/src/pages/ProxyHosts.tsx b/frontend/src/pages/ProxyHosts.tsx index 59d1873f5..386455f44 100644 --- a/frontend/src/pages/ProxyHosts.tsx +++ b/frontend/src/pages/ProxyHosts.tsx @@ -103,7 +103,7 @@ export default function ProxyHosts() { const certStatusByDomain = useMemo(() => { const map: Record = {} for (const cert of certificates) { - const domains = cert.domains.split(',').map(d => d.trim().toLowerCase()) + const domains = (cert.domains || '').split(',').map(d => d.trim().toLowerCase()).filter(Boolean) for (const domain of domains) { if (!map[domain]) { map[domain] = { status: cert.status, provider: cert.provider } From e865fa2b8b5d6353d88ddf3ff83167b84b3c4947 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 03:55:13 +0000 Subject: [PATCH 17/57] chore: update package.json and package-lock.json to include vitest and coverage dependencies --- package-lock.json | 506 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 5 +- 2 files changed, 508 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c1a3c73e3..08f1a31b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,58 @@ "prettier-plugin-tailwindcss": "^0.7.2", "tar": "^7.5.13", "typescript": "^6.0.2", - "vite": "^8.0.8" + "vite": "^8.0.8", + "vitest": "^4.1.4" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { @@ -682,6 +733,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -693,6 +751,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -703,6 +772,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint-plugin-jsx-a11y": { "version": "6.10.1", "resolved": "https://registry.npmjs.org/@types/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.1.tgz", @@ -786,6 +862,160 @@ "simplify-trace-types": "bin/simplify-trace-types" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", + "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.4", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.4", + "vitest": "4.1.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -861,6 +1091,28 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -902,6 +1154,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1186,6 +1448,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1352,6 +1621,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1370,6 +1649,16 @@ "node": ">= 0.8.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1834,6 +2123,13 @@ "node": ">=8" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2245,6 +2541,28 @@ "dev": true, "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -3019,6 +3337,17 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3121,6 +3450,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3505,6 +3841,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -3550,6 +3893,20 @@ "readable-stream": "^3.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -3643,6 +4000,23 @@ "readable-stream": "3" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3691,6 +4065,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", @@ -3929,6 +4313,109 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -3955,6 +4442,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index a9e6501ce..00896762f 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,9 @@ "smol-toml": "^1.6.1" }, "devDependencies": { - "@types/eslint-plugin-jsx-a11y": "^6.10.1", "@bgotink/playwright-coverage": "^0.3.2", "@playwright/test": "^1.59.1", + "@types/eslint-plugin-jsx-a11y": "^6.10.1", "@types/node": "^25.6.0", "dotenv": "^17.4.2", "markdownlint-cli2": "^0.22.0", @@ -28,6 +28,7 @@ "prettier-plugin-tailwindcss": "^0.7.2", "tar": "^7.5.13", "typescript": "^6.0.2", - "vite": "^8.0.8" + "vite": "^8.0.8", + "vitest": "^4.1.4" } } From 9c056faec7ec750305f597b3d3d9d36888ddb373 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 04:07:49 +0000 Subject: [PATCH 18/57] fix: downgrade versions of css-color, brace-expansion, baseline-browser-mapping, and electron-to-chromium for compatibility --- frontend/package-lock.json | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 257325635..a39e78e97 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -101,9 +101,9 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.10", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz", - "integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz", + "integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==", "dev": true, "license": "MIT", "dependencies": { @@ -868,9 +868,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3609,9 +3609,9 @@ "license": "MIT" }, "node_modules/@types/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4952,9 +4952,9 @@ } }, "node_modules/baseline-browser-mapping": { - "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==", + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5777,9 +5777,9 @@ } }, "node_modules/electron-to-chromium": { - "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==", + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", "dev": true, "license": "ISC" }, @@ -6204,9 +6204,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { From e88a4c798200a294b075308f3845339a2e9c4579 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 04:10:16 +0000 Subject: [PATCH 19/57] chore: update package-lock.json to remove unused dependencies and improve overall package management --- frontend/package-lock.json | 36 +++++------ package-lock.json | 122 ------------------------------------- 2 files changed, 18 insertions(+), 140 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a39e78e97..257325635 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": { @@ -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": { diff --git a/package-lock.json b/package-lock.json index 08f1a31b5..53dbe143f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,56 +24,6 @@ "vitest": "^4.1.4" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -862,47 +812,6 @@ "simplify-trace-types": "bin/simplify-trace-types" } }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", - "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.4", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.4", - "vitest": "4.1.4" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@vitest/expect": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", @@ -1101,18 +1010,6 @@ "node": ">=12" } }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2123,13 +2020,6 @@ "node": ">=8" } }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2551,18 +2441,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", From 78a9231c8a0c8754a97b6b670475e4d37231f436 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 04:24:16 +0000 Subject: [PATCH 20/57] chore: add test_output.txt to .gitignore to exclude test output files from version control --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f9747c9da..171565bdd 100644 --- a/.gitignore +++ b/.gitignore @@ -314,3 +314,4 @@ validation-evidence/** .github/agents/# Tools Configuration.md docs/reports/codecove_patch_report.md vuln-results.json +test_output.txt From 3b4fa064d6e48c2c5141334dbd6a4207a264ce37 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 04:32:26 +0000 Subject: [PATCH 21/57] test: add end-to-end tests for certificate export dialog functionality --- tests/certificate-export.spec.ts | 689 +++++++++++++++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 tests/certificate-export.spec.ts diff --git a/tests/certificate-export.spec.ts b/tests/certificate-export.spec.ts new file mode 100644 index 000000000..f737465a7 --- /dev/null +++ b/tests/certificate-export.spec.ts @@ -0,0 +1,689 @@ +/** + * Certificate Export E2E Tests + * + * Tests the certificate export dialog UX: + * - Export button in certificate list opens export dialog + * - Dialog displays format radio group (PEM, PFX/PKCS#12, DER) + * - Format selection updates the active radio button + * - Include private key checkbox shown when cert has a key + * - Password field appears when include private key is checked + * - PFX password field appears when PFX format is selected + * - Cancel closes dialog without exporting + * - Escape key closes dialog + * - Successful PEM export triggers file download + * - Export with include key requires password re-authentication + * - Dialog accessibility: keyboard navigation, dialog role, labels + * + * @see /projects/Charon/docs/plans/current_spec.md + */ + +import { test, expect, loginUser } from './fixtures/auth-fixtures'; +import { request as playwrightRequest } from '@playwright/test'; +import { + waitForLoadingComplete, + waitForDialog, + waitForAPIResponse, +} from './utils/wait-helpers'; +import { generateUniqueId } from './fixtures/test-data'; +import { STORAGE_STATE } from './constants'; +import { getStorageStateAuthHeaders } from './utils/api-helpers'; + +const CERTIFICATES_API = /\/api\/v1\/certificates/; + +/** + * Real self-signed certificate and key for upload tests. + * Generated via: openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=test.local/O=TestOrg" + */ +const REAL_TEST_CERT = `-----BEGIN CERTIFICATE----- +MIIDLzCCAhegAwIBAgIUehGqwKI4zLvoZSNHlAuv7cJ0G5AwDQYJKoZIhvcNAQEL +BQAwJzETMBEGA1UEAwwKdGVzdC5sb2NhbDEQMA4GA1UECgwHVGVzdE9yZzAeFw0y +NjAzMjIwMzQyMDhaFw0yNzAzMjIwMzQyMDhaMCcxEzARBgNVBAMMCnRlc3QubG9j +YWwxEDAOBgNVBAoMB1Rlc3RPcmcwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQDdzdQfOkHzG/lZ242xTvFYMVOrd12rUGQVcWhc9NG1LIJGYZKpS0bzNUdo +ylHhIqbwNq18Dni1znDYsOAlnfZR+gv84U4klRHGE7liNRixBA5ymZ6KI68sOwqx +bn6wpDZgNLnjD3POwSQoPEx2BAYwIyLPjXFjfnv5nce8Bt99j/zDVwhq24b9YdMR +BVV/sOBsAtNEuRngajA9+i2rmLVrXJSiSFhA/hR0wX6bICpFTtahYX7JqfzlMHFO +4lBka9sbC3xujwtFmLtkBovCzf69fA6p2qhJGVNJ9oHeFY3V2CdYq5Q8SZTsG1Yt +S0O/2A9ZkQmHezeG9DYeg68nLfJDAgMBAAGjUzBRMB0GA1UdDgQWBBRE+2+ss2yl +0vAmlccEC7MBWX6UmDAfBgNVHSMEGDAWgBRE+2+ss2yl0vAmlccEC7MBWX6UmDAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCvwsnSRYQ5PYtuhJ3v +YhKmjkg+NsojYItlo+UkJmq09LkIEwRqJwFLcDxhyHWqRL5Bpc1PA1VJAG6Pif8D +uwwNnXwZZf0P5e7exccSQZnI03OhS0c6/4kfvRSiFiT6BYTYSvQ+OWhpMIIcwhov +86muij2Y32E3F0aqOPjEB+cm/XauXzmFjXi7ig7cktphHcwT8zQn43yCG/BJfWe2 +bRLWqMy+jdr/x2Ij8eWPSlJD3zDxsQiLiO0hFzpQNHfz2Qe17K3dsuhNQ85h2s0w +zCLDm4WygKTw2foUXGNtbWG7z6Eq7PI+2fSlJDFgb+xmdIFQdyKDsZeYO5bmdYq5 +0tY8 +-----END CERTIFICATE-----`; + +const REAL_TEST_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdzdQfOkHzG/lZ +242xTvFYMVOrd12rUGQVcWhc9NG1LIJGYZKpS0bzNUdoylHhIqbwNq18Dni1znDY +sOAlnfZR+gv84U4klRHGE7liNRixBA5ymZ6KI68sOwqxbn6wpDZgNLnjD3POwSQo +PEx2BAYwIyLPjXFjfnv5nce8Bt99j/zDVwhq24b9YdMRBVV/sOBsAtNEuRngajA9 ++i2rmLVrXJSiSFhA/hR0wX6bICpFTtahYX7JqfzlMHFO4lBka9sbC3xujwtFmLtk +BovCzf69fA6p2qhJGVNJ9oHeFY3V2CdYq5Q8SZTsG1YtS0O/2A9ZkQmHezeG9DYe +g68nLfJDAgMBAAECggEAA8uIcZsBkzNLVOpDcQvfZ+7ldkLt61x4xJUoKqRVt4/c +usTjSYTsNdps2lzRLH+h85eRPaonDpVLAP97FlRZk+rUrFhT30mzACdI6LvtLDox +imxudgFI91dwm2Xp7QPM77XMkxdUl+5eEVeBchN84kiiSS2BCdQZiEUsLF9sZi2P +A5+x6XHImE+Sqfm/xVOZzHjj7ObHxc3bUpDT+RvRDvEBGjtEUlCCWuKvLi3DWIBF +T9E38f0hqoxKwc7gsZCZs7phoVm9a3xjQ8Xh3ONLa30aBsJii33KHHxSASc7hMy1 +cM6GaGcg4xgqFw3B677KWUMc3Ur5YdLu71Bw7MFc4QKBgQD9FyRoWcTEktPdvH9y +o7yxRVWcSs5c47h5X9rhcKvUCyEzQ/89Gt1d8e/qMv9JxXmcg3AS8VYeFmzyyMta +iKTrHYnA8iRgM6CHvgSD4+vc7niW1de7qxW3T6MrGA4AEoQOPUvd6ZljBPIqxV8h +jw9BW5YREZV6fXqqVOVT4GMrbQKBgQDgWpvmu1FY65TjoDljOPBtO17krwaWzb/D +jlXQgZgRJVD7kaUPhm7Kb2d7P7t34LgzGH63hF82PlXqtwd5QhB3EZP9mhZTbXxK +vwLf+H44ANDlcZiyDG9OJBT6ND5/JP0jHEt/KsP9pcd9xbZWNEZZFzddbbcp1G/v +ue6p18XWbwKBgQCmdm8y10BNToldQVrOKxWzvve1CZq7i+fMpRhQyQurNvrKPkIF +jcLlxHhZINu6SNFY+TZgry1GMtfLw/fEfzWBkvcE2f7E64/9WCSeHu4GbS8Rfmsb +e0aYQCAA+xxSPdtvhi99MOT7NMiXCyQr7W1KPpPwfBFF9HwWxinjxiVT7QKBgFAb +Ch9QMrN1Kiw8QUFUS0Q1NqSgedHOlPHWGH3iR9GXaVrpne31KgnNzT0MfHtJGXvk ++xm7geN0TmkIAPsiw45AEH80TVRsezyVBwnBSA/m+q9x5/tqxTM5XuQXU1lCc7/d +kndNZb1jO9+EgJ42/AdDatlJG2UsHOuTj8vE5zaxAoGBAPthB+5YZfu3de+vnfpa +o0oFy++FeeHUTxor2605Lit9ZfEvDTe1/iPQw5TNOLjwx0CdsrCxWk5Tyz50aA30 +KfVperc+m+vEVXIPI1qluI0iTPcHd/lMQYCsu6tKWmFP/hAFTIy7rOHMHfPx3RzK +yRNV1UrzJGv5ZUVKq2kymBut +-----END PRIVATE KEY-----`; + +/** + * Create a custom certificate directly via the API. + * Returns the numeric cert ID (from list endpoint), UUID, and name. + */ +async function createCustomCertViaAPI( + baseURL: string, +): Promise<{ id: number; uuid: string; certName: string }> { + const id = generateUniqueId(); + const certName = `export-cert-${id}`; + + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: STORAGE_STATE, + extraHTTPHeaders: getStorageStateAuthHeaders(), + }); + + try { + const response = await ctx.post('/api/v1/certificates', { + multipart: { + name: certName, + certificate_file: { + name: 'cert.pem', + mimeType: 'application/x-pem-file', + buffer: Buffer.from(REAL_TEST_CERT), + }, + key_file: { + name: 'key.pem', + mimeType: 'application/x-pem-file', + buffer: Buffer.from(REAL_TEST_KEY), + }, + }, + }); + + if (!response.ok()) { + throw new Error( + `Failed to create certificate: ${response.status()} ${await response.text()}`, + ); + } + + const createResult = await response.json(); + const certUUID: string = createResult.uuid; + + const listResponse = await ctx.get('/api/v1/certificates'); + if (!listResponse.ok()) { + throw new Error(`Failed to list certificates: ${listResponse.status()}`); + } + const certs: Array<{ id: number; uuid: string }> = await listResponse.json(); + const match = certs.find((c) => c.uuid === certUUID); + if (!match) { + throw new Error(`Certificate with UUID ${certUUID} not found in list after creation`); + } + + return { id: match.id, uuid: certUUID, certName }; + } finally { + await ctx.dispose(); + } +} + +/** + * Delete a certificate directly via the API for cleanup. + */ +async function deleteCertViaAPI(baseURL: string, certId: number): Promise { + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: STORAGE_STATE, + extraHTTPHeaders: getStorageStateAuthHeaders(), + }); + + try { + await ctx.delete(`/api/v1/certificates/${certId}`); + } finally { + await ctx.dispose(); + } +} + +/** + * Navigate to the certificates page and wait for data to load. + */ +async function navigateToCertificates( + page: import('@playwright/test').Page, +): Promise { + const certsResponse = waitForAPIResponse(page, CERTIFICATES_API); + await page.goto('/certificates'); + await certsResponse; + await waitForLoadingComplete(page); +} + +test.describe('Certificate Export', () => { + const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080'; + const createdCertIds: number[] = []; + + test.beforeEach(async ({ page, adminUser }) => { + await loginUser(page, adminUser); + await waitForLoadingComplete(page); + }); + + test.afterAll(async () => { + for (const certId of createdCertIds) { + await deleteCertViaAPI(baseURL, certId).catch(() => {}); + } + }); + + // --------------------------------------------------------------------------- + // Scenario 1: Export button opens the export dialog + // --------------------------------------------------------------------------- + test('should open export dialog when export button is clicked', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate to certificates page', async () => { + await navigateToCertificates(page); + }); + + await test.step('Click the export button for the seeded cert', async () => { + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + + const exportButton = certRow.getByRole('button', { name: /export/i }); + await exportButton.click(); + }); + + await test.step('Verify export dialog opens with correct title', async () => { + const dialog = await waitForDialog(page); + await expect(dialog).toBeVisible(); + + await expect(dialog.getByText(/Export Certificate/i)).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 2: Dialog shows format radio group with PEM selected by default + // --------------------------------------------------------------------------- + test('should show format radio group with PEM selected by default', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + const exportButton = certRow.getByRole('button', { name: /export/i }); + await exportButton.click(); + await waitForDialog(page); + }); + + await test.step('Verify format radio group is present', async () => { + const dialog = page.getByRole('dialog'); + const radioGroup = dialog.getByRole('radiogroup'); + await expect(radioGroup).toBeVisible(); + }); + + await test.step('Verify PEM is selected by default', async () => { + const dialog = page.getByRole('dialog'); + const pemRadio = dialog.getByRole('radio', { name: /PEM/i }); + await expect(pemRadio).toHaveAttribute('aria-checked', 'true'); + }); + + await test.step('Verify all three format options are present', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('radio', { name: /PEM/i })).toBeVisible(); + await expect(dialog.getByRole('radio', { name: /PFX/i })).toBeVisible(); + await expect(dialog.getByRole('radio', { name: /DER/i })).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 3: Selecting a different format updates the radio state + // --------------------------------------------------------------------------- + test('should update radio state when different format is selected', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Select DER format and verify state', async () => { + const dialog = page.getByRole('dialog'); + const derRadio = dialog.getByRole('radio', { name: /DER/i }); + await derRadio.click(); + + await expect(derRadio).toHaveAttribute('aria-checked', 'true'); + + const pemRadio = dialog.getByRole('radio', { name: /PEM/i }); + await expect(pemRadio).toHaveAttribute('aria-checked', 'false'); + }); + + await test.step('Select PFX format and verify state', async () => { + const dialog = page.getByRole('dialog'); + const pfxRadio = dialog.getByRole('radio', { name: /PFX/i }); + await pfxRadio.click(); + + await expect(pfxRadio).toHaveAttribute('aria-checked', 'true'); + + const derRadio = dialog.getByRole('radio', { name: /DER/i }); + await expect(derRadio).toHaveAttribute('aria-checked', 'false'); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 4: Include private key checkbox is visible for cert with key + // --------------------------------------------------------------------------- + test('should show include private key checkbox for cert with key', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate with key via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Verify include private key checkbox is present', async () => { + const dialog = page.getByRole('dialog'); + const includeKeyCheckbox = dialog.locator('#include-key'); + await expect(includeKeyCheckbox).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 5: Checking include key shows password field + // --------------------------------------------------------------------------- + test('should show password field when include private key is checked', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate with key via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Verify password field is hidden initially', async () => { + const dialog = page.getByRole('dialog'); + const passwordInput = dialog.locator('#export-password'); + await expect(passwordInput).toHaveCount(0); + }); + + await test.step('Check include private key checkbox', async () => { + const dialog = page.getByRole('dialog'); + const includeKeyCheckbox = dialog.locator('#include-key'); + await includeKeyCheckbox.check(); + }); + + await test.step('Verify password field and warning appear', async () => { + const dialog = page.getByRole('dialog'); + + const passwordInput = dialog.locator('#export-password'); + await expect(passwordInput).toBeVisible(); + + const warning = dialog.getByText(/re-authentication/i); + await expect(warning).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 6: Selecting PFX format shows PFX password field + // --------------------------------------------------------------------------- + test('should show PFX password field when PFX format is selected', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Verify PFX password is hidden with PEM selected', async () => { + const dialog = page.getByRole('dialog'); + const pfxPasswordInput = dialog.locator('#pfx-password'); + await expect(pfxPasswordInput).toHaveCount(0); + }); + + await test.step('Select PFX format and verify PFX password appears', async () => { + const dialog = page.getByRole('dialog'); + const pfxRadio = dialog.getByRole('radio', { name: /PFX/i }); + await pfxRadio.click(); + + const pfxPasswordInput = dialog.locator('#pfx-password'); + await expect(pfxPasswordInput).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 7: Cancel closes dialog without exporting + // --------------------------------------------------------------------------- + test('should close dialog without exporting when Cancel is clicked', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Click Cancel and verify dialog closes', async () => { + const dialog = page.getByRole('dialog'); + const cancelButton = dialog.getByRole('button', { name: /cancel/i }); + await cancelButton.click(); + + await expect(dialog).not.toBeVisible({ timeout: 3000 }); + }); + + await test.step('Verify certificate row is still present', async () => { + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 8: Escape key closes dialog + // --------------------------------------------------------------------------- + test('should close dialog when Escape key is pressed', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Press Escape and verify dialog closes', async () => { + const dialog = page.getByRole('dialog'); + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible({ timeout: 3000 }); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 9: Successful PEM export triggers file download + // --------------------------------------------------------------------------- + test('should download PEM file on successful export', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Submit export with PEM format and verify download', async () => { + const dialog = page.getByRole('dialog'); + + // PEM is default — click export + const downloadPromise = page.waitForEvent('download', { timeout: 15000 }); + const exportButton = dialog.locator('[data-testid="export-certificate-submit"]'); + await exportButton.click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/\.pem$/); + }); + + await test.step('Verify dialog closed after successful export', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 10: Export with include key but no password is rejected + // --------------------------------------------------------------------------- + test('should require password when exporting with private key', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate with key via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Check include key and try to submit without password', async () => { + const dialog = page.getByRole('dialog'); + const includeKeyCheckbox = dialog.locator('#include-key'); + await includeKeyCheckbox.check(); + + // Password field should be required + const passwordInput = dialog.locator('#export-password'); + await expect(passwordInput).toBeVisible(); + await expect(passwordInput).toHaveAttribute('required', ''); + }); + + await test.step('Submit without password — dialog should remain open', async () => { + const dialog = page.getByRole('dialog'); + const exportButton = dialog.locator('[data-testid="export-certificate-submit"]'); + await exportButton.click(); + + // Dialog should still be visible (HTML5 validation prevents submission) + await expect(dialog).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 11: Dialog resets form state when reopened + // --------------------------------------------------------------------------- + test('should reset form state when dialog is reopened', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Change format to DER and check include key', async () => { + const dialog = page.getByRole('dialog'); + const derRadio = dialog.getByRole('radio', { name: /DER/i }); + await derRadio.click(); + + const includeKeyCheckbox = dialog.locator('#include-key'); + await includeKeyCheckbox.check(); + }); + + await test.step('Close the dialog', async () => { + const dialog = page.getByRole('dialog'); + const cancelButton = dialog.getByRole('button', { name: /cancel/i }); + await cancelButton.click(); + await expect(dialog).not.toBeVisible({ timeout: 3000 }); + }); + + await test.step('Reopen dialog and verify form is reset', async () => { + const certRow = page.getByRole('row').filter({ hasText: certName }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + + const dialog = page.getByRole('dialog'); + + // PEM should be selected again (default) + const pemRadio = dialog.getByRole('radio', { name: /PEM/i }); + await expect(pemRadio).toHaveAttribute('aria-checked', 'true'); + + // Include key checkbox should be unchecked + const includeKeyCheckbox = dialog.locator('#include-key'); + await expect(includeKeyCheckbox).not.toBeChecked(); + + // Password field should not be visible + const passwordInput = dialog.locator('#export-password'); + await expect(passwordInput).toHaveCount(0); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 12: Dialog accessibility — proper ARIA roles and labels + // --------------------------------------------------------------------------- + test('should have proper ARIA roles and keyboard accessibility', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Verify dialog has proper ARIA role', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + }); + + await test.step('Verify dialog has a heading', async () => { + const dialog = page.getByRole('dialog'); + const heading = dialog.getByRole('heading'); + await expect(heading).toBeVisible(); + }); + + await test.step('Verify radiogroup has an accessible label', async () => { + const dialog = page.getByRole('dialog'); + const radioGroup = dialog.getByRole('radiogroup'); + await expect(radioGroup).toHaveAttribute('aria-label'); + }); + + await test.step('Verify keyboard navigation through format options', async () => { + // Tab into the dialog — focus should land on an interactive element + await page.keyboard.press('Tab'); + const focusedElement = page.locator(':focus'); + await expect(focusedElement).toBeVisible(); + }); + + await test.step('Verify export button label', async () => { + const dialog = page.getByRole('dialog'); + const exportButton = dialog.locator('[data-testid="export-certificate-submit"]'); + await expect(exportButton).toBeVisible(); + await expect(exportButton).toContainText(/export/i); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 13: Every cert row has an export button + // --------------------------------------------------------------------------- + test('should show export button for all certificates in the list', async ({ page }) => { + await test.step('Navigate to certificates page', async () => { + await navigateToCertificates(page); + }); + + await test.step('Verify each data row has an export button', async () => { + const rows = page.locator('tbody tr'); + const rowCount = await rows.count(); + + if (rowCount === 0) { + // Only empty state — nothing to verify + return; + } + + for (let i = 0; i < rowCount; i++) { + const row = rows.nth(i); + const cellCount = await row.locator('td').count(); + + // Skip empty-state row (has colspan) + if (cellCount < 4) continue; + + const exportBtn = row.getByRole('button', { name: /export/i }); + await expect(exportBtn).toBeVisible(); + } + }); + }); +}); From 850550c5daae61968fbbd8a36d3f2e7a8d43eca5 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 04:38:26 +0000 Subject: [PATCH 22/57] test: update common name display test to match exact text --- .../dialogs/__tests__/CertificateDetailDialog.test.tsx | 8 +++++++- .../dialogs/__tests__/CertificateExportDialog.test.tsx | 8 ++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/dialogs/__tests__/CertificateDetailDialog.test.tsx b/frontend/src/components/dialogs/__tests__/CertificateDetailDialog.test.tsx index c2ced1569..f1570a386 100644 --- a/frontend/src/components/dialogs/__tests__/CertificateDetailDialog.test.tsx +++ b/frontend/src/components/dialogs/__tests__/CertificateDetailDialog.test.tsx @@ -104,7 +104,7 @@ describe('CertificateDetailDialog', () => { it('displays common name', () => { renderDialog() - const matches = screen.getAllByText(/app\.example\.com/) + const matches = screen.getAllByText(/^app\.example\.com$/) expect(matches.length).toBeGreaterThanOrEqual(1) }) @@ -161,6 +161,7 @@ describe('CertificateDetailDialog', () => { vi.mocked(useCertificateDetail).mockReturnValue({ detail: undefined as unknown as CertificateDetail, isLoading: true, + error: null, }) renderDialog() expect(screen.getByTestId('certificate-detail-dialog')).toBeTruthy() @@ -189,6 +190,7 @@ describe('CertificateDetailDialog', () => { vi.mocked(useCertificateDetail).mockReturnValue({ detail: sparseDetail, isLoading: false, + error: null, }) renderDialog() const dashes = screen.getAllByText('-') @@ -204,6 +206,7 @@ describe('CertificateDetailDialog', () => { vi.mocked(useCertificateDetail).mockReturnValue({ detail: noHostDetail, isLoading: false, + error: null, }) renderDialog() expect(screen.getByText('certificates.noAssignedHosts')).toBeTruthy() @@ -217,6 +220,7 @@ describe('CertificateDetailDialog', () => { vi.mocked(useCertificateDetail).mockReturnValue({ detail: autoRenewDetail, isLoading: false, + error: null, }) renderDialog() expect(screen.getByText('common.yes')).toBeTruthy() @@ -231,6 +235,7 @@ describe('CertificateDetailDialog', () => { vi.mocked(useCertificateDetail).mockReturnValue({ detail: noOrgDetail, isLoading: false, + error: null, }) renderDialog() expect(screen.getByText('Fallback Issuer')).toBeTruthy() @@ -240,6 +245,7 @@ describe('CertificateDetailDialog', () => { vi.mocked(useCertificateDetail).mockReturnValue({ detail: undefined as unknown as CertificateDetail, isLoading: false, + error: null, }) renderDialog(null) expect(screen.queryByText('My Cert')).toBeFalsy() diff --git a/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx b/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx index dc6b140ae..76a0107fb 100644 --- a/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx +++ b/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx @@ -175,8 +175,8 @@ describe('CertificateExportDialog', () => { const fakeBlob = new Blob(['cert-data'], { type: 'application/x-pem-file' }) const revokeURL = vi.fn() const createURL = vi.fn(() => 'blob:http://localhost/fake') - global.URL.createObjectURL = createURL - global.URL.revokeObjectURL = revokeURL + globalThis.URL.createObjectURL = createURL + globalThis.URL.revokeObjectURL = revokeURL const appendSpy = vi.spyOn(document.body, 'appendChild') const removeSpy = vi.fn() @@ -251,8 +251,8 @@ describe('CertificateExportDialog', () => { it('uses certificate name in download filename on success', async () => { const fakeBlob = new Blob(['data']) - global.URL.createObjectURL = vi.fn(() => 'blob:fake') - global.URL.revokeObjectURL = vi.fn() + globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake') + globalThis.URL.revokeObjectURL = vi.fn() let capturedAnchor: HTMLAnchorElement | null = null exportMutateFn.mockImplementation( From 122e1fc20bf3c999434bd6975c7d3cb68afa66b9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 04:38:53 +0000 Subject: [PATCH 23/57] chore(deps): update renovatebot/github-action action to v46.1.9 --- .github/workflows/renovate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 1797f7514..e8b2e9d26 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -33,7 +33,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Run Renovate - uses: renovatebot/github-action@b67590ea780158ccd13192c22a3655a5231f869d # v46.1.8 + uses: renovatebot/github-action@eb932558ad942cccfd8211cf535f17ff183a9f74 # v46.1.9 with: configurationFile: .github/renovate.json token: ${{ secrets.RENOVATE_TOKEN || secrets.GITHUB_TOKEN }} From 48f6b7a12bb3e54f08f5eb37e2bb1ea0f6f44967 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 04:40:02 +0000 Subject: [PATCH 24/57] fix: update Dockerfile to include musl and musl-utils in apk upgrade for improved compatibility --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3c4f31c5f..238e9ca81 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 libcrypto3 libssl3 + && apk upgrade --no-cache zlib libcrypto3 libssl3 musl musl-utils # 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 0ae1dc998a7d4739d75edc5a592a58553394dbe7 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 12:04:47 +0000 Subject: [PATCH 25/57] test: update certificate deletion tests to use string UUIDs instead of integers --- .../certificate_handler_coverage_test.go | 398 +++++++++++++ .../certificate_service_coverage_test.go | 524 ++++++++++++++++++ .../ProxyHosts-cert-cleanup.test.tsx | 6 +- .../ProxyHosts-coverage-isolated.test.tsx | 2 +- 4 files changed, 926 insertions(+), 4 deletions(-) create mode 100644 backend/internal/services/certificate_service_coverage_test.go diff --git a/backend/internal/api/handlers/certificate_handler_coverage_test.go b/backend/internal/api/handlers/certificate_handler_coverage_test.go index 30131600a..7baea67da 100644 --- a/backend/internal/api/handlers/certificate_handler_coverage_test.go +++ b/backend/internal/api/handlers/certificate_handler_coverage_test.go @@ -1,12 +1,18 @@ package handlers import ( + "bytes" + "encoding/json" + "mime/multipart" "net/http" "net/http/httptest" + "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" @@ -179,3 +185,395 @@ func TestCertificateHandler_DBSetupOrdering(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } + +// --- Get handler tests --- + +func TestCertificateHandler_Get_Success(t *testing.T) { + db := OpenTestDBWithMigrations(t) + expiry := time.Now().Add(30 * 24 * time.Hour) + db.Create(&models.SSLCertificate{UUID: "get-uuid-1", Name: "Get Test", Provider: "custom", Domains: "get.example.com", ExpiresAt: &expiry}) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.GET("/api/certificates/:uuid", h.Get) + + req := httptest.NewRequest(http.MethodGet, "/api/certificates/get-uuid-1", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "get-uuid-1") + assert.Contains(t, w.Body.String(), "Get Test") +} + +func TestCertificateHandler_Get_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.GET("/api/certificates/:uuid", h.Get) + + req := httptest.NewRequest(http.MethodGet, "/api/certificates/nonexistent-uuid", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Get_EmptyUUID(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + // Route with empty uuid param won't match, test the handler directly with blank uuid + r.GET("/api/certificates/", h.Get) + + req := httptest.NewRequest(http.MethodGet, "/api/certificates/", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Empty uuid should return 400 or 404 depending on router handling + assert.True(t, w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound) +} + +// --- SetDB test --- + +func TestCertificateHandler_SetDB(t *testing.T) { + db := OpenTestDBWithMigrations(t) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + assert.Nil(t, h.db) + + h.SetDB(db) + assert.NotNil(t, h.db) +} + +// --- Update handler tests --- + +func TestCertificateHandler_Update_Success(t *testing.T) { + db := OpenTestDBWithMigrations(t) + expiry := time.Now().Add(30 * 24 * time.Hour) + db.Create(&models.SSLCertificate{UUID: "upd-uuid-1", Name: "Old Name", Provider: "custom", Domains: "update.example.com", ExpiresAt: &expiry}) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + body, _ := json.Marshal(map[string]string{"name": "New Name"}) + req := httptest.NewRequest(http.MethodPut, "/api/certificates/upd-uuid-1", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "New Name") +} + +func TestCertificateHandler_Update_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + body, _ := json.Marshal(map[string]string{"name": "New Name"}) + req := httptest.NewRequest(http.MethodPut, "/api/certificates/nonexistent-uuid", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Update_BadJSON(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + req := httptest.NewRequest(http.MethodPut, "/api/certificates/some-uuid", strings.NewReader("{invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCertificateHandler_Update_MissingName(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + body, _ := json.Marshal(map[string]string{}) + req := httptest.NewRequest(http.MethodPut, "/api/certificates/some-uuid", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// --- Validate handler tests --- + +func TestCertificateHandler_Validate_Success(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/validate", h.Validate) + + certPEM, keyPEM, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, _ := writer.CreateFormFile("certificate_file", "cert.pem") + _, _ = part.Write([]byte(certPEM)) + part2, _ := writer.CreateFormFile("key_file", "key.pem") + _, _ = part2.Write([]byte(keyPEM)) + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "valid") +} + +func TestCertificateHandler_Validate_NoCertFile(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/validate", h.Validate) + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", strings.NewReader("")) + req.Header.Set("Content-Type", "multipart/form-data") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCertificateHandler_Validate_CertOnly(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/validate", h.Validate) + + certPEM, _, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, _ := writer.CreateFormFile("certificate_file", "cert.pem") + _, _ = part.Write([]byte(certPEM)) + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +// --- Export handler tests --- + +func TestCertificateHandler_Export_EmptyUUID(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem"}) + // Use a route that provides :uuid param as empty would not match normal routing + req := httptest.NewRequest(http.MethodPost, "/api/certificates//export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Router won't match empty uuid, so 404 or redirect + assert.True(t, w.Code == http.StatusNotFound || w.Code == http.StatusMovedPermanently || w.Code == http.StatusBadRequest) +} + +func TestCertificateHandler_Export_BadJSON(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/some-uuid/export", strings.NewReader("{bad")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCertificateHandler_Export_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem"}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/nonexistent-uuid/export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Export_PEMSuccess(t *testing.T) { + db := OpenTestDBWithMigrations(t) + certPEM, _, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + cert := models.SSLCertificate{UUID: "export-uuid-1", Name: "Export Test", Provider: "custom", Domains: "export.example.com", Certificate: certPEM} + db.Create(&cert) + + r := gin.New() + r.Use(mockAuthMiddleware()) + tmpDir := t.TempDir() + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem"}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/export-uuid-1/export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Disposition"), "Export Test.pem") +} + +func TestCertificateHandler_Export_IncludeKeyNoPassword(t *testing.T) { + db := OpenTestDBWithMigrations(t) + cert := models.SSLCertificate{UUID: "export-uuid-2", Name: "Key Test", Provider: "custom", Domains: "key.example.com"} + db.Create(&cert) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem", "include_key": true}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/export-uuid-2/export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "password required") +} + +func TestCertificateHandler_Export_IncludeKeyNoDBSet(t *testing.T) { + db := OpenTestDBWithMigrations(t) + cert := models.SSLCertificate{UUID: "export-uuid-3", Name: "No DB Test", Provider: "custom", Domains: "nodb.example.com"} + db.Create(&cert) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + // h.db is nil - not set via SetDB + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem", "include_key": true, "password": "test123"}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/export-uuid-3/export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "authentication required") +} + +// --- Delete via UUID path tests --- + +func TestCertificateHandler_Delete_UUIDPath_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.DELETE("/api/certificates/:uuid", h.Delete) + + // Valid UUID format but does not exist + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/00000000-0000-0000-0000-000000000001", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Delete_UUIDPath_InUse(t *testing.T) { + db := OpenTestDBWithMigrations(t) + cert := models.SSLCertificate{UUID: "11111111-1111-1111-1111-111111111111", Name: "InUse UUID", Provider: "custom", Domains: "uuid-inuse.example.com"} + db.Create(&cert) + + ph := models.ProxyHost{UUID: "ph-uuid-del", Name: "Proxy", DomainNames: "uuid-inuse.example.com", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID} + db.Create(&ph) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/11111111-1111-1111-1111-111111111111", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +// --- sanitizeCertRef tests --- + +func TestSanitizeCertRef(t *testing.T) { + assert.Equal(t, "00000000-0000-0000-0000-000000000001", sanitizeCertRef("00000000-0000-0000-0000-000000000001")) + assert.Equal(t, "123", sanitizeCertRef("123")) + assert.Equal(t, "[invalid-ref]", sanitizeCertRef("not-valid")) + assert.Equal(t, "0", sanitizeCertRef("0")) +} diff --git a/backend/internal/services/certificate_service_coverage_test.go b/backend/internal/services/certificate_service_coverage_test.go new file mode 100644 index 000000000..22c02937d --- /dev/null +++ b/backend/internal/services/certificate_service_coverage_test.go @@ -0,0 +1,524 @@ +package services + +import ( + "context" + "encoding/base64" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/crypto" + "github.com/Wikid82/charon/backend/internal/models" +) + +// newTestEncryptionService creates a real EncryptionService for tests. +func newTestEncryptionService(t *testing.T) *crypto.EncryptionService { + t.Helper() + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + keyB64 := base64.StdEncoding.EncodeToString(key) + svc, err := crypto.NewEncryptionService(keyB64) + require.NoError(t, err) + return svc +} + +func newTestCertServiceWithEnc(t *testing.T, dataDir string, db *gorm.DB) *CertificateService { + t.Helper() + encSvc := newTestEncryptionService(t) + return &CertificateService{ + dataDir: dataDir, + db: db, + encSvc: encSvc, + scanTTL: 5 * time.Minute, + } +} + +func seedCertWithKey(t *testing.T, db *gorm.DB, encSvc *crypto.EncryptionService, uuid, name, domain string, expiry time.Time) models.SSLCertificate { + t.Helper() + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) + + encKey, err := encSvc.Encrypt(keyPEM) + require.NoError(t, err) + + cert := models.SSLCertificate{ + UUID: uuid, + Name: name, + Provider: "custom", + Domains: domain, + CommonName: domain, + Certificate: string(certPEM), + PrivateKeyEncrypted: encKey, + ExpiresAt: &expiry, + } + require.NoError(t, db.Create(&cert).Error) + return cert +} + +func TestCertificateService_GetCertificate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("not found", func(t *testing.T) { + _, err := cs.GetCertificate("nonexistent-uuid") + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("found with no hosts", func(t *testing.T) { + expiry := time.Now().Add(30 * 24 * time.Hour) + notBefore := time.Now().Add(-time.Hour) + cert := models.SSLCertificate{ + UUID: "get-cert-1", + Name: "Test Cert", + Provider: "custom", + Domains: "get.example.com", + CommonName: "get.example.com", + ExpiresAt: &expiry, + NotBefore: ¬Before, + } + require.NoError(t, db.Create(&cert).Error) + + detail, err := cs.GetCertificate("get-cert-1") + require.NoError(t, err) + assert.Equal(t, "get-cert-1", detail.UUID) + assert.Equal(t, "Test Cert", detail.Name) + assert.Equal(t, "get.example.com", detail.CommonName) + assert.False(t, detail.InUse) + assert.Empty(t, detail.AssignedHosts) + }) + + t.Run("found with assigned host", func(t *testing.T) { + expiry := time.Now().Add(30 * 24 * time.Hour) + cert := models.SSLCertificate{ + UUID: "get-cert-2", + Name: "Assigned Cert", + Provider: "custom", + Domains: "assigned.example.com", + CommonName: "assigned.example.com", + ExpiresAt: &expiry, + } + require.NoError(t, db.Create(&cert).Error) + + ph := models.ProxyHost{ + UUID: "ph-assigned", + Name: "My Proxy", + DomainNames: "assigned.example.com", + ForwardHost: "localhost", + ForwardPort: 8080, + CertificateID: &cert.ID, + } + require.NoError(t, db.Create(&ph).Error) + + detail, err := cs.GetCertificate("get-cert-2") + require.NoError(t, err) + assert.True(t, detail.InUse) + require.Len(t, detail.AssignedHosts, 1) + assert.Equal(t, "My Proxy", detail.AssignedHosts[0].Name) + }) + + t.Run("nil expiry and not_before", func(t *testing.T) { + cert := models.SSLCertificate{ + UUID: "get-cert-3", + Name: "No Dates", + Provider: "custom", + Domains: "nodates.example.com", + } + require.NoError(t, db.Create(&cert).Error) + + detail, err := cs.GetCertificate("get-cert-3") + require.NoError(t, err) + assert.True(t, detail.ExpiresAt.IsZero()) + assert.True(t, detail.NotBefore.IsZero()) + }) +} + +func TestCertificateService_ValidateCertificate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("valid cert with key", func(t *testing.T) { + certPEM, keyPEM := generateTestCertAndKey(t, "validate.example.com", time.Now().Add(24*time.Hour)) + result, err := cs.ValidateCertificate(string(certPEM), string(keyPEM), "") + require.NoError(t, err) + assert.True(t, result.Valid) + assert.True(t, result.KeyMatch) + assert.Empty(t, result.Errors) + }) + + t.Run("invalid cert data", func(t *testing.T) { + result, err := cs.ValidateCertificate("not-a-cert", "", "") + require.NoError(t, err) + assert.False(t, result.Valid) + assert.NotEmpty(t, result.Errors) + }) + + t.Run("valid cert without key", func(t *testing.T) { + certPEM := generateTestCert(t, "nokey.example.com", time.Now().Add(24*time.Hour)) + result, err := cs.ValidateCertificate(string(certPEM), "", "") + require.NoError(t, err) + assert.True(t, result.Valid) + assert.False(t, result.KeyMatch) + assert.Empty(t, result.Errors) + }) + + t.Run("expired cert", func(t *testing.T) { + certPEM := generateTestCert(t, "expired.example.com", time.Now().Add(-24*time.Hour)) + result, err := cs.ValidateCertificate(string(certPEM), "", "") + require.NoError(t, err) + assert.NotEmpty(t, result.Warnings) + }) +} + +func TestCertificateService_UpdateCertificate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("not found", func(t *testing.T) { + _, err := cs.UpdateCertificate("nonexistent-uuid", "New Name") + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("successful rename", func(t *testing.T) { + expiry := time.Now().Add(30 * 24 * time.Hour) + cert := models.SSLCertificate{ + UUID: "update-cert-1", + Name: "Old Name", + Provider: "custom", + Domains: "update.example.com", + CommonName: "update.example.com", + ExpiresAt: &expiry, + } + require.NoError(t, db.Create(&cert).Error) + + info, err := cs.UpdateCertificate("update-cert-1", "New Name") + require.NoError(t, err) + assert.Equal(t, "New Name", info.Name) + assert.Equal(t, "update-cert-1", info.UUID) + assert.Equal(t, "custom", info.Provider) + }) + + t.Run("updates persist", func(t *testing.T) { + var cert models.SSLCertificate + require.NoError(t, db.Where("uuid = ?", "update-cert-1").First(&cert).Error) + assert.Equal(t, "New Name", cert.Name) + }) + + t.Run("nil expiry and not_before", func(t *testing.T) { + cert := models.SSLCertificate{ + UUID: "update-cert-2", + Name: "No Dates Cert", + Provider: "custom", + Domains: "nodates-update.example.com", + } + require.NoError(t, db.Create(&cert).Error) + + info, err := cs.UpdateCertificate("update-cert-2", "Renamed No Dates") + require.NoError(t, err) + assert.Equal(t, "Renamed No Dates", info.Name) + assert.True(t, info.ExpiresAt.IsZero()) + }) +} + +func TestCertificateService_IsCertificateInUseByUUID(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("not found", func(t *testing.T) { + _, err := cs.IsCertificateInUseByUUID("nonexistent-uuid") + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("not in use", func(t *testing.T) { + cert := models.SSLCertificate{UUID: "inuse-1", Name: "Free Cert", Provider: "custom", Domains: "free.example.com"} + require.NoError(t, db.Create(&cert).Error) + + inUse, err := cs.IsCertificateInUseByUUID("inuse-1") + require.NoError(t, err) + assert.False(t, inUse) + }) + + t.Run("in use", func(t *testing.T) { + cert := models.SSLCertificate{UUID: "inuse-2", Name: "Used Cert", Provider: "custom", Domains: "used.example.com"} + require.NoError(t, db.Create(&cert).Error) + + ph := models.ProxyHost{UUID: "ph-inuse", Name: "Using Proxy", DomainNames: "used.example.com", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID} + require.NoError(t, db.Create(&ph).Error) + + inUse, err := cs.IsCertificateInUseByUUID("inuse-2") + require.NoError(t, err) + assert.True(t, inUse) + }) +} + +func TestCertificateService_DeleteCertificateByID(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + cert := models.SSLCertificate{UUID: "del-by-id-1", Name: "Delete By ID", Provider: "custom", Domains: "delbyid.example.com"} + require.NoError(t, db.Create(&cert).Error) + + err = cs.DeleteCertificateByID(cert.ID) + require.NoError(t, err) + + var found models.SSLCertificate + err = db.Where("uuid = ?", "del-by-id-1").First(&found).Error + assert.Error(t, err) +} + +func TestCertificateService_ExportCertificate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + encSvc := newTestEncryptionService(t) + cs := newTestCertServiceWithEnc(t, tmpDir, db) + + domain := "export.example.com" + expiry := time.Now().Add(30 * 24 * time.Hour) + cert := seedCertWithKey(t, db, encSvc, "export-cert-1", "Export Cert", domain, expiry) + + t.Run("not found", func(t *testing.T) { + _, _, err := cs.ExportCertificate("nonexistent", "pem", false) + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("pem without key", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "pem", false) + require.NoError(t, err) + assert.Equal(t, "Export Cert.pem", filename) + assert.Contains(t, string(data), "BEGIN CERTIFICATE") + }) + + t.Run("pem with key", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "pem", true) + require.NoError(t, err) + assert.Equal(t, "Export Cert.pem", filename) + assert.Contains(t, string(data), "BEGIN CERTIFICATE") + assert.Contains(t, string(data), "PRIVATE KEY") + }) + + t.Run("der format", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "der", false) + require.NoError(t, err) + assert.Equal(t, "Export Cert.der", filename) + assert.NotEmpty(t, data) + }) + + t.Run("pfx format", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "pfx", false) + require.NoError(t, err) + assert.Equal(t, "Export Cert.pfx", filename) + assert.NotEmpty(t, data) + }) + + t.Run("unsupported format", func(t *testing.T) { + _, _, err := cs.ExportCertificate(cert.UUID, "jks", false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported export format") + }) + + t.Run("empty name uses fallback", func(t *testing.T) { + noNameCert := seedCertWithKey(t, db, encSvc, "export-noname", "", domain, expiry) + _, filename, err := cs.ExportCertificate(noNameCert.UUID, "pem", false) + require.NoError(t, err) + assert.Equal(t, "certificate.pem", filename) + }) +} + +func TestCertificateService_GetDecryptedPrivateKey(t *testing.T) { + encSvc := newTestEncryptionService(t) + + t.Run("no encrypted key", func(t *testing.T) { + cs := &CertificateService{encSvc: encSvc} + cert := &models.SSLCertificate{PrivateKeyEncrypted: ""} + _, err := cs.GetDecryptedPrivateKey(cert) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no encrypted private key") + }) + + t.Run("no encryption service", func(t *testing.T) { + cs := &CertificateService{encSvc: nil} + cert := &models.SSLCertificate{PrivateKeyEncrypted: "some-data"} + _, err := cs.GetDecryptedPrivateKey(cert) + assert.Error(t, err) + assert.Contains(t, err.Error(), "encryption service not configured") + }) + + t.Run("successful decryption", func(t *testing.T) { + cs := &CertificateService{encSvc: encSvc} + plaintext := "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----" //nolint:gosec // test data, not real credentials + encrypted, err := encSvc.Encrypt([]byte(plaintext)) + require.NoError(t, err) + + cert := &models.SSLCertificate{PrivateKeyEncrypted: encrypted} + result, err := cs.GetDecryptedPrivateKey(cert) + require.NoError(t, err) + assert.Equal(t, plaintext, result) + }) +} + +func TestCertificateService_CheckExpiringCertificates(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + // Create certs with different expiry states + expiringSoon := time.Now().Add(5 * 24 * time.Hour) + expired := time.Now().Add(-24 * time.Hour) + farFuture := time.Now().Add(365 * 24 * time.Hour) + + db.Create(&models.SSLCertificate{UUID: "exp-soon", Name: "Expiring Soon", Provider: "custom", Domains: "soon.example.com", ExpiresAt: &expiringSoon}) + db.Create(&models.SSLCertificate{UUID: "exp-past", Name: "Already Expired", Provider: "custom", Domains: "expired.example.com", ExpiresAt: &expired}) + db.Create(&models.SSLCertificate{UUID: "exp-far", Name: "Far Future", Provider: "custom", Domains: "far.example.com", ExpiresAt: &farFuture}) + // ACME certs should not be included (only custom) + db.Create(&models.SSLCertificate{UUID: "exp-le", Name: "LE Cert", Provider: "letsencrypt", Domains: "le.example.com", ExpiresAt: &expiringSoon}) + + t.Run("30 day window", func(t *testing.T) { + certs, err := cs.CheckExpiringCertificates(30) + require.NoError(t, err) + assert.Len(t, certs, 2) // expiringSoon and expired + + foundSoon := false + foundExpired := false + for _, c := range certs { + if c.UUID == "exp-soon" { + foundSoon = true + } + if c.UUID == "exp-past" { + foundExpired = true + } + } + assert.True(t, foundSoon) + assert.True(t, foundExpired) + }) + + t.Run("1 day window", func(t *testing.T) { + certs, err := cs.CheckExpiringCertificates(1) + require.NoError(t, err) + assert.Len(t, certs, 1) // only the expired one + assert.Equal(t, "exp-past", certs[0].UUID) + }) +} + +func TestCertificateService_CheckExpiry(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.Setting{}, &models.NotificationProvider{}, &models.Notification{})) + + cs := newTestCertificateService(tmpDir, db) + ns := NewNotificationService(db, nil) + + expiringSoon := time.Now().Add(5 * 24 * time.Hour) + expired := time.Now().Add(-24 * time.Hour) + db.Create(&models.SSLCertificate{UUID: "chk-soon", Name: "Expiring", Provider: "custom", Domains: "chksoon.example.com", ExpiresAt: &expiringSoon}) + db.Create(&models.SSLCertificate{UUID: "chk-past", Name: "Expired", Provider: "custom", Domains: "chkpast.example.com", ExpiresAt: &expired}) + + t.Run("nil notification service", func(t *testing.T) { + cs.checkExpiry(context.Background(), nil, 30) + }) + + t.Run("creates notifications for expiring certs", func(t *testing.T) { + cs.checkExpiry(context.Background(), ns, 30) + + var notifications []models.Notification + db.Find(¬ifications) + assert.GreaterOrEqual(t, len(notifications), 2) + }) +} + +func TestCertificateService_MigratePrivateKeys(t *testing.T) { + t.Run("no encryption service", func(t *testing.T) { + cs := &CertificateService{encSvc: nil} + err := cs.MigratePrivateKeys() + require.NoError(t, err) + }) + + t.Run("no keys to migrate", func(t *testing.T) { + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + // MigratePrivateKeys uses raw SQL referencing private_key column (gorm:"-" tag) + require.NoError(t, db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''").Error) + + encSvc := newTestEncryptionService(t) + cs := &CertificateService{db: db, encSvc: encSvc} + + err = cs.MigratePrivateKeys() + require.NoError(t, err) + }) + + t.Run("migrates plaintext key", func(t *testing.T) { + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + // MigratePrivateKeys uses raw SQL referencing private_key column (gorm:"-" tag) + require.NoError(t, db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''").Error) + + // Insert cert with plaintext key using raw SQL{})) + // MigratePrivateKeys uses raw SQL referencing private_key column (gorm:"-" tag) + require.NoError(t, db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''").Error) + + // Insert cert with plaintext key using raw SQL + require.NoError(t, db.Exec( + "INSERT INTO ssl_certificates (uuid, name, provider, domains, private_key) VALUES (?, ?, ?, ?, ?)", + "migrate-1", "Migrate Test", "custom", "migrate.example.com", "plaintext-key-data", + ).Error) + + encSvc := newTestEncryptionService(t) + cs := &CertificateService{db: db, encSvc: encSvc} + + err = cs.MigratePrivateKeys() + require.NoError(t, err) + + // Verify the key was encrypted and plaintext cleared + type rawRow struct { + PrivateKey string `gorm:"column:private_key"` + PrivateKeyEnc string `gorm:"column:private_key_enc"` + } + var row rawRow + require.NoError(t, db.Raw("SELECT private_key, private_key_enc FROM ssl_certificates WHERE uuid = ?", "migrate-1").Scan(&row).Error) + assert.Empty(t, row.PrivateKey) + assert.NotEmpty(t, row.PrivateKeyEnc) + }) +} diff --git a/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx index 6e869fc10..245d0fa4b 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx @@ -129,7 +129,7 @@ describe('ProxyHosts - Certificate Cleanup Prompts', () => { await waitFor(() => { expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1') - expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1) + expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith('cert-1') }) }) @@ -303,7 +303,7 @@ describe('ProxyHosts - Certificate Cleanup Prompts', () => { await waitFor(() => { expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1') - expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1) + expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith('cert-1') }) // Toast should show error about certificate but host was deleted @@ -366,7 +366,7 @@ describe('ProxyHosts - Certificate Cleanup Prompts', () => { await waitFor(() => { expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1') - expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1) + expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith('cert-1') }) expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h2') }) diff --git a/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx index d9c4641d1..bf160e6cc 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx @@ -72,7 +72,7 @@ describe('ProxyHosts page - coverage targets (isolated)', () => { vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [ - { id: 1, name: 'StagingCert', domain: 'staging.example.com', status: 'untrusted', provider: 'letsencrypt-staging' } + { id: 1, name: 'StagingCert', domains: 'staging.example.com', status: 'untrusted', provider: 'letsencrypt-staging' } ], isLoading: false, error: null, From 06aacdee98164efb7c1d9fec9e50f6d2b5e49f8b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:24 +0000 Subject: [PATCH 26/57] chore(deps): update actions/checkout action to v6 --- .github/skills/examples/gorm-scanner-ci-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/skills/examples/gorm-scanner-ci-workflow.yml b/.github/skills/examples/gorm-scanner-ci-workflow.yml index e5b49b165..2f38c43a0 100644 --- a/.github/skills/examples/gorm-scanner-ci-workflow.yml +++ b/.github/skills/examples/gorm-scanner-ci-workflow.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup Go uses: actions/setup-go@v5 From 85216ba6e037c306eadad0b138b867ca160d7e75 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:30 +0000 Subject: [PATCH 27/57] chore(deps): update actions/github-script action to v9 --- .github/skills/examples/gorm-scanner-ci-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/skills/examples/gorm-scanner-ci-workflow.yml b/.github/skills/examples/gorm-scanner-ci-workflow.yml index e5b49b165..d74207895 100644 --- a/.github/skills/examples/gorm-scanner-ci-workflow.yml +++ b/.github/skills/examples/gorm-scanner-ci-workflow.yml @@ -56,7 +56,7 @@ jobs: - name: Comment on PR if: always() && github.event_name == 'pull_request' - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const critical = ${{ steps.parse-report.outputs.critical }}; From 368130b07a4d44fd1c9383d5e09cdd7632722000 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:36 +0000 Subject: [PATCH 28/57] chore(deps): update actions/setup-go action to v6 --- .github/skills/examples/gorm-scanner-ci-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/skills/examples/gorm-scanner-ci-workflow.yml b/.github/skills/examples/gorm-scanner-ci-workflow.yml index e5b49b165..aaf947271 100644 --- a/.github/skills/examples/gorm-scanner-ci-workflow.yml +++ b/.github/skills/examples/gorm-scanner-ci-workflow.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: "1.26.2" From f0ffc27ca7e8d9a283ada6d7a87f643ce1d63e47 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:54 +0000 Subject: [PATCH 29/57] chore(deps): update actions/upload-artifact action to v7 --- .github/skills/examples/gorm-scanner-ci-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/skills/examples/gorm-scanner-ci-workflow.yml b/.github/skills/examples/gorm-scanner-ci-workflow.yml index 6cb070519..257b8d98c 100644 --- a/.github/skills/examples/gorm-scanner-ci-workflow.yml +++ b/.github/skills/examples/gorm-scanner-ci-workflow.yml @@ -89,7 +89,7 @@ jobs: - name: Upload GORM Scan Report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: gorm-security-report-${{ github.run_id }} path: docs/reports/gorm-scan-ci-*.txt From ea3d93253ffc98ca5d75e7df0afb04e98cccc4a2 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 13:07:51 +0000 Subject: [PATCH 30/57] fix: update CADDY_SECURITY_VERSION to 1.1.62 for improved security --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a6b68eb9e..efc4040fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ ARG CADDY_CANDIDATE_VERSION=2.11.2 ARG CADDY_USE_CANDIDATE=0 ARG CADDY_PATCH_SCENARIO=B # renovate: datasource=go depName=github.com/greenpau/caddy-security -ARG CADDY_SECURITY_VERSION=1.1.61 +ARG CADDY_SECURITY_VERSION=1.1.62 # renovate: datasource=go depName=github.com/corazawaf/coraza-caddy ARG CORAZA_CADDY_VERSION=2.5.0 ## When an official caddy image tag isn't available on the host, use a From c19aa55fd74ee3b0e0e6daf507d4eede68cdba4f Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 13:09:33 +0000 Subject: [PATCH 31/57] chore: update package-lock.json to upgrade dependencies for improved stability --- frontend/package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 257325635..7c9dd5c00 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1164,9 +1164,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -6810,9 +6810,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -7945,9 +7945,9 @@ } }, "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { From f3c33dc81b1b914128f67863d830e6de165e49dd Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 13:55:41 +0000 Subject: [PATCH 32/57] fix: update golang.org/x/term to v0.42.0 for compatibility improvements --- go.work.sum | 1 + 1 file changed, 1 insertion(+) diff --git a/go.work.sum b/go.work.sum index c0da62ff6..017df268a 100644 --- a/go.work.sum +++ b/go.work.sum @@ -123,6 +123,7 @@ golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= From 3005db694349d932935dbccad7f0a718a9351c8c Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 13:56:59 +0000 Subject: [PATCH 33/57] fix: remove unnecessary string checks for key file in Upload method --- backend/internal/api/handlers/certificate_handler.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go index ed2c630d6..6e9744cee 100644 --- a/backend/internal/api/handlers/certificate_handler.go +++ b/backend/internal/api/handlers/certificate_handler.go @@ -5,7 +5,6 @@ import ( "io" "net/http" "strconv" - "strings" "sync" "time" @@ -120,7 +119,7 @@ func (h *CertificateHandler) Upload(c *gin.Context) { } certPEM := string(certBytes) - // Read private key file (optional for PFX) + // Read private key file (optional — format detection is content-based in the service) var keyPEM string keyFile, err := c.FormFile("key_file") if err == nil { @@ -141,10 +140,6 @@ func (h *CertificateHandler) Upload(c *gin.Context) { 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 } // Read chain file (optional) From 942f585dd18a081d4676b0fab43affd196942f08 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 13:58:45 +0000 Subject: [PATCH 34/57] fix: improve error response format in certificate validation --- backend/internal/api/handlers/certificate_handler.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go index 6e9744cee..a1ffa34ef 100644 --- a/backend/internal/api/handlers/certificate_handler.go +++ b/backend/internal/api/handlers/certificate_handler.go @@ -293,7 +293,10 @@ func (h *CertificateHandler) Validate(c *gin.Context) { 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"}) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "validation failed", + "errors": []string{err.Error()}, + }) return } From 0391f2b3e3fce114c6fa2392922913812705fee4 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 14:01:05 +0000 Subject: [PATCH 35/57] fix: add PFX password parameter to ExportCertificate method and update tests --- .../internal/api/handlers/certificate_handler.go | 2 +- backend/internal/services/certificate_service.go | 4 ++-- .../services/certificate_service_coverage_test.go | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go index a1ffa34ef..b9d8e7f3f 100644 --- a/backend/internal/api/handlers/certificate_handler.go +++ b/backend/internal/api/handlers/certificate_handler.go @@ -360,7 +360,7 @@ func (h *CertificateHandler) Export(c *gin.Context) { } } - data, filename, err := h.service.ExportCertificate(certUUID, req.Format, req.IncludeKey) + data, filename, err := h.service.ExportCertificate(certUUID, req.Format, req.IncludeKey, req.PFXPassword) if err != nil { if err == services.ErrCertNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) diff --git a/backend/internal/services/certificate_service.go b/backend/internal/services/certificate_service.go index b96f1c289..d1646e720 100644 --- a/backend/internal/services/certificate_service.go +++ b/backend/internal/services/certificate_service.go @@ -708,7 +708,7 @@ func (s *CertificateService) DeleteCertificate(certUUID string) error { // 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) { +func (s *CertificateService) ExportCertificate(certUUID string, format string, includeKey bool, pfxPassword string) ([]byte, string, error) { var cert models.SSLCertificate if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil { if err == gorm.ErrRecordNotFound { @@ -752,7 +752,7 @@ func (s *CertificateService) ExportCertificate(certUUID string, format string, i if err != nil { return nil, "", fmt.Errorf("failed to decrypt private key for PFX: %w", err) } - pfxData, err := ConvertPEMToPFX(cert.Certificate, keyPEM, cert.CertificateChain, "") + pfxData, err := ConvertPEMToPFX(cert.Certificate, keyPEM, cert.CertificateChain, pfxPassword) if err != nil { return nil, "", fmt.Errorf("failed to create PFX: %w", err) } diff --git a/backend/internal/services/certificate_service_coverage_test.go b/backend/internal/services/certificate_service_coverage_test.go index 22c02937d..86d3547ec 100644 --- a/backend/internal/services/certificate_service_coverage_test.go +++ b/backend/internal/services/certificate_service_coverage_test.go @@ -311,19 +311,19 @@ func TestCertificateService_ExportCertificate(t *testing.T) { cert := seedCertWithKey(t, db, encSvc, "export-cert-1", "Export Cert", domain, expiry) t.Run("not found", func(t *testing.T) { - _, _, err := cs.ExportCertificate("nonexistent", "pem", false) + _, _, err := cs.ExportCertificate("nonexistent", "pem", false, "") assert.ErrorIs(t, err, ErrCertNotFound) }) t.Run("pem without key", func(t *testing.T) { - data, filename, err := cs.ExportCertificate(cert.UUID, "pem", false) + data, filename, err := cs.ExportCertificate(cert.UUID, "pem", false, "") require.NoError(t, err) assert.Equal(t, "Export Cert.pem", filename) assert.Contains(t, string(data), "BEGIN CERTIFICATE") }) t.Run("pem with key", func(t *testing.T) { - data, filename, err := cs.ExportCertificate(cert.UUID, "pem", true) + data, filename, err := cs.ExportCertificate(cert.UUID, "pem", true, "") require.NoError(t, err) assert.Equal(t, "Export Cert.pem", filename) assert.Contains(t, string(data), "BEGIN CERTIFICATE") @@ -331,28 +331,28 @@ func TestCertificateService_ExportCertificate(t *testing.T) { }) t.Run("der format", func(t *testing.T) { - data, filename, err := cs.ExportCertificate(cert.UUID, "der", false) + data, filename, err := cs.ExportCertificate(cert.UUID, "der", false, "") require.NoError(t, err) assert.Equal(t, "Export Cert.der", filename) assert.NotEmpty(t, data) }) t.Run("pfx format", func(t *testing.T) { - data, filename, err := cs.ExportCertificate(cert.UUID, "pfx", false) + data, filename, err := cs.ExportCertificate(cert.UUID, "pfx", false, "") require.NoError(t, err) assert.Equal(t, "Export Cert.pfx", filename) assert.NotEmpty(t, data) }) t.Run("unsupported format", func(t *testing.T) { - _, _, err := cs.ExportCertificate(cert.UUID, "jks", false) + _, _, err := cs.ExportCertificate(cert.UUID, "jks", false, "") assert.Error(t, err) assert.Contains(t, err.Error(), "unsupported export format") }) t.Run("empty name uses fallback", func(t *testing.T) { noNameCert := seedCertWithKey(t, db, encSvc, "export-noname", "", domain, expiry) - _, filename, err := cs.ExportCertificate(noNameCert.UUID, "pem", false) + _, filename, err := cs.ExportCertificate(noNameCert.UUID, "pem", false, "") require.NoError(t, err) assert.Equal(t, "certificate.pem", filename) }) From 29c56ab283d073e02c0460dd7fcbb86bd963d345 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 14:10:44 +0000 Subject: [PATCH 36/57] fix: add context parameter to route registration functions for improved lifecycle management --- backend/cmd/api/main.go | 9 +- .../api/routes/endpoint_inventory_test.go | 7 +- backend/internal/api/routes/routes.go | 8 +- .../api/routes/routes_coverage_test.go | 9 +- .../api/routes/routes_save_contract_test.go | 3 +- backend/internal/api/routes/routes_test.go | 95 ++++++++++--------- .../internal/api/tests/integration_test.go | 3 +- backend/internal/config/config.go | 11 +-- 8 files changed, 75 insertions(+), 70 deletions(-) diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 5bc854090..f48467fd2 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -255,7 +255,11 @@ func main() { cerb := cerberus.New(cfg.Security, db) // Pass config to routes for auth service and certificate service - if err := routes.RegisterWithDeps(router, db, cfg, caddyManager, cerb); err != nil { + // Lifecycle context cancelled on shutdown to stop background goroutines + appCtx, appCancel := context.WithCancel(context.Background()) + defer appCancel() + + if err := routes.RegisterWithDeps(appCtx, router, db, cfg, caddyManager, cerb); err != nil { log.Fatalf("register routes: %v", err) } @@ -291,6 +295,9 @@ func main() { sig := <-quit logger.Log().Infof("Received signal %v, initiating graceful shutdown...", sig) + // Cancel the app-wide context to stop background goroutines (e.g. cert expiry checker) + appCancel() + // Graceful shutdown with timeout ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/backend/internal/api/routes/endpoint_inventory_test.go b/backend/internal/api/routes/endpoint_inventory_test.go index e6c233143..6019c6faa 100644 --- a/backend/internal/api/routes/endpoint_inventory_test.go +++ b/backend/internal/api/routes/endpoint_inventory_test.go @@ -1,6 +1,7 @@ package routes_test import ( + "context" "testing" "github.com/gin-gonic/gin" @@ -20,7 +21,7 @@ func TestEndpointInventory_FrontendCanonicalSaveImportContractsExistInBackend(t require.NoError(t, err) router := gin.New() - require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"})) + require.NoError(t, routes.Register(context.Background(), router, db, config.Config{JWTSecret: "test-secret"})) routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile") assertStrictMethodPathMatrix(t, router.Routes(), backendImportSaveInventoryCanonical(), "backend canonical save/import inventory") @@ -33,7 +34,7 @@ func TestEndpointInventory_FrontendParityMatchesCurrentContract(t *testing.T) { require.NoError(t, err) router := gin.New() - require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"})) + require.NoError(t, routes.Register(context.Background(), router, db, config.Config{JWTSecret: "test-secret"})) routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile") assertStrictMethodPathMatrix(t, router.Routes(), frontendObservedImportSaveInventory(), "frontend observed save/import inventory") @@ -46,7 +47,7 @@ func TestEndpointInventory_FrontendParityDetectsActualMismatch(t *testing.T) { require.NoError(t, err) router := gin.New() - require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"})) + require.NoError(t, routes.Register(context.Background(), router, db, config.Config{JWTSecret: "test-secret"})) routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile") contractWithMismatch := append([]endpointInventoryEntry{}, frontendObservedImportSaveInventory()...) diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 0cb00e988..0a085c297 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -61,7 +61,7 @@ func migrateViewerToPassthrough(db *gorm.DB) { } // Register wires up API routes and performs automatic migrations. -func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { +func Register(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg config.Config) error { // Caddy Manager - created early so it can be used by settings handlers for config reload caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security) @@ -69,11 +69,11 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { // Cerberus middleware applies the optional security suite checks (WAF, ACL, CrowdSec) cerb := cerberus.New(cfg.Security, db) - return RegisterWithDeps(router, db, cfg, caddyManager, cerb) + return RegisterWithDeps(ctx, router, db, cfg, caddyManager, cerb) } // RegisterWithDeps wires up API routes and performs automatic migrations with prebuilt dependencies. -func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyManager *caddy.Manager, cerb *cerberus.Cerberus) error { +func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg config.Config, caddyManager *caddy.Manager, cerb *cerberus.Cerberus) error { // Emergency bypass must be registered FIRST. // When a valid X-Emergency-Token is present from an authorized source, // it sets an emergency context flag and strips the token header so downstream @@ -705,7 +705,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM if cfg.CertExpiryWarningDays > 0 { warningDays = cfg.CertExpiryWarningDays } - go certService.StartExpiryChecker(context.Background(), notificationService, warningDays) + go certService.StartExpiryChecker(ctx, notificationService, warningDays) // Proxy Hosts & Remote Servers proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService) diff --git a/backend/internal/api/routes/routes_coverage_test.go b/backend/internal/api/routes/routes_coverage_test.go index 57939ce75..21b21d41e 100644 --- a/backend/internal/api/routes/routes_coverage_test.go +++ b/backend/internal/api/routes/routes_coverage_test.go @@ -1,6 +1,7 @@ package routes import ( + "context" "errors" "testing" @@ -34,7 +35,7 @@ func TestRegister_NotifyOnlyProviderMigrationErrorReturns(t *testing.T) { cfg := config.Config{JWTSecret: "test-secret"} - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.Error(t, err) require.Contains(t, err.Error(), "notify-only provider migration") } @@ -61,7 +62,7 @@ func TestRegister_LegacyMigrationErrorIsNonFatal(t *testing.T) { cfg := config.Config{JWTSecret: "test-secret"} - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.NoError(t, err) hasHealth := false @@ -96,7 +97,7 @@ func TestRegister_UptimeFeatureFlagDefaultErrorIsNonFatal(t *testing.T) { cfg := config.Config{JWTSecret: "test-secret"} - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.NoError(t, err) } @@ -122,6 +123,6 @@ func TestRegister_SecurityHeaderPresetInitErrorIsNonFatal(t *testing.T) { cfg := config.Config{JWTSecret: "test-secret"} - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.NoError(t, err) } diff --git a/backend/internal/api/routes/routes_save_contract_test.go b/backend/internal/api/routes/routes_save_contract_test.go index 33e9afd49..e5be04847 100644 --- a/backend/internal/api/routes/routes_save_contract_test.go +++ b/backend/internal/api/routes/routes_save_contract_test.go @@ -1,6 +1,7 @@ package routes_test import ( + "context" "testing" "github.com/gin-gonic/gin" @@ -19,7 +20,7 @@ func TestRegister_StrictSaveRouteMatrixUsedByImportWorkflows(t *testing.T) { require.NoError(t, err) router := gin.New() - require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"})) + require.NoError(t, routes.Register(context.Background(), router, db, config.Config{JWTSecret: "test-secret"})) assertStrictMethodPathMatrix(t, router.Routes(), saveRouteMatrixForImportWorkflows(), "save") } diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go index 28a3ed16b..d8714b0a7 100644 --- a/backend/internal/api/routes/routes_test.go +++ b/backend/internal/api/routes/routes_test.go @@ -1,6 +1,7 @@ package routes import ( + "context" "io" "net/http" "net/http/httptest" @@ -41,7 +42,7 @@ func TestRegister(t *testing.T) { JWTSecret: "test-secret", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) assert.NoError(t, err) // Verify some routes are registered @@ -70,7 +71,7 @@ func TestRegister_WithDevelopmentEnvironment(t *testing.T) { Environment: "development", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) assert.NoError(t, err) } @@ -86,7 +87,7 @@ func TestRegister_WithProductionEnvironment(t *testing.T) { Environment: "production", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) assert.NoError(t, err) } @@ -107,7 +108,7 @@ func TestRegister_AutoMigrateFailure(t *testing.T) { JWTSecret: "test-secret", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "auto migrate") } @@ -148,7 +149,7 @@ func TestRegister_RoutesRegistration(t *testing.T) { JWTSecret: "test-secret", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.NoError(t, err) routes := router.Routes() @@ -181,7 +182,7 @@ func TestRegister_ProxyHostsRequireAuth(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") @@ -200,7 +201,7 @@ func TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist(t *testi require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) mutatingMethods := map[string]bool{ http.MethodPost: true, @@ -264,7 +265,7 @@ func TestRegister_DNSProviders_NotRegisteredWhenEncryptionKeyMissing(t *testing. require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret", EncryptionKey: ""} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) for _, r := range router.Routes() { assert.NotContains(t, r.Path, "/api/v1/dns-providers") @@ -279,7 +280,7 @@ func TestRegister_DNSProviders_NotRegisteredWhenEncryptionKeyInvalid(t *testing. require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret", EncryptionKey: "not-base64"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) for _, r := range router.Routes() { assert.NotContains(t, r.Path, "/api/v1/dns-providers") @@ -295,7 +296,7 @@ func TestRegister_DNSProviders_RegisteredWhenEncryptionKeyValid(t *testing.T) { // 32-byte all-zero key in base64 cfg := config.Config{JWTSecret: "test-secret", EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) paths := make(map[string]bool) for _, r := range router.Routes() { @@ -317,7 +318,7 @@ func TestRegister_AllRoutesRegistered(t *testing.T) { JWTSecret: "test-secret", EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string][]string) // path -> methods @@ -384,7 +385,7 @@ func TestRegister_MiddlewareApplied(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Test that security headers middleware is applied w := httptest.NewRecorder() @@ -413,7 +414,7 @@ func TestRegister_AuthenticatedRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Test that protected routes require authentication protectedPaths := []struct { @@ -449,7 +450,7 @@ func TestRegister_StateChangingRoutesRequireAuthentication(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) stateChangingPaths := []struct { method string @@ -488,7 +489,7 @@ func TestRegister_AdminRoutes(t *testing.T) { JWTSecret: "test-secret", EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Admin routes should exist and require auth adminPaths := []string{ @@ -513,7 +514,7 @@ func TestRegister_PublicRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Public routes should be accessible without auth (route exists, not 404) publicPaths := []struct { @@ -545,7 +546,7 @@ func TestRegister_HealthEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) @@ -563,7 +564,7 @@ func TestRegister_MetricsEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/metrics", nil) @@ -582,7 +583,7 @@ func TestRegister_DBHealthEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/health/db", nil) @@ -600,7 +601,7 @@ func TestRegister_LoginEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Test login endpoint exists and accepts POST body := `{"username": "test", "password": "test"}` @@ -621,7 +622,7 @@ func TestRegister_SetupEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // GET /setup should return setup status w := httptest.NewRecorder() @@ -646,7 +647,7 @@ func TestRegister_WithEncryptionRoutes(t *testing.T) { JWTSecret: "test-secret", EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Check if encryption routes are registered (may depend on env) routes := router.Routes() @@ -668,7 +669,7 @@ func TestRegister_UptimeCheckEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Uptime check route should exist and require auth w := httptest.NewRecorder() @@ -687,7 +688,7 @@ func TestRegister_CrowdSecRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // CrowdSec routes should exist routes := router.Routes() @@ -713,7 +714,7 @@ func TestRegister_SecurityRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -740,7 +741,7 @@ func TestRegister_AccessListRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -763,7 +764,7 @@ func TestRegister_CertificateRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -792,7 +793,7 @@ func TestRegister_NilHandlers(t *testing.T) { EncryptionKey: "", // No encryption key - DNS providers won't be registered } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) assert.NoError(t, err) // Verify that routes still work without DNS provider features @@ -823,7 +824,7 @@ func TestRegister_MiddlewareOrder(t *testing.T) { Environment: "development", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.NoError(t, err) // Test that security headers are applied (they should come first) @@ -848,7 +849,7 @@ func TestRegister_GzipCompression(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Request with Accept-Encoding: gzip w := httptest.NewRecorder() @@ -875,7 +876,7 @@ func TestRegister_CerberusMiddleware(t *testing.T) { }, } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.NoError(t, err) // API routes should have Cerberus middleware applied @@ -896,7 +897,7 @@ func TestRegister_FeatureFlagsEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Feature flags should require auth w := httptest.NewRecorder() @@ -915,7 +916,7 @@ func TestRegister_WebSocketRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -939,7 +940,7 @@ func TestRegister_NotificationRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -967,7 +968,7 @@ func TestRegister_DomainRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -989,7 +990,7 @@ func TestRegister_VerifyAuthEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Verify endpoint is public (for Caddy forward auth) w := httptest.NewRecorder() @@ -1009,7 +1010,7 @@ func TestRegister_SMTPRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -1064,7 +1065,7 @@ func TestRegister_EncryptionRoutesWithValidKey(t *testing.T) { JWTSecret: "test-secret", EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -1091,7 +1092,7 @@ func TestRegister_WAFExclusionRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -1113,7 +1114,7 @@ func TestRegister_BreakGlassRoute(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -1134,7 +1135,7 @@ func TestRegister_RateLimitPresetsRoute(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -1166,7 +1167,7 @@ func TestEmergencyEndpoint_BypassACL(t *testing.T) { CerberusEnabled: true, }, } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Note: We don't need to create ACL settings here because the emergency endpoint // bypass happens at middleware level before Cerberus checks @@ -1210,7 +1211,7 @@ func TestEmergencyBypass_MiddlewareOrder(t *testing.T) { ManagementCIDRs: []string{"127.0.0.0/8"}, }, } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Request with emergency token should set bypass flag w := httptest.NewRecorder() @@ -1239,7 +1240,7 @@ func TestEmergencyBypass_InvalidToken(t *testing.T) { CerberusEnabled: true, }, } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Request with WRONG emergency token w := httptest.NewRecorder() @@ -1271,7 +1272,7 @@ func TestEmergencyBypass_UnauthorizedIP(t *testing.T) { ManagementCIDRs: []string{"192.168.1.0/24"}, }, } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Request from public IP (not in management network) w := httptest.NewRecorder() @@ -1295,7 +1296,7 @@ func TestRegister_CreatesAccessLogFileForLogWatcher(t *testing.T) { t.Setenv("CHARON_CADDY_ACCESS_LOG", logFilePath) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) _, statErr := os.Stat(logFilePath) assert.NoError(t, statErr) @@ -1341,7 +1342,7 @@ func TestRegister_CleansLetsEncryptCertAssignments(t *testing.T) { require.NoError(t, db.Create(&host).Error) cfg := config.Config{JWTSecret: "test-secret"} - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.NoError(t, err) var reloaded models.ProxyHost diff --git a/backend/internal/api/tests/integration_test.go b/backend/internal/api/tests/integration_test.go index 6cc21b9af..2488744d2 100644 --- a/backend/internal/api/tests/integration_test.go +++ b/backend/internal/api/tests/integration_test.go @@ -2,6 +2,7 @@ package tests import ( + "context" "net/http" "net/http/httptest" "strings" @@ -33,7 +34,7 @@ func TestIntegration_WAF_BlockAndMonitor(t *testing.T) { } cfg.Security.WAFMode = mode r := gin.New() - if err := routes.Register(r, db, cfg); err != nil { + if err := routes.Register(context.Background(), r, db, cfg); err != nil { t.Fatalf("register: %v", err) } return r, db diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index bf1634df5..fe09bce32 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -110,15 +110,8 @@ 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 != "" { + cfg.CertExpiryWarningDays = 30 + if days := getEnvAny("", "CHARON_CERT_EXPIRY_WARNING_DAYS"); days != "" { if n, err := strconv.Atoi(days); err == nil && n > 0 { cfg.CertExpiryWarningDays = n } From 7c8e8c001c254a478fede294ab21c1f8d91fae99 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 14:11:48 +0000 Subject: [PATCH 37/57] fix: enhance error handling in ConvertPEMToPFX for empty certificate cases --- backend/internal/services/certificate_validator.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/internal/services/certificate_validator.go b/backend/internal/services/certificate_validator.go index 4557e4d90..86455bfd2 100644 --- a/backend/internal/services/certificate_validator.go +++ b/backend/internal/services/certificate_validator.go @@ -340,9 +340,12 @@ func ConvertPFXToPEM(pfxData []byte, password string) (certPEM string, keyPEM st // 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 { + if err != nil { return nil, fmt.Errorf("failed to parse cert PEM: %w", err) } + if len(certs) == 0 { + return nil, fmt.Errorf("no certificates found in cert PEM") + } key, err := parsePEMPrivateKey([]byte(keyPEM)) if err != nil { From 1fe8a79ea3c46a3d745a270e10042580eab54c04 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 17:29:26 +0000 Subject: [PATCH 38/57] fix: update @typescript-eslint packages to version 8.58.2 and undici to version 7.25.0 --- frontend/package-lock.json | 128 ++++++++++++++++++------------------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7c9dd5c00..82b3ceae4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3849,17 +3849,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", - "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/type-utils": "8.58.1", - "@typescript-eslint/utils": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -3872,22 +3872,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.1", + "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", - "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "engines": { @@ -3903,14 +3903,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", - "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.1", - "@typescript-eslint/types": "^8.58.1", + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "engines": { @@ -3925,14 +3925,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", - "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1" + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3943,9 +3943,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", - "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", "dev": true, "license": "MIT", "engines": { @@ -3960,15 +3960,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", - "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3985,9 +3985,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", - "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", "dev": true, "license": "MIT", "engines": { @@ -3999,16 +3999,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", - "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.1", - "@typescript-eslint/tsconfig-utils": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4027,16 +4027,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", - "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1" + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4051,13 +4051,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", - "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -11288,16 +11288,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", - "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", + "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.1", - "@typescript-eslint/parser": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1" + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11341,9 +11341,9 @@ } }, "node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { From 877a32f1806fb56eb5233a677318e4c3747d050f Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 17:31:05 +0000 Subject: [PATCH 39/57] fix: enhance form validation for certificate upload by adding required attributes and adjusting test logic --- docs/plans/current_spec.md | 305 ++++++++++++++++++++ frontend/src/components/ui/FileDropZone.tsx | 1 + tests/core/certificates.spec.ts | 61 ++-- 3 files changed, 339 insertions(+), 28 deletions(-) diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 96e9eadc8..19b516331 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -947,3 +947,308 @@ GORM `AutoMigrate` handles additive column changes automatically. The private ke - [ ] GORM security scan reports zero CRITICAL/HIGH findings - [ ] CodeQL scans report zero HIGH/CRITICAL findings - [ ] No plaintext private keys in database after migration + +--- + +## Root Cause Analysis: E2E Certificate Test Failures (PR #928) + +**Date**: 2026-06-24 +**Scope**: 4 failing tests in `tests/core/certificates.spec.ts` +**Branch**: `feature/beta-release` + +### Failing Tests + +| # | Test Name | Line | Test Describe Block | +|---|-----------|------|---------------------| +| 1 | should validate required name field | L349 | Upload Dialog | +| 2 | should require certificate file | L375 | Upload Dialog | +| 3 | should require private key file | L400 | Upload Dialog | +| 4 | should reject empty friendly name | L776 | Form Validation | + +--- + +### Root Cause Summary + +There are **two layers** of failure. Layer 1 is the primary blocker in CI. Layer 2 contains test-logic defects that would surface even after Layer 1 is resolved. + +#### Layer 1: Infrastructure — Disabled Submit Button Blocks All Validation Tests + +**Classification**: Test Issue +**Severity**: CRITICAL — blocks all 4 tests + +**Mechanism**: + +The `CertificateUploadDialog` submit button is governed by: + +```tsx +// frontend/src/components/dialogs/CertificateUploadDialog.tsx +const canSubmit = !!certFile && !!name.trim() + +