├── docs
└── .gitkeep
├── app
├── docs
│ └── .gitkeep
├── cmd
│ └── server
│ │ ├── web
│ │ ├── assets
│ │ │ ├── favicon.ico
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ ├── apple-touch-icon.png
│ │ │ ├── android-chrome-192x192.png
│ │ │ ├── android-chrome-512x512.png
│ │ │ └── site.webmanifest
│ │ └── templates
│ │ │ ├── certs-fragment.html
│ │ │ ├── footer-status.html
│ │ │ ├── theme-toggle-fragment.html
│ │ │ ├── certs-state.html
│ │ │ ├── certs-rows.html
│ │ │ ├── certs-pagination.html
│ │ │ ├── certs-sort.html
│ │ │ ├── cert-details.html
│ │ │ └── dashboard-fragment.html
│ │ ├── assets.go
│ │ └── main.go
├── internal
│ ├── version
│ │ ├── version_test.go
│ │ └── version.go
│ ├── handlers
│ │ ├── health.go
│ │ ├── ready.go
│ │ ├── health_test.go
│ │ ├── ready_test.go
│ │ ├── config.go
│ │ ├── i18n.go
│ │ ├── config_test.go
│ │ ├── certs_util_test.go
│ │ ├── certs_test.go
│ │ ├── i18n_test.go
│ │ ├── certs.go
│ │ └── ui_test.go
│ ├── vault
│ │ ├── client.go
│ │ ├── mock_client.go
│ │ ├── real_client_test.go
│ │ └── real.go
│ ├── certs
│ │ └── certificate.go
│ ├── i18n
│ │ ├── i18n_test.go
│ │ └── i18n_notification_test.go
│ ├── cache
│ │ ├── cache.go
│ │ └── cache_test.go
│ ├── metrics
│ │ ├── certificate_collector_test.go
│ │ └── certificate_collector.go
│ └── logger
│ │ ├── logger.go
│ │ └── logger_test.go
├── Dockerfile
├── go.mod
├── config
│ ├── config_test.go
│ ├── config_expiration_test.go
│ └── config.go
├── middleware
│ ├── middleware.go
│ └── middleware_test.go
├── README.md
└── go.sum
├── img
├── VaultCertsViewer-v1.3.png
├── VaultCertsViewer-v1.3-dark.png
└── VaultCertsViewer-v1.3-light.png
├── .github
├── dependabot.yml
├── FUNDING.yml
└── workflows
│ ├── lint.yml
│ └── go.yml
├── .gitignore
├── env.example
├── LICENSE
├── docker-compose.yml
├── Makefile
├── docker-compose.dev.yml
├── vcv-deployment.yaml
├── README.md
├── README.fr.md
└── vault-dev-init-2.sh
/docs/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/docs/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/VaultCertsViewer-v1.3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/julienhmmt/vcv/HEAD/img/VaultCertsViewer-v1.3.png
--------------------------------------------------------------------------------
/img/VaultCertsViewer-v1.3-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/julienhmmt/vcv/HEAD/img/VaultCertsViewer-v1.3-dark.png
--------------------------------------------------------------------------------
/img/VaultCertsViewer-v1.3-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/julienhmmt/vcv/HEAD/img/VaultCertsViewer-v1.3-light.png
--------------------------------------------------------------------------------
/app/cmd/server/web/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/julienhmmt/vcv/HEAD/app/cmd/server/web/assets/favicon.ico
--------------------------------------------------------------------------------
/app/cmd/server/web/assets/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/julienhmmt/vcv/HEAD/app/cmd/server/web/assets/favicon-16x16.png
--------------------------------------------------------------------------------
/app/cmd/server/web/assets/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/julienhmmt/vcv/HEAD/app/cmd/server/web/assets/favicon-32x32.png
--------------------------------------------------------------------------------
/app/cmd/server/web/assets/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/julienhmmt/vcv/HEAD/app/cmd/server/web/assets/apple-touch-icon.png
--------------------------------------------------------------------------------
/app/cmd/server/web/assets/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/julienhmmt/vcv/HEAD/app/cmd/server/web/assets/android-chrome-192x192.png
--------------------------------------------------------------------------------
/app/cmd/server/web/assets/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/julienhmmt/vcv/HEAD/app/cmd/server/web/assets/android-chrome-512x512.png
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | ---
2 | version: 2
3 | updates:
4 | - package-ecosystem: "github-actions"
5 | directory: "/"
6 | schedule:
7 | interval: "weekly"
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .windsurf/
2 | .DS_Store
3 | # Golang binary
4 | vcv
5 | # App logs
6 | vcv.log
7 | # test coverage
8 | coverage.out
9 | # test logs
10 | test-offline.log
--------------------------------------------------------------------------------
/app/cmd/server/assets.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "embed"
4 |
5 | // embeddedWeb contains the compiled-in static assets for the UI.
6 | //
7 | //go:embed web/*
8 | var embeddedWeb embed.FS
9 |
--------------------------------------------------------------------------------
/app/cmd/server/web/templates/certs-fragment.html:
--------------------------------------------------------------------------------
1 | {{template "certs-rows" .}}
2 | {{template "dashboard-fragment" .}}
3 | {{template "certs-state" .}}
4 | {{template "certs-pagination" .}}
5 | {{template "certs-sort" .}}
6 |
--------------------------------------------------------------------------------
/app/internal/version/version_test.go:
--------------------------------------------------------------------------------
1 | package version
2 |
3 | import "testing"
4 |
5 | func TestInfoContainsVersion(t *testing.T) {
6 | info := Info()
7 | if info["version"] == "" {
8 | t.Fatalf("expected version to be present")
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/app/cmd/server/web/templates/footer-status.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/app/cmd/server/web/assets/site.webmanifest:
--------------------------------------------------------------------------------
1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
--------------------------------------------------------------------------------
/app/internal/version/version.go:
--------------------------------------------------------------------------------
1 | package version
2 |
3 | // Build information set via ldflags at compile time.
4 | var (
5 | // Version is the semantic version of the application.
6 | Version = "1.0"
7 | )
8 |
9 | // Info returns version information as a structured map.
10 | func Info() map[string]string {
11 | return map[string]string{
12 | "version": Version,
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/app/internal/handlers/health.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "net/http"
5 |
6 | "vcv/internal/logger"
7 | "vcv/middleware"
8 | )
9 |
10 | func HealthCheck(w http.ResponseWriter, r *http.Request) {
11 | requestID := middleware.GetRequestID(r.Context())
12 | logger.HTTPEvent(r.Method, r.URL.Path, http.StatusOK, 0).
13 | Str("request_id", requestID).
14 | Msg("health check")
15 | w.WriteHeader(http.StatusOK)
16 | }
17 |
--------------------------------------------------------------------------------
/env.example:
--------------------------------------------------------------------------------
1 | # EXAMPLE
2 | # Change with your actual Vault configuration
3 | APP_ENV=prod
4 | LOG_FILE_PATH=/var/log/app/vcv.log
5 | LOG_FORMAT=json
6 | LOG_LEVEL=info
7 | LOG_OUTPUT=stdout # 'file', 'stdout' or 'both'
8 | PORT=52000
9 | VAULT_ADDR=https://your-vault-address:8200
10 | VAULT_PKI_MOUNTS=pki,pki2
11 | VAULT_READ_TOKEN=s.YourGeneratedTokenHere
12 | VAULT_TLS_INSECURE=false
13 | VCV_EXPIRE_CRITICAL=7
14 | VCV_EXPIRE_WARNING=30
--------------------------------------------------------------------------------
/app/internal/handlers/ready.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "net/http"
5 |
6 | "vcv/internal/logger"
7 | "vcv/middleware"
8 | )
9 |
10 | func ReadinessCheck(w http.ResponseWriter, r *http.Request) {
11 | requestID := middleware.GetRequestID(r.Context())
12 | logger.HTTPEvent(r.Method, r.URL.Path, http.StatusOK, 0).
13 | Str("request_id", requestID).
14 | Msg("readiness check")
15 | w.WriteHeader(http.StatusOK)
16 | }
17 |
--------------------------------------------------------------------------------
/app/cmd/server/web/templates/theme-toggle-fragment.html:
--------------------------------------------------------------------------------
1 | {{.Icon}}
2 |
3 |
12 |
--------------------------------------------------------------------------------
/app/internal/vault/client.go:
--------------------------------------------------------------------------------
1 | package vault
2 |
3 | import (
4 | "context"
5 |
6 | "vcv/internal/certs"
7 | )
8 |
9 | // Client defines the interface for interacting with Vault PKI.
10 | type Client interface {
11 | CheckConnection(ctx context.Context) error
12 | GetCertificateDetails(ctx context.Context, serialNumber string) (certs.DetailedCertificate, error)
13 | GetCertificatePEM(ctx context.Context, serialNumber string) (certs.PEMResponse, error)
14 | InvalidateCache()
15 | ListCertificates(ctx context.Context) ([]certs.Certificate, error)
16 | Shutdown()
17 | }
18 |
--------------------------------------------------------------------------------
/app/cmd/server/web/templates/certs-state.html:
--------------------------------------------------------------------------------
1 | {{define "certs-state"}}
2 |
3 |
4 |
5 | {{.PageInfoText}}
6 | {{.PageCountText}}
7 | {{end}}
8 |
--------------------------------------------------------------------------------
/app/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.25.5-alpine3.23 AS builder
2 |
3 | ARG VERSION=${VERSION}
4 |
5 | WORKDIR /app
6 |
7 | COPY go.mod go.sum ./
8 | RUN go mod download
9 |
10 | # Copy backend source code
11 | COPY . .
12 |
13 | # Build the application with version info
14 | RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \
15 | -ldflags="-w -s \
16 | -X vcv/internal/version.Version=${VERSION}" \
17 | -o /vcv-backend ./cmd/server
18 |
19 | # Final stage
20 | FROM gcr.io/distroless/static-debian13:nonroot
21 |
22 | WORKDIR /app
23 |
24 | COPY --from=builder --chown=nonroot:nonroot /vcv-backend /app/vcv-backend
25 |
26 | EXPOSE 52000
27 |
28 | CMD ["/app/vcv-backend"]
--------------------------------------------------------------------------------
/app/internal/handlers/health_test.go:
--------------------------------------------------------------------------------
1 | package handlers_test
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "testing"
7 |
8 | "vcv/internal/handlers"
9 | )
10 |
11 | func TestHealthCheck(t *testing.T) {
12 | tests := []struct {
13 | name string
14 | expectedStatus int
15 | }{
16 | {"returns 200 OK", http.StatusOK},
17 | }
18 |
19 | for _, tt := range tests {
20 | t.Run(tt.name, func(t *testing.T) {
21 | req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
22 | rec := httptest.NewRecorder()
23 |
24 | handlers.HealthCheck(rec, req)
25 |
26 | if rec.Code != tt.expectedStatus {
27 | t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code)
28 | }
29 | })
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/internal/handlers/ready_test.go:
--------------------------------------------------------------------------------
1 | package handlers_test
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "testing"
7 |
8 | "vcv/internal/handlers"
9 | )
10 |
11 | func TestReadinessCheck(t *testing.T) {
12 | tests := []struct {
13 | name string
14 | expectedStatus int
15 | }{
16 | {"returns 200 OK", http.StatusOK},
17 | }
18 |
19 | for _, tt := range tests {
20 | t.Run(tt.name, func(t *testing.T) {
21 | req := httptest.NewRequest(http.MethodGet, "/api/ready", nil)
22 | rec := httptest.NewRecorder()
23 |
24 | handlers.ReadinessCheck(rec, req)
25 |
26 | if rec.Code != tt.expectedStatus {
27 | t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code)
28 | }
29 | })
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | #github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
2 | #patreon: # Replace with a single Patreon username
3 | #open_collective: # Replace with a single Open Collective username
4 | ko_fi: julienhmmt
5 | #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
6 | #community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
7 | liberapay: julienhmmt
8 | #issuehunt: # Replace with a single IssueHunt username
9 | #lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
10 | #polar: # Replace with a single Polar username
11 | #buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
12 | #thanks_dev: # Replace with a single thanks.dev username
13 | #custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
--------------------------------------------------------------------------------
/app/cmd/server/web/templates/certs-rows.html:
--------------------------------------------------------------------------------
1 | {{define "certs-rows"}}
2 | {{range .Rows}}
3 |
4 | | {{.CommonName}} |
5 | {{.Sans}} |
6 | {{.CreatedAt}} |
7 |
8 | {{.ExpiresAt}}
9 | {{if .DaysRemainingText}}{{.DaysRemainingText}} {{end}}
10 | |
11 |
12 | {{range .Badges}}{{.Label}}{{end}}
13 | |
14 |
15 |
16 | {{.ButtonDownloadPEM}}
17 | |
18 |
19 | {{end}}
20 | {{end}}
21 |
--------------------------------------------------------------------------------
/app/cmd/server/web/templates/certs-pagination.html:
--------------------------------------------------------------------------------
1 | {{define "certs-pagination"}}
2 |
3 |
4 | {{end}}
5 |
--------------------------------------------------------------------------------
/app/internal/certs/certificate.go:
--------------------------------------------------------------------------------
1 | package certs
2 |
3 | import "time"
4 |
5 | type Certificate struct {
6 | ID string `json:"id"`
7 | CommonName string `json:"commonName"`
8 | Sans []string `json:"sans"`
9 | CreatedAt time.Time `json:"createdAt"`
10 | ExpiresAt time.Time `json:"expiresAt"`
11 | Revoked bool `json:"revoked"`
12 | }
13 |
14 | type DetailedCertificate struct {
15 | Certificate
16 | SerialNumber string `json:"serialNumber"`
17 | Issuer string `json:"issuer"`
18 | Subject string `json:"subject"`
19 | KeyAlgorithm string `json:"keyAlgorithm"`
20 | KeySize int `json:"keySize"`
21 | FingerprintSHA1 string `json:"fingerprintSHA1"`
22 | FingerprintSHA256 string `json:"fingerprintSHA256"`
23 | Usage []string `json:"usage"`
24 | PEM string `json:"pem"`
25 | }
26 |
27 | type PEMResponse struct {
28 | SerialNumber string `json:"serialNumber"`
29 | PEM string `json:"pem"`
30 | }
31 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | name: Lint
4 |
5 | on:
6 | push: null
7 | pull_request: null
8 |
9 | permissions: {}
10 |
11 | jobs:
12 |
13 | super-linter:
14 | name: Super Linter
15 | runs-on: ubuntu-latest
16 | timeout-minutes: 15
17 |
18 | permissions:
19 | contents: read
20 | packages: read
21 | statuses: write
22 |
23 | steps:
24 | - name: Checkout code
25 | uses: actions/checkout@v6
26 | with:
27 | # super-linter needs the full git history to get the
28 | # list of files that changed across commits
29 | fetch-depth: 0
30 |
31 | - name: Super-linter
32 | uses: super-linter/super-linter/slim@v8.3.0
33 | env:
34 | DEFAULT_BRANCH: main
35 | # To report GitHub Actions status checks
36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37 | VALIDATE_ALL_CODEBASE: false
38 | VALIDATE_CSS: true
39 | VALIDATE_MARKDOWN: true
40 | VALIDATE_YAML: true
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 jho
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/internal/vault/mock_client.go:
--------------------------------------------------------------------------------
1 | package vault
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/stretchr/testify/mock"
7 |
8 | "vcv/internal/certs"
9 | )
10 |
11 | // MockClient is a testify mock implementing Client.
12 | type MockClient struct {
13 | mock.Mock
14 | }
15 |
16 | func (m *MockClient) CheckConnection(ctx context.Context) error {
17 | args := m.Called(ctx)
18 | return args.Error(0)
19 | }
20 |
21 | func (m *MockClient) GetCertificateDetails(ctx context.Context, serialNumber string) (certs.DetailedCertificate, error) {
22 | args := m.Called(ctx, serialNumber)
23 | return args.Get(0).(certs.DetailedCertificate), args.Error(1)
24 | }
25 |
26 | func (m *MockClient) GetCertificatePEM(ctx context.Context, serialNumber string) (certs.PEMResponse, error) {
27 | args := m.Called(ctx, serialNumber)
28 | return args.Get(0).(certs.PEMResponse), args.Error(1)
29 | }
30 |
31 | func (m *MockClient) InvalidateCache() {
32 | m.Called()
33 | }
34 |
35 | func (m *MockClient) ListCertificates(ctx context.Context) ([]certs.Certificate, error) {
36 | args := m.Called(ctx)
37 | if list, ok := args.Get(0).([]certs.Certificate); ok {
38 | return list, args.Error(1)
39 | }
40 | return nil, args.Error(1)
41 | }
42 |
43 | func (m *MockClient) Shutdown() {
44 | m.Called()
45 | }
46 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | ---
2 | services:
3 | vcv:
4 | image: jhmmt/vcv:1.3
5 | container_name: vcv
6 | restart: unless-stopped
7 | ports:
8 | - "52000:52000/tcp"
9 | # Choose one of the following:
10 | # - Use .env file
11 | # env_file:
12 | # - .env
13 | # - Use environment variables
14 | environment:
15 | - APP_ENV=prod
16 | - LOG_LEVEL=info
17 | - LOG_FORMAT=json
18 | # Log file path (required if LOG_OUTPUT is 'file' or 'both')
19 | # - LOG_FILE_PATH=/var/log/app/vcv.log
20 | - LOG_OUTPUT=stdout # 'file', 'stdout' or 'both'
21 | - PORT=52000
22 | - VAULT_ADDR=${VAULT_ADDR:?VAULT_ADDR is required}
23 | - VAULT_PKI_MOUNTS=${VAULT_PKI_MOUNTS:-pki,pki2}
24 | - VAULT_READ_TOKEN=${VAULT_READ_TOKEN:?VAULT_READ_TOKEN is required}
25 | - VAULT_TLS_INSECURE=${VAULT_TLS_INSECURE:-false}
26 | # Certificate expiration thresholds (in days)
27 | - VCV_EXPIRE_CRITICAL=${VCV_EXPIRE_CRITICAL:-7}
28 | - VCV_EXPIRE_WARNING=${VCV_EXPIRE_WARNING:-30}
29 | cap_drop:
30 | - ALL
31 | read_only: true
32 | security_opt:
33 | - no-new-privileges:true
34 | # volumes: # only if you have the env var LOG_OUTPUT=file
35 | # - ./vcv.log:/var/log/app/vcv.log
36 | deploy:
37 | resources:
38 | limits:
39 | cpus: '0.50'
40 | memory: 64M
41 |
--------------------------------------------------------------------------------
/app/internal/handlers/config.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 |
7 | "vcv/config"
8 | "vcv/internal/logger"
9 | "vcv/middleware"
10 | )
11 |
12 | // ConfigResponse holds the public configuration exposed to the frontend.
13 | type ConfigResponse struct {
14 | ExpirationThresholds struct {
15 | Critical int `json:"critical"`
16 | Warning int `json:"warning"`
17 | } `json:"expirationThresholds"`
18 | PKIMounts []string `json:"pkiMounts"`
19 | }
20 |
21 | // GetConfig returns the application configuration.
22 | func GetConfig(cfg config.Config) http.HandlerFunc {
23 | return func(w http.ResponseWriter, r *http.Request) {
24 | requestID := middleware.GetRequestID(r.Context())
25 |
26 | resp := ConfigResponse{}
27 | resp.ExpirationThresholds.Critical = cfg.ExpirationThresholds.Critical
28 | resp.ExpirationThresholds.Warning = cfg.ExpirationThresholds.Warning
29 | resp.PKIMounts = cfg.Vault.PKIMounts
30 |
31 | w.Header().Set("Content-Type", "application/json")
32 | w.WriteHeader(http.StatusOK)
33 |
34 | if err := json.NewEncoder(w).Encode(resp); err != nil {
35 | logger.HTTPError(r.Method, r.URL.Path, http.StatusInternalServerError, err).
36 | Str("request_id", requestID).
37 | Msg("failed to encode config response")
38 | return
39 | }
40 |
41 | logger.HTTPEvent(r.Method, r.URL.Path, http.StatusOK, 0).
42 | Str("request_id", requestID).
43 | Msg("config retrieved")
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | name: Go
4 |
5 | on:
6 | push:
7 | branches: [ "*" ]
8 | pull_request:
9 | branches: [ "*" ]
10 |
11 | jobs:
12 | golangci-lint:
13 | name: Go lint
14 | runs-on: ubuntu-latest
15 | timeout-minutes: 10
16 |
17 | permissions:
18 | contents: read
19 | packages: read
20 | statuses: write
21 |
22 | steps:
23 | - name: Checkout code
24 | uses: actions/checkout@v6
25 | with:
26 | fetch-depth: 0
27 |
28 | - name: Set up Go
29 | uses: actions/setup-go@v6
30 | with:
31 | go-version-file: app/go.mod
32 |
33 | - name: Run golangci-lint
34 | uses: golangci/golangci-lint-action@v9
35 | with:
36 | version: latest
37 | working-directory: app
38 | args: -v --timeout=3m
39 |
40 | build:
41 | name: Go build
42 | permissions:
43 | contents: read
44 | runs-on: ubuntu-latest
45 | steps:
46 | - uses: actions/checkout@v6
47 |
48 | - name: Set up Go
49 | uses: actions/setup-go@v6
50 | with:
51 | go-version-file: app/go.mod
52 | cache: true
53 |
54 | - name: Verify dependencies
55 | run: go mod verify
56 | working-directory: app
57 |
58 | - name: Build
59 | run: go build -v ./...
60 | working-directory: app
61 |
62 | - name: Test
63 | run: go test -v ./...
64 | working-directory: app
65 | env:
66 | GO_TEST_ENVIRONMENT: 1
67 | LOG_LEVEL: INFO
68 |
--------------------------------------------------------------------------------
/app/internal/i18n/i18n_test.go:
--------------------------------------------------------------------------------
1 | package i18n
2 |
3 | import "testing"
4 |
5 | func TestMessagesForLanguage(t *testing.T) {
6 | if MessagesForLanguage(LanguageEnglish).AppTitle == "" {
7 | t.Fatalf("expected messages for English")
8 | }
9 | if MessagesForLanguage(LanguageFrench).AppTitle == "" {
10 | t.Fatalf("expected messages for French")
11 | }
12 | unknown := MessagesForLanguage(Language("xx"))
13 | if unknown.AppTitle != englishMessages.AppTitle {
14 | t.Fatalf("expected fallback to English")
15 | }
16 | }
17 |
18 | func TestFromQueryLanguage(t *testing.T) {
19 | tests := []struct {
20 | input string
21 | expected Language
22 | ok bool
23 | }{
24 | {"en", LanguageEnglish, true},
25 | {"fr", LanguageFrench, true},
26 | {"es", LanguageSpanish, true},
27 | {"de", LanguageGerman, true},
28 | {"it", LanguageItalian, true},
29 | {"unknown", "", false},
30 | }
31 | for _, tt := range tests {
32 | t.Run(tt.input, func(t *testing.T) {
33 | got, ok := FromQueryLanguage(tt.input)
34 | if ok != tt.ok || got != tt.expected {
35 | t.Fatalf("expected (%v,%v), got (%v,%v)", tt.expected, tt.ok, got, ok)
36 | }
37 | })
38 | }
39 | }
40 |
41 | func TestFromAcceptLanguage(t *testing.T) {
42 | tests := []struct {
43 | header string
44 | expected Language
45 | }{
46 | {"fr-FR,fr;q=0.9", LanguageFrench},
47 | {"es;q=0.8", LanguageSpanish},
48 | {"de,en;q=0.5", LanguageGerman},
49 | {"it-IT,it;q=0.9", LanguageItalian},
50 | {"", LanguageEnglish},
51 | {"pt-BR, en-US", LanguageEnglish},
52 | }
53 | for _, tt := range tests {
54 | t.Run(tt.header, func(t *testing.T) {
55 | got := FromAcceptLanguage(tt.header)
56 | if got != tt.expected {
57 | t.Fatalf("expected %s, got %s", tt.expected, got)
58 | }
59 | })
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/app/internal/cache/cache.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import (
4 | "sync"
5 | "time"
6 | )
7 |
8 | // CacheEntry represents a cached item with TTL
9 | type CacheEntry struct {
10 | Data any
11 | ExpiresAt time.Time
12 | }
13 |
14 | // Cache provides thread-safe in-memory caching with TTL
15 | type Cache struct {
16 | mu sync.RWMutex
17 | data map[string]*CacheEntry
18 | ttl time.Duration
19 | }
20 |
21 | // New creates a new cache with specified TTL
22 | func New(ttl time.Duration) *Cache {
23 | return &Cache{
24 | data: make(map[string]*CacheEntry),
25 | ttl: ttl,
26 | }
27 | }
28 |
29 | // Get retrieves cached data if not expired
30 | func (c *Cache) Get(key string) (any, bool) {
31 | c.mu.RLock()
32 | defer c.mu.RUnlock()
33 |
34 | entry, exists := c.data[key]
35 | if !exists || time.Now().After(entry.ExpiresAt) {
36 | return nil, false
37 | }
38 | return entry.Data, true
39 | }
40 |
41 | // Set stores data with TTL
42 | func (c *Cache) Set(key string, data any) {
43 | c.mu.Lock()
44 | defer c.mu.Unlock()
45 |
46 | c.data[key] = &CacheEntry{
47 | Data: data,
48 | ExpiresAt: time.Now().Add(c.ttl),
49 | }
50 | }
51 |
52 | // Invalidate removes a specific key from cache
53 | func (c *Cache) Invalidate(key string) {
54 | c.mu.Lock()
55 | defer c.mu.Unlock()
56 | delete(c.data, key)
57 | }
58 |
59 | // Clear removes all cached entries
60 | func (c *Cache) Clear() {
61 | c.mu.Lock()
62 | defer c.mu.Unlock()
63 | c.data = make(map[string]*CacheEntry)
64 | }
65 |
66 | // Cleanup removes expired entries (should be called periodically)
67 | func (c *Cache) Cleanup() {
68 | c.mu.Lock()
69 | defer c.mu.Unlock()
70 |
71 | now := time.Now()
72 | for key, entry := range c.data {
73 | if now.After(entry.ExpiresAt) {
74 | delete(c.data, key)
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/app/internal/handlers/i18n.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | "net/url"
7 |
8 | "vcv/internal/i18n"
9 | "vcv/internal/logger"
10 | "vcv/middleware"
11 |
12 | "github.com/go-chi/chi/v5"
13 | )
14 |
15 | // RegisterI18nRoutes exposes a small JSON API for UI translations.
16 | func RegisterI18nRoutes(router chi.Router) {
17 | router.Get("/api/i18n", func(writer http.ResponseWriter, request *http.Request) {
18 | language := resolveLanguage(request)
19 | payload := i18n.Response{
20 | Language: language,
21 | Messages: i18n.MessagesForLanguage(language),
22 | }
23 | writer.Header().Set("Content-Type", "application/json")
24 | encodeError := json.NewEncoder(writer).Encode(payload)
25 | if encodeError != nil {
26 | requestID := middleware.GetRequestID(request.Context())
27 | logger.HTTPError(request.Method, request.URL.Path, http.StatusInternalServerError, encodeError).
28 | Str("request_id", requestID).
29 | Msg("failed to encode i18n response")
30 | writer.WriteHeader(http.StatusInternalServerError)
31 | }
32 | })
33 | }
34 |
35 | func resolveLanguage(request *http.Request) i18n.Language {
36 | queryLanguage := request.URL.Query().Get("lang")
37 | if queryLanguage != "" {
38 | language, ok := i18n.FromQueryLanguage(queryLanguage)
39 | if ok {
40 | return language
41 | }
42 | }
43 | currentURL := request.Header.Get("HX-Current-URL")
44 | if currentURL != "" {
45 | parsed, err := url.Parse(currentURL)
46 | if err == nil {
47 | headerLanguage := parsed.Query().Get("lang")
48 | if headerLanguage != "" {
49 | language, ok := i18n.FromQueryLanguage(headerLanguage)
50 | if ok {
51 | return language
52 | }
53 | }
54 | }
55 | }
56 | return i18n.FromAcceptLanguage(request.Header.Get("Accept-Language"))
57 | }
58 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile VCV
2 |
3 | .PHONY: help build
4 |
5 | # Couleurs pour l'affichage
6 | BLUE=\033[0;34m
7 | GREEN=\033[0;32m
8 | RED=\033[0;31m
9 | NC=\033[0m # No Color
10 |
11 | help:
12 | @echo "$(BLUE)VCV - Commandes disponibles:$(NC)"
13 | @echo ""
14 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-15s$(NC) %s\n", $$1, $$2}'
15 | @echo ""
16 |
17 | dev: ## Construit le binaire Go et le container docker
18 | @echo "Building binary" && cd /Users/jh/git/vcv && go clean -cache && go build -C app -o ../vcv ./cmd/server
19 | @echo "Binary built successfully"
20 | @echo ""
21 | @echo "Remove old app logs and create new one"
22 | @rm -f vcv.log
23 | @touch vcv.log
24 | @echo "Successfully cleaned app logs"
25 | @echo "Building and running docker container"
26 | docker compose -f docker-compose.dev.yml down
27 | docker buildx build --platform linux/arm64 --load \
28 | --build-arg VERSION=dev \
29 | -t jhmmt/vcv:dev ./app
30 | docker compose -f docker-compose.dev.yml up -d
31 | @echo ""
32 |
33 | docker-build: ## Construit les images docker (arm64 et amd64) et push sur Docker Hub
34 | @echo "Building Docker images for multiple architectures..."
35 | @echo "Usage: make docker-build VCV_TAG=your-tag"
36 | @echo "Default tag: latest"
37 | @docker buildx build \
38 | --platform linux/amd64,linux/arm64 \
39 | --build-arg VERSION=$(VCV_TAG) \
40 | -t jhmmt/vcv:$(or $(VCV_TAG),latest) \
41 | --push \
42 | ./app
43 |
44 | test-offline: ## Run unit tests offline (no Vault) with coverage
45 | cd app && go test ./... -count=1 -coverprofile=coverage.out -covermode=atomic 2>&1 && go tool cover -func=coverage.out
46 |
47 | test-dev: ## Run tests against dev stack (docker-compose)
48 | cd app && VAULT_ADDR=http://localhost:8200 VAULT_TOKEN=root go test ./... -count=1 -coverprofile=coverage.out -covermode=atomic 2>&1 && go tool cover -func=coverage.out
49 |
--------------------------------------------------------------------------------
/app/cmd/server/web/templates/certs-sort.html:
--------------------------------------------------------------------------------
1 | {{define "certs-sort"}}
2 |
6 |
10 |
14 | {{end}}
15 |
--------------------------------------------------------------------------------
/app/go.mod:
--------------------------------------------------------------------------------
1 | module vcv
2 |
3 | go 1.25.4
4 |
5 | require (
6 | github.com/go-chi/chi/v5 v5.2.3
7 | github.com/hashicorp/vault/api v1.22.0
8 | github.com/joho/godotenv v1.5.1
9 | github.com/prometheus/client_golang v1.23.2
10 | github.com/prometheus/client_model v0.6.2
11 | github.com/rs/zerolog v1.34.0
12 | github.com/stretchr/testify v1.11.1
13 | )
14 |
15 | require (
16 | github.com/beorn7/perks v1.0.1 // indirect
17 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect
18 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
19 | github.com/davecgh/go-spew v1.1.1 // indirect
20 | github.com/go-jose/go-jose/v4 v4.1.3 // indirect
21 | github.com/hashicorp/errwrap v1.1.0 // indirect
22 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
23 | github.com/hashicorp/go-multierror v1.1.1 // indirect
24 | github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
25 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect
26 | github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
27 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
28 | github.com/hashicorp/go-sockaddr v1.0.7 // indirect
29 | github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
30 | github.com/kr/text v0.2.0 // indirect
31 | github.com/kylelemons/godebug v1.1.0 // indirect
32 | github.com/mattn/go-colorable v0.1.14 // indirect
33 | github.com/mattn/go-isatty v0.0.20 // indirect
34 | github.com/mitchellh/go-homedir v1.1.0 // indirect
35 | github.com/mitchellh/mapstructure v1.5.0 // indirect
36 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
37 | github.com/pmezard/go-difflib v1.0.0 // indirect
38 | github.com/prometheus/common v0.67.4 // indirect
39 | github.com/prometheus/procfs v0.19.2 // indirect
40 | github.com/ryanuber/go-glob v1.0.0 // indirect
41 | github.com/stretchr/objx v0.5.3 // indirect
42 | go.yaml.in/yaml/v2 v2.4.3 // indirect
43 | golang.org/x/net v0.48.0 // indirect
44 | golang.org/x/sys v0.39.0 // indirect
45 | golang.org/x/text v0.32.0 // indirect
46 | golang.org/x/time v0.14.0 // indirect
47 | google.golang.org/protobuf v1.36.11 // indirect
48 | gopkg.in/yaml.v3 v3.0.1 // indirect
49 | )
50 |
--------------------------------------------------------------------------------
/app/config/config_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os"
5 | "testing"
6 | )
7 |
8 | func TestLoadDefaults(t *testing.T) {
9 | clearEnv(t)
10 | cfg := Load()
11 | if cfg.Port == "" {
12 | t.Fatalf("expected default port, got empty")
13 | }
14 | if cfg.Env != EnvDev {
15 | t.Fatalf("expected dev env by default, got %s", cfg.Env)
16 | }
17 | if cfg.LogLevel == "" || cfg.LogFormat == "" {
18 | t.Fatalf("expected log defaults")
19 | }
20 | }
21 |
22 | func TestLoadFromEnv(t *testing.T) {
23 | clearEnv(t)
24 | _ = os.Setenv("APP_ENV", "prod")
25 | _ = os.Setenv("PORT", "1234")
26 | _ = os.Setenv("LOG_LEVEL", "warn")
27 | _ = os.Setenv("LOG_FORMAT", "json")
28 | _ = os.Setenv("LOG_OUTPUT", "stdout")
29 | _ = os.Setenv("VAULT_ADDR", "http://vault")
30 | _ = os.Setenv("VAULT_PKI_MOUNT", "pki")
31 | _ = os.Setenv("VAULT_READ_TOKEN", "token")
32 | _ = os.Setenv("VAULT_TLS_INSECURE", "true")
33 | _ = os.Setenv("CORS_ALLOWED_ORIGINS", "http://example.com")
34 | _ = os.Setenv("CORS_ALLOW_CREDENTIALS", "true")
35 |
36 | cfg := Load()
37 |
38 | if cfg.Env != EnvProd {
39 | t.Fatalf("expected prod env, got %s", cfg.Env)
40 | }
41 | if cfg.Port != "1234" {
42 | t.Fatalf("expected port 1234, got %s", cfg.Port)
43 | }
44 | if cfg.LogLevel != "warn" || cfg.LogFormat != "json" || cfg.LogOutput != "stdout" {
45 | t.Fatalf("expected log env values to be applied")
46 | }
47 | if cfg.Vault.Addr != "http://vault" || len(cfg.Vault.PKIMounts) != 1 || cfg.Vault.PKIMounts[0] != "pki" || cfg.Vault.ReadToken != "token" || !cfg.Vault.TLSInsecure {
48 | t.Fatalf("expected vault env values to be applied")
49 | }
50 | if len(cfg.CORS.AllowedOrigins) != 1 || cfg.CORS.AllowedOrigins[0] != "http://example.com" || !cfg.CORS.AllowCredentials {
51 | t.Fatalf("expected cors env values to be applied")
52 | }
53 | }
54 |
55 | func clearEnv(t *testing.T) {
56 | t.Helper()
57 | envs := []string{
58 | "APP_ENV", "PORT", "LOG_LEVEL", "LOG_FORMAT", "LOG_OUTPUT",
59 | "VAULT_ADDR", "VAULT_PKI_MOUNT", "VAULT_READ_TOKEN", "VAULT_TLS_INSECURE",
60 | "CORS_ALLOWED_ORIGINS", "CORS_ALLOW_CREDENTIALS",
61 | }
62 | for _, key := range envs {
63 | _ = os.Unsetenv(key)
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/app/internal/cache/cache_test.go:
--------------------------------------------------------------------------------
1 | package cache_test
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "vcv/internal/cache"
8 | )
9 |
10 | func TestCache_SetAndGet(t *testing.T) {
11 | c := cache.New(1 * time.Minute)
12 |
13 | c.Set("key1", "value1")
14 |
15 | got, found := c.Get("key1")
16 | if !found {
17 | t.Error("expected key1 to be found")
18 | }
19 | if got != "value1" {
20 | t.Errorf("expected value1, got %v", got)
21 | }
22 | }
23 |
24 | func TestCache_GetMissing(t *testing.T) {
25 | c := cache.New(1 * time.Minute)
26 |
27 | _, found := c.Get("nonexistent")
28 | if found {
29 | t.Error("expected nonexistent key to not be found")
30 | }
31 | }
32 |
33 | func TestCache_Expiration(t *testing.T) {
34 | c := cache.New(10 * time.Millisecond)
35 |
36 | c.Set("key1", "value1")
37 |
38 | // Wait for expiration
39 | time.Sleep(20 * time.Millisecond)
40 |
41 | _, found := c.Get("key1")
42 | if found {
43 | t.Error("expected key1 to be expired")
44 | }
45 | }
46 |
47 | func TestCache_Invalidate(t *testing.T) {
48 | c := cache.New(1 * time.Minute)
49 |
50 | c.Set("key1", "value1")
51 | c.Invalidate("key1")
52 |
53 | _, found := c.Get("key1")
54 | if found {
55 | t.Error("expected key1 to be invalidated")
56 | }
57 | }
58 |
59 | func TestCache_Clear(t *testing.T) {
60 | c := cache.New(1 * time.Minute)
61 |
62 | c.Set("key1", "value1")
63 | c.Set("key2", "value2")
64 | c.Clear()
65 |
66 | _, found1 := c.Get("key1")
67 | _, found2 := c.Get("key2")
68 | if found1 || found2 {
69 | t.Error("expected all keys to be cleared")
70 | }
71 | }
72 |
73 | func TestCache_Cleanup(t *testing.T) {
74 | c := cache.New(10 * time.Millisecond)
75 |
76 | c.Set("key1", "value1")
77 | c.Set("key2", "value2")
78 |
79 | // Wait for expiration
80 | time.Sleep(20 * time.Millisecond)
81 |
82 | // Add a fresh key
83 | c.Set("key3", "value3")
84 |
85 | // Cleanup should remove expired entries
86 | c.Cleanup()
87 |
88 | _, found1 := c.Get("key1")
89 | _, found2 := c.Get("key2")
90 | _, found3 := c.Get("key3")
91 |
92 | if found1 || found2 {
93 | t.Error("expected expired keys to be cleaned up")
94 | }
95 | if !found3 {
96 | t.Error("expected fresh key to remain after cleanup")
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/app/internal/i18n/i18n_notification_test.go:
--------------------------------------------------------------------------------
1 | package i18n
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestNotificationMessagesHaveThresholdPlaceholder(t *testing.T) {
8 | tests := []struct {
9 | name string
10 | message string
11 | }{
12 | {
13 | name: "English critical notification",
14 | message: MessagesForLanguage(LanguageEnglish).NotificationCritical,
15 | },
16 | {
17 | name: "English warning notification",
18 | message: MessagesForLanguage(LanguageEnglish).NotificationWarning,
19 | },
20 | {
21 | name: "French critical notification",
22 | message: MessagesForLanguage(LanguageFrench).NotificationCritical,
23 | },
24 | {
25 | name: "French warning notification",
26 | message: MessagesForLanguage(LanguageFrench).NotificationWarning,
27 | },
28 | {
29 | name: "Spanish critical notification",
30 | message: MessagesForLanguage(LanguageSpanish).NotificationCritical,
31 | },
32 | {
33 | name: "Spanish warning notification",
34 | message: MessagesForLanguage(LanguageSpanish).NotificationWarning,
35 | },
36 | {
37 | name: "German critical notification",
38 | message: MessagesForLanguage(LanguageGerman).NotificationCritical,
39 | },
40 | {
41 | name: "German warning notification",
42 | message: MessagesForLanguage(LanguageGerman).NotificationWarning,
43 | },
44 | {
45 | name: "Italian critical notification",
46 | message: MessagesForLanguage(LanguageItalian).NotificationCritical,
47 | },
48 | {
49 | name: "Italian warning notification",
50 | message: MessagesForLanguage(LanguageItalian).NotificationWarning,
51 | },
52 | }
53 |
54 | for _, tt := range tests {
55 | t.Run(tt.name, func(t *testing.T) {
56 | if !contains(tt.message, "{{threshold}}") {
57 | t.Errorf("expected message to contain {{threshold}} placeholder, got: %s", tt.message)
58 | }
59 | if !contains(tt.message, "{{count}}") {
60 | t.Errorf("expected message to contain {{count}} placeholder, got: %s", tt.message)
61 | }
62 | })
63 | }
64 | }
65 |
66 | func contains(s, substr string) bool {
67 | for i := 0; i < len(s)-len(substr)+1; i++ {
68 | if s[i:i+len(substr)] == substr {
69 | return true
70 | }
71 | }
72 | return false
73 | }
74 |
--------------------------------------------------------------------------------
/app/cmd/server/web/templates/cert-details.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
{{.Messages.ColumnStatus}}
6 |
7 | {{range .Badges}}{{.Label}}{{end}}
8 |
9 |
10 |
11 |
{{.Messages.LabelSerialNumber}}
12 |
{{.Certificate.SerialNumber}}
13 |
14 |
15 |
{{.Messages.LabelSubject}}
16 |
{{.Certificate.Subject}}
17 |
18 |
19 |
{{.Messages.LabelIssuer}}
20 |
{{.Certificate.Issuer}}
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
{{.Messages.LabelKeyAlgorithm}}
29 |
{{.KeySummary}}
30 |
31 |
32 |
{{.Messages.LabelFingerprintSHA1}}
33 |
{{.Certificate.FingerprintSHA1}}
34 |
35 |
36 |
{{.Messages.LabelFingerprintSHA256}}
37 |
{{.Certificate.FingerprintSHA256}}
38 |
39 |
40 |
{{.Messages.LabelUsage}}
41 |
{{.UsageSummary}}
42 |
43 |
44 |
45 |
53 |
--------------------------------------------------------------------------------
/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | ---
2 | # Multi-Vault development environment for VCV testing
3 | # Currently VCV connects only to vault:8200 (single Vault instance)
4 | # Additional Vaults (vault-dev-2, vault-dev-3) are infrastructure prep
5 | # for future multi-Vault support when VCV supports VAULT_ADDRS or similar
6 | services:
7 | vault:
8 | image: hashicorp/vault:1.21.1
9 | container_name: vault
10 | ports:
11 | - "8200:8200/tcp"
12 | environment:
13 | - VAULT_DEV_ROOT_TOKEN_ID=root
14 | - VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200
15 | cap_add:
16 | - IPC_LOCK
17 | # command: "server -dev -dev-root-token-id=root -dev-listen-address=0.0.0.0:8200"
18 | command: "/vault-dev-init.sh"
19 | networks:
20 | - vcv-network
21 | volumes:
22 | - ./vault-dev-init.sh:/vault-dev-init.sh:ro
23 |
24 | vault-dev-2:
25 | image: hashicorp/vault:1.21.1
26 | container_name: vault-dev-2
27 | ports:
28 | - "8201:8200/tcp"
29 | environment:
30 | - VAULT_DEV_ROOT_TOKEN_ID=root
31 | - VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200
32 | cap_add:
33 | - IPC_LOCK
34 | command: "/vault-dev-init-2.sh"
35 | networks:
36 | - vcv-network
37 | volumes:
38 | - ./vault-dev-init-2.sh:/vault-dev-init-2.sh:ro
39 |
40 | vault-dev-3:
41 | image: hashicorp/vault:1.21.1
42 | container_name: vault-dev-3
43 | ports:
44 | - "8202:8200/tcp"
45 | environment:
46 | - VAULT_DEV_ROOT_TOKEN_ID=root
47 | - VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200
48 | cap_add:
49 | - IPC_LOCK
50 | command: "/vault-dev-init-3.sh"
51 | networks:
52 | - vcv-network
53 | volumes:
54 | - ./vault-dev-init-3.sh:/vault-dev-init-3.sh:ro
55 |
56 | app:
57 | # build:
58 | # context: ./app
59 | # dockerfile: Dockerfile
60 | image: jhmmt/vcv:dev
61 | container_name: vcv
62 | ports:
63 | - "52000:52000/tcp"
64 | networks:
65 | - vcv-network
66 | environment:
67 | - APP_ENV=dev
68 | - LOG_LEVEL=debug
69 | - LOG_FORMAT=json
70 | # Log file path (required if LOG_OUTPUT is 'file' or 'both')
71 | - LOG_FILE_PATH=/var/log/app/vcv.log
72 | - LOG_OUTPUT=both # 'file', 'stdout' or 'both'
73 | - PORT=52000
74 | - VAULT_ADDR=http://vault:8200
75 | - VAULT_PKI_MOUNTS=pki,pki_dev,pki_stage,pki_production
76 | - VAULT_READ_TOKEN=root
77 | - VAULT_TLS_INSECURE=true
78 | # Certificate expiration thresholds (in days)
79 | - VCV_EXPIRE_CRITICAL=2
80 | - VCV_EXPIRE_WARNING=10
81 | depends_on:
82 | - vault
83 | read_only: true
84 | security_opt:
85 | - no-new-privileges:true
86 | volumes:
87 | - ./vcv.log:/var/log/app/vcv.log
88 | deploy:
89 | resources:
90 | limits:
91 | cpus: '0.50'
92 | memory: 64M
93 |
94 | networks:
95 | vcv-network:
96 | driver: bridge
97 |
--------------------------------------------------------------------------------
/vcv-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Namespace
3 | metadata:
4 | name: vcv
5 | ---
6 | apiVersion: v1
7 | kind: ServiceAccount
8 | metadata:
9 | name: vcv-sa
10 | namespace: vcv
11 | ---
12 | apiVersion: apps/v1
13 | kind: Deployment
14 | metadata:
15 | name: vcv
16 | namespace: vcv
17 | labels:
18 | app: vcv
19 | spec:
20 | replicas: 1
21 | selector:
22 | matchLabels:
23 | app: vcv
24 | template:
25 | metadata:
26 | labels:
27 | app: vcv
28 | spec:
29 | serviceAccountName: vcv-sa
30 | securityContext:
31 | runAsNonRoot: true
32 | runAsUser: 1000
33 | fsGroup: 1000
34 | containers:
35 | - name: vcv
36 | image: jhmmt/vcv:1.3
37 | imagePullPolicy: IfNotPresent
38 | ports:
39 | - name: http
40 | containerPort: 52000
41 | protocol: TCP
42 | env:
43 | - name: APP_ENV
44 | value: "prod"
45 | - name: LOG_LEVEL
46 | value: "info"
47 | - name: LOG_FORMAT
48 | value: "json"
49 | - name: LOG_OUTPUT
50 | value: "stdout"
51 | - name: PORT
52 | value: "52000"
53 | - name: VAULT_ADDR
54 | value: "http://vault:8200"
55 | - name: VAULT_PKI_MOUNTS
56 | value: "pki,pki2"
57 | - name: VAULT_READ_TOKEN
58 | valueFrom:
59 | secretKeyRef:
60 | name: vcv-vault-token
61 | key: VAULT_READ_TOKEN
62 | - name: VAULT_TLS_INSECURE
63 | value: "false"
64 | - name: VCV_EXPIRE_CRITICAL
65 | value: "7"
66 | - name: VCV_EXPIRE_WARNING
67 | value: "30"
68 | resources:
69 | requests:
70 | cpu: "100m"
71 | memory: "64Mi"
72 | limits:
73 | cpu: "500m"
74 | memory: "128Mi"
75 | readinessProbe:
76 | httpGet:
77 | path: /api/ready
78 | port: http
79 | initialDelaySeconds: 5
80 | periodSeconds: 10
81 | livenessProbe:
82 | httpGet:
83 | path: /api/health
84 | port: http
85 | initialDelaySeconds: 10
86 | periodSeconds: 20
87 | securityContext:
88 | readOnlyRootFilesystem: true
89 | allowPrivilegeEscalation: false
90 | ---
91 | apiVersion: v1
92 | kind: Secret
93 | metadata:
94 | name: vcv-vault-token
95 | namespace: vcv
96 | type: Opaque
97 | stringData:
98 | VAULT_READ_TOKEN: "root"
99 | ---
100 | apiVersion: v1
101 | kind: Service
102 | metadata:
103 | name: vcv
104 | namespace: vcv
105 | labels:
106 | app: vcv
107 | spec:
108 | selector:
109 | app: vcv
110 | ports:
111 | - name: http
112 | port: 52000
113 | targetPort: http
114 | protocol: TCP
115 | type: ClusterIP
116 |
--------------------------------------------------------------------------------
/app/cmd/server/web/templates/dashboard-fragment.html:
--------------------------------------------------------------------------------
1 | {{define "dashboard-fragment"}}
2 | {{.DashboardTotal}}
3 | {{.DashboardValid}}
4 | {{.DashboardExpiring}}
5 | {{.DashboardExpired}}
6 |
7 |
8 | {{if not .ChartHasData}}
9 |
{{.Messages.NoData}}
10 | {{else}}
11 |
12 |
24 |
25 |
{{.ChartTotal}}
26 |
TOTAL
27 |
28 |
29 |
30 |
31 |
32 | {{.Messages.ChartLegendValid}} ({{.ChartValid}})
33 |
34 |
35 |
36 | {{.Messages.ChartLegendExpired}} ({{.ChartExpired}})
37 |
38 |
39 |
40 | {{.Messages.ChartLegendRevoked}} ({{.ChartRevoked}})
41 |
42 | {{if gt .DualStatusCount 0}}
43 |
{{.DualStatusNoteText}}
44 | {{end}}
45 |
46 | {{end}}
47 |
48 |
49 |
50 | {{if eq (len .TimelineItems) 0}}
51 |
{{.Messages.NoCertsExpiringSoon}}
52 | {{else}}
53 | {{range .TimelineItems}}
54 |
55 |
56 |
{{.Name}}
57 |
{{.DaysLabel}}
58 |
59 | {{end}}
60 | {{end}}
61 |
62 | {{end}}
63 |
--------------------------------------------------------------------------------
/app/internal/handlers/config_test.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "net/http"
7 | "net/http/httptest"
8 | "testing"
9 |
10 | "vcv/config"
11 | )
12 |
13 | // failingResponseWriter is a ResponseWriter that always fails on Write
14 | type failingResponseWriter struct {
15 | header http.Header
16 | }
17 |
18 | func (w *failingResponseWriter) Header() http.Header {
19 | if w.header == nil {
20 | w.header = make(http.Header)
21 | }
22 | return w.header
23 | }
24 |
25 | func (w *failingResponseWriter) Write([]byte) (int, error) {
26 | return 0, errors.New("write failed")
27 | }
28 |
29 | func (w *failingResponseWriter) WriteHeader(statusCode int) {
30 | }
31 |
32 | func TestGetConfig_Success(t *testing.T) {
33 | cfg := config.Config{
34 | ExpirationThresholds: config.ExpirationThresholds{
35 | Critical: 7,
36 | Warning: 30,
37 | },
38 | }
39 |
40 | handler := GetConfig(cfg)
41 | req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
42 | w := httptest.NewRecorder()
43 |
44 | handler(w, req)
45 |
46 | if w.Code != http.StatusOK {
47 | t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
48 | }
49 |
50 | if ct := w.Header().Get("Content-Type"); ct != "application/json" {
51 | t.Errorf("expected Content-Type application/json, got %s", ct)
52 | }
53 |
54 | var resp ConfigResponse
55 | if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
56 | t.Fatalf("failed to decode response: %v", err)
57 | }
58 |
59 | if resp.ExpirationThresholds.Critical != 7 {
60 | t.Errorf("expected critical threshold 7, got %d", resp.ExpirationThresholds.Critical)
61 | }
62 | if resp.ExpirationThresholds.Warning != 30 {
63 | t.Errorf("expected warning threshold 30, got %d", resp.ExpirationThresholds.Warning)
64 | }
65 | }
66 |
67 | func TestGetConfig_CustomValues(t *testing.T) {
68 | cfg := config.Config{
69 | ExpirationThresholds: config.ExpirationThresholds{
70 | Critical: 14,
71 | Warning: 60,
72 | },
73 | }
74 |
75 | handler := GetConfig(cfg)
76 | req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
77 | w := httptest.NewRecorder()
78 |
79 | handler(w, req)
80 |
81 | if w.Code != http.StatusOK {
82 | t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
83 | }
84 |
85 | var resp ConfigResponse
86 | if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
87 | t.Fatalf("failed to decode response: %v", err)
88 | }
89 |
90 | if resp.ExpirationThresholds.Critical != 14 {
91 | t.Errorf("expected critical threshold 14, got %d", resp.ExpirationThresholds.Critical)
92 | }
93 | if resp.ExpirationThresholds.Warning != 60 {
94 | t.Errorf("expected warning threshold 60, got %d", resp.ExpirationThresholds.Warning)
95 | }
96 | }
97 |
98 | func TestGetConfig_EncodingError(t *testing.T) {
99 | cfg := config.Config{
100 | ExpirationThresholds: config.ExpirationThresholds{Critical: 7, Warning: 30},
101 | Vault: config.VaultConfig{PKIMounts: []string{"pki"}},
102 | }
103 |
104 | handler := GetConfig(cfg)
105 |
106 | // Create a response writer that will fail on write
107 | w := &failingResponseWriter{}
108 | req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
109 |
110 | // This should not panic
111 | handler(w, req)
112 | }
113 |
--------------------------------------------------------------------------------
/app/config/config_expiration_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os"
5 | "testing"
6 | )
7 |
8 | func TestLoadExpirationThresholds_Defaults(t *testing.T) {
9 | // Clear environment variables
10 | if err := os.Unsetenv("VCV_EXPIRE_CRITICAL"); err != nil {
11 | t.Fatalf("failed to unset VCV_EXPIRE_CRITICAL: %v", err)
12 | }
13 | if err := os.Unsetenv("VCV_EXPIRE_WARNING"); err != nil {
14 | t.Fatalf("failed to unset VCV_EXPIRE_WARNING: %v", err)
15 | }
16 |
17 | thresholds := loadExpirationThresholds()
18 |
19 | if thresholds.Critical != 7 {
20 | t.Errorf("expected default critical 7, got %d", thresholds.Critical)
21 | }
22 | if thresholds.Warning != 30 {
23 | t.Errorf("expected default warning 30, got %d", thresholds.Warning)
24 | }
25 | }
26 |
27 | func TestLoadExpirationThresholds_CustomValues(t *testing.T) {
28 | if err := os.Setenv("VCV_EXPIRE_CRITICAL", "14"); err != nil {
29 | t.Fatalf("failed to set VCV_EXPIRE_CRITICAL: %v", err)
30 | }
31 | if err := os.Setenv("VCV_EXPIRE_WARNING", "60"); err != nil {
32 | t.Fatalf("failed to set VCV_EXPIRE_WARNING: %v", err)
33 | }
34 | defer func() {
35 | if err := os.Unsetenv("VCV_EXPIRE_CRITICAL"); err != nil {
36 | t.Fatalf("failed to unset VCV_EXPIRE_CRITICAL: %v", err)
37 | }
38 | if err := os.Unsetenv("VCV_EXPIRE_WARNING"); err != nil {
39 | t.Fatalf("failed to unset VCV_EXPIRE_WARNING: %v", err)
40 | }
41 | }()
42 |
43 | thresholds := loadExpirationThresholds()
44 |
45 | if thresholds.Critical != 14 {
46 | t.Errorf("expected critical 14, got %d", thresholds.Critical)
47 | }
48 | if thresholds.Warning != 60 {
49 | t.Errorf("expected warning 60, got %d", thresholds.Warning)
50 | }
51 | }
52 |
53 | func TestLoadExpirationThresholds_InvalidValues(t *testing.T) {
54 | if err := os.Setenv("VCV_EXPIRE_CRITICAL", "invalid"); err != nil {
55 | t.Fatalf("failed to set VCV_EXPIRE_CRITICAL: %v", err)
56 | }
57 | if err := os.Setenv("VCV_EXPIRE_WARNING", "not_a_number"); err != nil {
58 | t.Fatalf("failed to set VCV_EXPIRE_WARNING: %v", err)
59 | }
60 | defer func() {
61 | if err := os.Unsetenv("VCV_EXPIRE_CRITICAL"); err != nil {
62 | t.Fatalf("failed to unset VCV_EXPIRE_CRITICAL: %v", err)
63 | }
64 | if err := os.Unsetenv("VCV_EXPIRE_WARNING"); err != nil {
65 | t.Fatalf("failed to unset VCV_EXPIRE_WARNING: %v", err)
66 | }
67 | }()
68 |
69 | thresholds := loadExpirationThresholds()
70 |
71 | // Should fall back to defaults on invalid input
72 | if thresholds.Critical != 7 {
73 | t.Errorf("expected default critical 7 on invalid input, got %d", thresholds.Critical)
74 | }
75 | if thresholds.Warning != 30 {
76 | t.Errorf("expected default warning 30 on invalid input, got %d", thresholds.Warning)
77 | }
78 | }
79 |
80 | func TestLoadExpirationThresholds_PartialCustom(t *testing.T) {
81 | if err := os.Setenv("VCV_EXPIRE_CRITICAL", "21"); err != nil {
82 | t.Fatalf("failed to set VCV_EXPIRE_CRITICAL: %v", err)
83 | }
84 | if err := os.Unsetenv("VCV_EXPIRE_WARNING"); err != nil {
85 | t.Fatalf("failed to unset VCV_EXPIRE_WARNING: %v", err)
86 | }
87 | defer func() {
88 | if err := os.Unsetenv("VCV_EXPIRE_CRITICAL"); err != nil {
89 | t.Fatalf("failed to unset VCV_EXPIRE_CRITICAL: %v", err)
90 | }
91 | }()
92 |
93 | thresholds := loadExpirationThresholds()
94 |
95 | if thresholds.Critical != 21 {
96 | t.Errorf("expected critical 21, got %d", thresholds.Critical)
97 | }
98 | if thresholds.Warning != 30 {
99 | t.Errorf("expected default warning 30, got %d", thresholds.Warning)
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/app/internal/handlers/certs_util_test.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "testing"
5 |
6 | "vcv/internal/certs"
7 | )
8 |
9 | func TestFilterCertificatesByMounts(t *testing.T) {
10 | testCertificates := []certs.Certificate{
11 | {ID: "pki:01", CommonName: "test1.example.com"},
12 | {ID: "pki:02", CommonName: "test2.example.com"},
13 | {ID: "custom-pki:01", CommonName: "custom.example.com"},
14 | {ID: "other:01", CommonName: "other.example.com"},
15 | }
16 |
17 | tests := []struct {
18 | name string
19 | certificates []certs.Certificate
20 | selectedMounts []string
21 | expected []certs.Certificate
22 | }{
23 | {
24 | name: "nil selected mounts returns all",
25 | certificates: testCertificates,
26 | selectedMounts: nil,
27 | expected: testCertificates,
28 | },
29 | {
30 | name: "empty selected mounts returns empty",
31 | certificates: testCertificates,
32 | selectedMounts: []string{},
33 | expected: []certs.Certificate{},
34 | },
35 | {
36 | name: "filter by single mount",
37 | certificates: testCertificates,
38 | selectedMounts: []string{"pki"},
39 | expected: []certs.Certificate{
40 | {ID: "pki:01", CommonName: "test1.example.com"},
41 | {ID: "pki:02", CommonName: "test2.example.com"},
42 | },
43 | },
44 | {
45 | name: "filter by multiple mounts",
46 | certificates: testCertificates,
47 | selectedMounts: []string{"pki", "custom-pki"},
48 | expected: []certs.Certificate{
49 | {ID: "pki:01", CommonName: "test1.example.com"},
50 | {ID: "pki:02", CommonName: "test2.example.com"},
51 | {ID: "custom-pki:01", CommonName: "custom.example.com"},
52 | },
53 | },
54 | {
55 | name: "filter by non-existent mount",
56 | certificates: testCertificates,
57 | selectedMounts: []string{"nonexistent"},
58 | expected: []certs.Certificate{},
59 | },
60 | {
61 | name: "certificate without colon",
62 | certificates: []certs.Certificate{{ID: "invalid", CommonName: "invalid.example.com"}},
63 | selectedMounts: []string{"pki"},
64 | expected: []certs.Certificate{},
65 | },
66 | {
67 | name: "empty certificate list",
68 | certificates: []certs.Certificate{},
69 | selectedMounts: []string{"pki"},
70 | expected: []certs.Certificate{},
71 | },
72 | }
73 |
74 | for _, tt := range tests {
75 | t.Run(tt.name, func(t *testing.T) {
76 | result := filterCertificatesByMounts(tt.certificates, tt.selectedMounts)
77 |
78 | if len(result) != len(tt.expected) {
79 | t.Errorf("expected %d certificates, got %d", len(tt.expected), len(result))
80 | }
81 |
82 | for i := range result {
83 | if result[i].ID != tt.expected[i].ID {
84 | t.Errorf("expected certificate ID %q at index %d, got %q", tt.expected[i].ID, i, result[i].ID)
85 | }
86 | }
87 | })
88 | }
89 | }
90 |
91 | func TestBuildPEMDownloadFilename(t *testing.T) {
92 | tests := []struct {
93 | name string
94 | serial string
95 | expected string
96 | }{
97 | {
98 | name: "normal serial number",
99 | serial: "01:23:45:67",
100 | expected: "certificate-01-23-45-67.pem",
101 | },
102 | {
103 | name: "serial with slashes",
104 | serial: "01/23/45",
105 | expected: "certificate-01-23-45.pem",
106 | },
107 | {
108 | name: "serial with backslashes",
109 | serial: "01\\23\\45",
110 | expected: "certificate-01-23-45.pem",
111 | },
112 | {
113 | name: "serial with double dots",
114 | serial: "01..45",
115 | expected: "certificate-01-45.pem",
116 | },
117 | {
118 | name: "empty serial",
119 | serial: "",
120 | expected: "certificate.pem",
121 | },
122 | {
123 | name: "only special characters",
124 | serial: ":/\\..",
125 | expected: "certificate-----.pem",
126 | },
127 | {
128 | name: "mixed special characters",
129 | serial: "01:23/45\\67..89",
130 | expected: "certificate-01-23-45-67-89.pem",
131 | },
132 | {
133 | name: "single serial number",
134 | serial: "01",
135 | expected: "certificate-01.pem",
136 | },
137 | }
138 |
139 | for _, tt := range tests {
140 | t.Run(tt.name, func(t *testing.T) {
141 | result := buildPEMDownloadFilename(tt.serial)
142 | if result != tt.expected {
143 | t.Errorf("expected %q, got %q", tt.expected, result)
144 | }
145 | })
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/app/internal/metrics/certificate_collector_test.go:
--------------------------------------------------------------------------------
1 | package metrics
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/prometheus/client_golang/prometheus"
8 | "github.com/prometheus/client_golang/prometheus/testutil"
9 | dto "github.com/prometheus/client_model/go"
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/mock"
12 | "github.com/stretchr/testify/require"
13 |
14 | "vcv/internal/certs"
15 | "vcv/internal/vault"
16 | )
17 |
18 | func TestCollector_ErrorStopsCollection(t *testing.T) {
19 | mockVault := new(vault.MockClient)
20 | mockVault.On("ListCertificates", mock.Anything).Return([]certs.Certificate{}, assert.AnError)
21 |
22 | registry := prometheus.NewRegistry()
23 | collector := NewCertificateCollector(mockVault)
24 | require.NoError(t, registry.Register(collector))
25 |
26 | metricsCount := testutil.CollectAndCount(collector)
27 | assert.Greater(t, metricsCount, 0)
28 |
29 | // Only last_scrape_success should be emitted with value 0
30 | value, err := gatherGauge(registry, "vcv_certificate_exporter_last_scrape_success", nil)
31 | require.NoError(t, err)
32 | assert.Equal(t, 0.0, value)
33 |
34 | mockVault.AssertExpectations(t)
35 | }
36 |
37 | func TestCollector_SuccessMetrics(t *testing.T) {
38 | now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
39 | certsList := []certs.Certificate{
40 | {ID: "active-soon", CommonName: "soon", ExpiresAt: now.Add(10 * 24 * time.Hour), Revoked: false},
41 | {ID: "active-later", CommonName: "later", ExpiresAt: now.Add(90 * 24 * time.Hour), Revoked: false},
42 | {ID: "revoked", CommonName: "rev", ExpiresAt: now.Add(20 * 24 * time.Hour), Revoked: true},
43 | {ID: "expired", CommonName: "old", ExpiresAt: now.Add(-24 * time.Hour), Revoked: false},
44 | }
45 |
46 | mockVault := new(vault.MockClient)
47 | mockVault.On("ListCertificates", mock.Anything).Return(certsList, nil)
48 | mockVault.On("CheckConnection", mock.Anything).Return(nil)
49 |
50 | registry := prometheus.NewRegistry()
51 | rawCollector := NewCertificateCollector(mockVault)
52 | collector, ok := rawCollector.(*certificateCollector)
53 | require.True(t, ok)
54 | collector.now = func() time.Time { return now }
55 | require.NoError(t, registry.Register(collector))
56 |
57 | totalMetrics := testutil.CollectAndCount(collector)
58 | assert.GreaterOrEqual(t, totalMetrics, 5)
59 |
60 | assertGauge(t, registry, "vcv_certificate_exporter_last_scrape_success", nil, 1.0)
61 | assertGauge(t, registry, "vcv_vault_connected", nil, 1.0)
62 | assertGauge(t, registry, "vcv_certificates_expired_count", nil, 1.0)
63 | assertGauge(t, registry, "vcv_certificates_total", map[string]string{"status": "active"}, 3.0)
64 | assertGauge(t, registry, "vcv_certificates_total", map[string]string{"status": "revoked"}, 1.0)
65 |
66 | // Per-certificate expiry timestamp for the "soon" cert
67 | assertGauge(t, registry, "vcv_certificate_expiry_timestamp_seconds", map[string]string{
68 | "serial_number": "active-soon",
69 | "common_name": "soon",
70 | "status": "active",
71 | }, float64(now.Add(10*24*time.Hour).Unix()))
72 |
73 | mockVault.AssertExpectations(t)
74 | }
75 |
76 | func assertGauge(t *testing.T, registry *prometheus.Registry, name string, labels map[string]string, expected float64) {
77 | t.Helper()
78 | value, err := gatherGauge(registry, name, labels)
79 | require.NoError(t, err)
80 | assert.InDelta(t, expected, value, 0.0001)
81 | }
82 |
83 | func gatherGauge(registry *prometheus.Registry, name string, labels map[string]string) (float64, error) {
84 | families, err := registry.Gather()
85 | if err != nil {
86 | return 0, err
87 | }
88 | for _, mf := range families {
89 | if mf.GetName() != name {
90 | continue
91 | }
92 | for _, m := range mf.Metric {
93 | if !matchLabels(m, labels) {
94 | continue
95 | }
96 | return m.GetGauge().GetValue(), nil
97 | }
98 | }
99 | return 0, nil
100 | }
101 |
102 | func matchLabels(metric *dto.Metric, labels map[string]string) bool {
103 | if len(labels) == 0 {
104 | return true
105 | }
106 | for _, lp := range metric.Label {
107 | key := lp.GetName()
108 | val := lp.GetValue()
109 | if expected, ok := labels[key]; ok {
110 | if expected != val {
111 | return false
112 | }
113 | }
114 | }
115 | // Ensure no expected label is missing
116 | for expectedKey := range labels {
117 | found := false
118 | for _, lp := range metric.Label {
119 | if lp.GetName() == expectedKey {
120 | found = true
121 | break
122 | }
123 | }
124 | if !found {
125 | return false
126 | }
127 | }
128 | return true
129 | }
130 |
--------------------------------------------------------------------------------
/app/cmd/server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "io/fs"
7 | "net/http"
8 | "os"
9 | "os/signal"
10 | "syscall"
11 | "time"
12 | "vcv/internal/metrics"
13 |
14 | "vcv/config"
15 | "vcv/internal/handlers"
16 | "vcv/internal/logger"
17 | "vcv/internal/vault"
18 | "vcv/internal/version"
19 | "vcv/middleware"
20 |
21 | "github.com/go-chi/chi/v5"
22 | "github.com/prometheus/client_golang/prometheus"
23 | "github.com/prometheus/client_golang/prometheus/collectors"
24 | "github.com/prometheus/client_golang/prometheus/promhttp"
25 | )
26 |
27 | func main() {
28 | cfg := config.Load()
29 |
30 | // Initialize structured logger from config
31 | logger.Init(cfg.LogLevel)
32 | log := logger.Get()
33 |
34 | log.Info().
35 | Str("version", version.Version).
36 | Msg("VaultCertsViewer starting")
37 |
38 | log.Info().
39 | Str("env", string(cfg.Env)).
40 | Str("log_level", cfg.LogLevel).
41 | Str("log_format", cfg.LogFormat).
42 | Msg("Configuration loaded")
43 |
44 | r := chi.NewRouter()
45 | vaultClient, vaultError := vault.NewClientFromConfig(cfg.Vault)
46 | if vaultError != nil {
47 | log.Fatal().Err(vaultError).
48 | Msg("Failed to initialize Vault client")
49 | }
50 |
51 | log.Info().
52 | Str("vault_addr", cfg.Vault.Addr).
53 | Strs("vault_mounts", cfg.Vault.PKIMounts).
54 | Msg("Vault client initialized")
55 |
56 | registry := prometheus.NewRegistry()
57 | registry.MustRegister(collectors.NewGoCollector())
58 | registry.MustRegister(metrics.NewCertificateCollector(vaultClient))
59 |
60 | webFS, fsError := fs.Sub(embeddedWeb, "web")
61 | if fsError != nil {
62 | log.Fatal().Err(fsError).
63 | Msg("Failed to initialize embedded web filesystem")
64 | }
65 | assetsFS, assetsError := fs.Sub(webFS, "assets")
66 | if assetsError != nil {
67 | log.Fatal().Err(assetsError).
68 | Msg("Failed to initialize embedded assets filesystem")
69 | }
70 |
71 | // Middleware must be registered before any routes
72 | r.Use(middleware.RequestID)
73 | r.Use(middleware.Logger)
74 | r.Use(middleware.Recoverer)
75 | r.Use(middleware.SecurityHeaders)
76 |
77 | // Static frontend from embedded filesystem
78 | r.Get("/", func(w http.ResponseWriter, req *http.Request) {
79 | data, readError := fs.ReadFile(webFS, "index.html")
80 | if readError != nil {
81 | log.Error().Err(readError).
82 | Str("path", "/").
83 | Msg("Failed to read embedded index.html")
84 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
85 | return
86 | }
87 | w.Header().Set("Content-Type", "text/html; charset=utf-8")
88 | _, _ = w.Write(data)
89 | })
90 | staticHandler := http.StripPrefix("/assets/", http.FileServer(http.FS(assetsFS)))
91 | r.Handle("/assets/*", staticHandler)
92 |
93 | // Health and readiness probes
94 | r.Get("/api/health", handlers.HealthCheck)
95 | r.Get("/api/ready", handlers.ReadinessCheck)
96 | r.Get("/api/status", func(w http.ResponseWriter, req *http.Request) {
97 | ctx := req.Context()
98 | status := map[string]interface{}{
99 | "version": version.Version,
100 | }
101 | if err := vaultClient.CheckConnection(ctx); err != nil {
102 | status["vault_connected"] = false
103 | status["vault_error"] = err.Error()
104 | } else {
105 | status["vault_connected"] = true
106 | }
107 | w.Header().Set("Content-Type", "application/json")
108 | _ = json.NewEncoder(w).Encode(status)
109 | })
110 | r.Get("/api/version", func(w http.ResponseWriter, req *http.Request) {
111 | w.Header().Set("Content-Type", "application/json")
112 | _ = json.NewEncoder(w).Encode(version.Info())
113 | })
114 | r.Get("/api/config", handlers.GetConfig(cfg))
115 | r.Get("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{}).ServeHTTP)
116 | handlers.RegisterI18nRoutes(r)
117 | handlers.RegisterCertRoutes(r, vaultClient)
118 | handlers.RegisterUIRoutes(r, vaultClient, webFS, cfg.ExpirationThresholds)
119 |
120 | srv := &http.Server{
121 | Addr: ":" + cfg.Port,
122 | Handler: r,
123 | ReadTimeout: 15 * time.Second,
124 | WriteTimeout: 15 * time.Second,
125 | IdleTimeout: 60 * time.Second,
126 | }
127 |
128 | go func() {
129 | log.Info().Str("port", cfg.Port).Msg("Server starting")
130 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
131 | log.Fatal().Err(err).Msg("Server error")
132 | }
133 | }()
134 |
135 | quit := make(chan os.Signal, 1)
136 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
137 | <-quit
138 |
139 | log.Info().Msg("Shutting down server...")
140 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
141 | defer cancel()
142 |
143 | if err := srv.Shutdown(ctx); err != nil {
144 | log.Fatal().Err(err).Msg("Server forced to shutdown")
145 | }
146 |
147 | // Shutdown Vault client (stops background goroutines)
148 | vaultClient.Shutdown()
149 |
150 | log.Info().Msg("Server stopped")
151 | }
152 |
--------------------------------------------------------------------------------
/app/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os"
5 | "strconv"
6 | "strings"
7 |
8 | "github.com/joho/godotenv"
9 | )
10 |
11 | // Environment represents the application environment.
12 | type Environment string
13 |
14 | const (
15 | EnvDev Environment = "dev"
16 | EnvStage Environment = "stage"
17 | EnvProd Environment = "prod"
18 | )
19 |
20 | // Config holds application configuration.
21 | type Config struct {
22 | Env Environment
23 | Port string
24 | LogLevel string
25 | LogFormat string
26 | LogOutput string
27 | CORS CORSConfig
28 | Vault VaultConfig
29 | ExpirationThresholds ExpirationThresholds
30 | }
31 |
32 | // CORSConfig holds CORS-specific configuration.
33 | type CORSConfig struct {
34 | AllowedOrigins []string
35 | AllowCredentials bool
36 | }
37 |
38 | type VaultConfig struct {
39 | Addr string
40 | PKIMounts []string
41 | ReadToken string
42 | TLSInsecure bool
43 | }
44 |
45 | // ExpirationThresholds holds certificate expiration alert thresholds (in days).
46 | type ExpirationThresholds struct {
47 | Critical int
48 | Warning int
49 | }
50 |
51 | // Load reads configuration from environment variables.
52 | func Load() Config {
53 | _ = godotenv.Load()
54 |
55 | env := parseEnv(getEnv("APP_ENV", "dev"))
56 |
57 | cfg := Config{
58 | Env: env,
59 | Port: getEnv("PORT", "52000"),
60 | LogLevel: getEnv("LOG_LEVEL", defaultLogLevel(env)),
61 | LogFormat: getEnv("LOG_FORMAT", defaultLogFormat(env)),
62 | LogOutput: getEnv("LOG_OUTPUT", "stdout"),
63 | CORS: loadCORSConfig(env),
64 | Vault: loadVaultConfig(),
65 | ExpirationThresholds: loadExpirationThresholds(),
66 | }
67 |
68 | return cfg
69 | }
70 |
71 | // IsDev returns true if the environment is development.
72 | func (c Config) IsDev() bool {
73 | return c.Env == EnvDev
74 | }
75 |
76 | // IsProd returns true if the environment is production.
77 | func (c Config) IsProd() bool {
78 | return c.Env == EnvProd
79 | }
80 |
81 | func parseEnv(s string) Environment {
82 | switch strings.ToLower(strings.TrimSpace(s)) {
83 | case "prod", "production":
84 | return EnvProd
85 | case "stage", "staging":
86 | return EnvStage
87 | default:
88 | return EnvDev
89 | }
90 | }
91 |
92 | func defaultLogLevel(env Environment) string {
93 | switch env {
94 | case EnvProd:
95 | return "info"
96 | case EnvStage:
97 | return "debug"
98 | default:
99 | return "debug"
100 | }
101 | }
102 |
103 | func defaultLogFormat(env Environment) string {
104 | switch env {
105 | case EnvProd:
106 | return "json"
107 | default:
108 | return "console"
109 | }
110 | }
111 |
112 | func loadCORSConfig(env Environment) CORSConfig {
113 | originsEnv := getEnv("CORS_ALLOWED_ORIGINS", "")
114 | if originsEnv != "" {
115 | origins := strings.Split(originsEnv, ",")
116 | for i := range origins {
117 | origins[i] = strings.TrimSpace(origins[i])
118 | }
119 | return CORSConfig{
120 | AllowedOrigins: origins,
121 | AllowCredentials: true,
122 | }
123 | }
124 | switch env {
125 | case EnvProd:
126 | return CORSConfig{
127 | AllowedOrigins: []string{},
128 | AllowCredentials: true,
129 | }
130 | default:
131 | return CORSConfig{
132 | AllowedOrigins: []string{"http://localhost:4321", "http://localhost:3000"},
133 | AllowCredentials: true,
134 | }
135 | }
136 | }
137 |
138 | func loadVaultConfig() VaultConfig {
139 | tlsInsecure := strings.ToLower(getEnv("VAULT_TLS_INSECURE", "false")) == "true"
140 |
141 | // Support both new VAULT_PKI_MOUNTS (comma-separated) and legacy VAULT_PKI_MOUNT
142 | pkiMountsStr := getEnv("VAULT_PKI_MOUNTS", "")
143 | if pkiMountsStr == "" {
144 | // Fallback to legacy single mount for backward compatibility
145 | legacyMount := getEnv("VAULT_PKI_MOUNT", "pki")
146 | pkiMountsStr = legacyMount
147 | }
148 |
149 | var pkiMounts []string
150 | if pkiMountsStr != "" {
151 | pkiMounts = strings.Split(pkiMountsStr, ",")
152 | for i := range pkiMounts {
153 | pkiMounts[i] = strings.TrimSpace(pkiMounts[i])
154 | }
155 | }
156 |
157 | return VaultConfig{
158 | Addr: getEnv("VAULT_ADDR", ""),
159 | PKIMounts: pkiMounts,
160 | ReadToken: getEnv("VAULT_READ_TOKEN", ""),
161 | TLSInsecure: tlsInsecure,
162 | }
163 | }
164 |
165 | func loadExpirationThresholds() ExpirationThresholds {
166 | critical := getEnvInt("VCV_EXPIRE_CRITICAL", 7)
167 | warning := getEnvInt("VCV_EXPIRE_WARNING", 30)
168 | return ExpirationThresholds{
169 | Critical: critical,
170 | Warning: warning,
171 | }
172 | }
173 |
174 | func getEnv(key, defaultValue string) string {
175 | if value := os.Getenv(key); value != "" {
176 | return value
177 | }
178 | return defaultValue
179 | }
180 |
181 | func getEnvInt(key string, defaultValue int) int {
182 | if value := os.Getenv(key); value != "" {
183 | if intVal, err := strconv.Atoi(value); err == nil {
184 | return intVal
185 | }
186 | }
187 | return defaultValue
188 | }
189 |
--------------------------------------------------------------------------------
/app/internal/metrics/certificate_collector.go:
--------------------------------------------------------------------------------
1 | package metrics
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/prometheus/client_golang/prometheus"
8 |
9 | "vcv/internal/certs"
10 | "vcv/internal/vault"
11 | )
12 |
13 | var (
14 | cacheSizeDesc = prometheus.NewDesc("vcv_cache_size", "Number of items currently cached", nil, nil)
15 | certificatesLastFetchDesc = prometheus.NewDesc("vcv_certificates_last_fetch_timestamp_seconds", "Timestamp of last successful certificates fetch", nil, nil)
16 | certificatesTotalDesc = prometheus.NewDesc("vcv_certificates_total", "Total certificates grouped by status", []string{"status"}, nil)
17 | expiredCountDesc = prometheus.NewDesc("vcv_certificates_expired_count", "Number of expired certificates", nil, nil)
18 | expiryTimestampDesc = prometheus.NewDesc("vcv_certificate_expiry_timestamp_seconds", "Certificate expiration timestamp in seconds since epoch", []string{"serial_number", "common_name", "status"}, nil)
19 | lastScrapeSuccessDesc = prometheus.NewDesc("vcv_certificate_exporter_last_scrape_success", "Whether the last scrape succeeded (1) or failed (0)", nil, nil)
20 | vaultConnectedDesc = prometheus.NewDesc("vcv_vault_connected", "Vault connection status (1=connected,0=disconnected)", nil, nil)
21 | )
22 |
23 | type certificateCollector struct {
24 | vaultClient vault.Client
25 | now func() time.Time
26 | }
27 |
28 | // NewCertificateCollector returns a Prometheus collector exposing certificate inventory and expiry status.
29 | func NewCertificateCollector(vaultClient vault.Client) prometheus.Collector {
30 | return &certificateCollector{
31 | vaultClient: vaultClient,
32 | now: time.Now,
33 | }
34 | }
35 |
36 | func (collector *certificateCollector) Describe(ch chan<- *prometheus.Desc) {
37 | ch <- cacheSizeDesc
38 | ch <- certificatesLastFetchDesc
39 | ch <- certificatesTotalDesc
40 | ch <- expiredCountDesc
41 | ch <- expiryTimestampDesc
42 | ch <- lastScrapeSuccessDesc
43 | ch <- vaultConnectedDesc
44 | }
45 |
46 | func (collector *certificateCollector) Collect(ch chan<- prometheus.Metric) {
47 | certificates, err := collector.listCertificates()
48 | if err != nil {
49 | ch <- prometheus.MustNewConstMetric(lastScrapeSuccessDesc, prometheus.GaugeValue, 0)
50 | return
51 | }
52 | ch <- prometheus.MustNewConstMetric(lastScrapeSuccessDesc, prometheus.GaugeValue, 1)
53 | now := collector.now()
54 |
55 | // Check Vault connection
56 | vaultConnected := 1.0
57 | if err := collector.vaultClient.CheckConnection(context.Background()); err != nil {
58 | vaultConnected = 0.0
59 | }
60 |
61 | activeCount, revokedCount := collector.countStatuses(certificates)
62 | expiredCount := collector.countExpired(certificates, now)
63 | cacheSize := collector.getCacheSize()
64 |
65 | ch <- prometheus.MustNewConstMetric(cacheSizeDesc, prometheus.GaugeValue, float64(cacheSize))
66 | ch <- prometheus.MustNewConstMetric(certificatesLastFetchDesc, prometheus.GaugeValue, float64(now.Unix()))
67 | ch <- prometheus.MustNewConstMetric(certificatesTotalDesc, prometheus.GaugeValue, float64(activeCount), "active")
68 | ch <- prometheus.MustNewConstMetric(certificatesTotalDesc, prometheus.GaugeValue, float64(revokedCount), "revoked")
69 | ch <- prometheus.MustNewConstMetric(expiredCountDesc, prometheus.GaugeValue, float64(expiredCount))
70 | ch <- prometheus.MustNewConstMetric(vaultConnectedDesc, prometheus.GaugeValue, vaultConnected)
71 | collector.emitCertificateMetrics(ch, certificates, now)
72 | }
73 |
74 | func (collector *certificateCollector) listCertificates() ([]certs.Certificate, error) {
75 | return collector.vaultClient.ListCertificates(context.Background())
76 | }
77 |
78 | func (collector *certificateCollector) countStatuses(certificates []certs.Certificate) (int, int) {
79 | activeCount := 0
80 | revokedCount := 0
81 | for _, certificate := range certificates {
82 | if certificate.Revoked {
83 | revokedCount++
84 | continue
85 | }
86 | activeCount++
87 | }
88 | return activeCount, revokedCount
89 | }
90 |
91 | func (collector *certificateCollector) countExpired(certificates []certs.Certificate, now time.Time) int {
92 | count := 0
93 | for _, certificate := range certificates {
94 | if !certificate.Revoked && certificate.ExpiresAt.Before(now) {
95 | count++
96 | }
97 | }
98 | return count
99 | }
100 |
101 | func (collector *certificateCollector) getCacheSize() int {
102 | // Try to access cache via reflection or interface if available
103 | // For now, return 0 as cache size is not exposed by vault.Client interface
104 | return 0
105 | }
106 |
107 | func (collector *certificateCollector) emitCertificateMetrics(ch chan<- prometheus.Metric, certificates []certs.Certificate, now time.Time) {
108 | for _, certificate := range certificates {
109 | status := collector.statusLabel(certificate.Revoked)
110 | expiryTimestamp := float64(certificate.ExpiresAt.Unix())
111 | ch <- prometheus.MustNewConstMetric(expiryTimestampDesc, prometheus.GaugeValue, expiryTimestamp, certificate.ID, certificate.CommonName, status)
112 | }
113 | }
114 |
115 | func (collector *certificateCollector) statusLabel(revoked bool) string {
116 | if revoked {
117 | return "revoked"
118 | }
119 | return "active"
120 | }
121 |
--------------------------------------------------------------------------------
/app/internal/handlers/certs_test.go:
--------------------------------------------------------------------------------
1 | package handlers_test
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "net/http"
8 | "net/http/httptest"
9 | "testing"
10 | "time"
11 |
12 | "github.com/go-chi/chi/v5"
13 | "github.com/stretchr/testify/assert"
14 | "github.com/stretchr/testify/mock"
15 |
16 | "vcv/internal/certs"
17 | "vcv/internal/handlers"
18 | "vcv/internal/vault"
19 | "vcv/middleware"
20 | )
21 |
22 | func setupRouter(mockVault *vault.MockClient) *chi.Mux {
23 | r := chi.NewRouter()
24 | r.Use(middleware.RequestID)
25 | handlers.RegisterCertRoutes(r, mockVault)
26 | return r
27 | }
28 |
29 | func TestListCertificates_Success(t *testing.T) {
30 | mockVault := new(vault.MockClient)
31 | certsList := []certs.Certificate{
32 | {ID: "1", CommonName: "a", ExpiresAt: time.Now()},
33 | }
34 | mockVault.On("ListCertificates", mock.Anything).Return(certsList, nil)
35 | router := setupRouter(mockVault)
36 |
37 | req := httptest.NewRequest(http.MethodGet, "/api/certs", nil)
38 | rec := httptest.NewRecorder()
39 |
40 | router.ServeHTTP(rec, req)
41 |
42 | assert.Equal(t, http.StatusOK, rec.Code)
43 | var got []certs.Certificate
44 | assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
45 | assert.Len(t, got, 1)
46 | mockVault.AssertExpectations(t)
47 | }
48 |
49 | func TestListCertificates_Error(t *testing.T) {
50 | mockVault := new(vault.MockClient)
51 | mockVault.On("ListCertificates", mock.Anything).Return([]certs.Certificate{}, errors.New("boom"))
52 | router := setupRouter(mockVault)
53 |
54 | req := httptest.NewRequest(http.MethodGet, "/api/certs", nil)
55 | rec := httptest.NewRecorder()
56 |
57 | router.ServeHTTP(rec, req)
58 |
59 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
60 | mockVault.AssertExpectations(t)
61 | }
62 |
63 | func TestGetCertificateDetails_Success(t *testing.T) {
64 | mockVault := new(vault.MockClient)
65 | expected := certs.DetailedCertificate{
66 | Certificate: certs.Certificate{
67 | ID: "serial",
68 | CommonName: "cn",
69 | ExpiresAt: time.Now(),
70 | },
71 | SerialNumber: "serial",
72 | }
73 | mockVault.On("GetCertificateDetails", mock.Anything, "serial").Return(expected, nil)
74 | router := setupRouter(mockVault)
75 |
76 | req := httptest.NewRequest(http.MethodGet, "/api/certs/serial/details", nil)
77 | rec := httptest.NewRecorder()
78 |
79 | router.ServeHTTP(rec, req)
80 |
81 | assert.Equal(t, http.StatusOK, rec.Code)
82 | var got certs.DetailedCertificate
83 | assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
84 | assert.Equal(t, "serial", got.SerialNumber)
85 | mockVault.AssertExpectations(t)
86 | }
87 |
88 | func TestGetCertificateDetails_BadRequest(t *testing.T) {
89 | mockVault := new(vault.MockClient)
90 | router := setupRouter(mockVault)
91 |
92 | req := httptest.NewRequest(http.MethodGet, "/api/certs//details", nil)
93 | rec := httptest.NewRecorder()
94 |
95 | router.ServeHTTP(rec, req)
96 |
97 | assert.Equal(t, http.StatusBadRequest, rec.Code)
98 | mockVault.AssertExpectations(t)
99 | }
100 |
101 | func TestGetCertificateDetails_Error(t *testing.T) {
102 | mockVault := new(vault.MockClient)
103 | mockVault.On("GetCertificateDetails", mock.Anything, "serial").Return(certs.DetailedCertificate{}, errors.New("fail"))
104 | router := setupRouter(mockVault)
105 |
106 | req := httptest.NewRequest(http.MethodGet, "/api/certs/serial/details", nil)
107 | rec := httptest.NewRecorder()
108 |
109 | router.ServeHTTP(rec, req)
110 |
111 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
112 | mockVault.AssertExpectations(t)
113 | }
114 |
115 | func TestGetCertificatePEM_Success(t *testing.T) {
116 | mockVault := new(vault.MockClient)
117 | pemResp := certs.PEMResponse{SerialNumber: "serial", PEM: "pem-data"}
118 | mockVault.On("GetCertificatePEM", mock.Anything, "serial").Return(pemResp, nil)
119 | router := setupRouter(mockVault)
120 |
121 | req := httptest.NewRequest(http.MethodGet, "/api/certs/serial/pem", nil)
122 | rec := httptest.NewRecorder()
123 |
124 | router.ServeHTTP(rec, req)
125 |
126 | assert.Equal(t, http.StatusOK, rec.Code)
127 | var got certs.PEMResponse
128 | assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
129 | assert.Equal(t, "pem-data", got.PEM)
130 | mockVault.AssertExpectations(t)
131 | }
132 |
133 | func TestGetCertificatePEM_Error(t *testing.T) {
134 | mockVault := new(vault.MockClient)
135 | mockVault.On("GetCertificatePEM", mock.Anything, "serial").Return(certs.PEMResponse{}, errors.New("fail"))
136 | router := setupRouter(mockVault)
137 |
138 | req := httptest.NewRequest(http.MethodGet, "/api/certs/serial/pem", nil)
139 | rec := httptest.NewRecorder()
140 |
141 | router.ServeHTTP(rec, req)
142 |
143 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
144 | mockVault.AssertExpectations(t)
145 | }
146 |
147 | func TestInvalidateCache(t *testing.T) {
148 | mockVault := new(vault.MockClient)
149 | mockVault.On("InvalidateCache").Return()
150 | router := setupRouter(mockVault)
151 |
152 | req := httptest.NewRequest(http.MethodPost, "/api/cache/invalidate", bytes.NewBuffer(nil))
153 | rec := httptest.NewRecorder()
154 |
155 | router.ServeHTTP(rec, req)
156 |
157 | assert.Equal(t, http.StatusNoContent, rec.Code)
158 | mockVault.AssertExpectations(t)
159 | }
160 |
--------------------------------------------------------------------------------
/app/internal/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "os"
7 | "strings"
8 | "time"
9 |
10 | "github.com/rs/zerolog"
11 | "github.com/rs/zerolog/log"
12 | )
13 |
14 | // Logger is the application-wide logger type, aliased to zerolog.Logger.
15 | // This allows other packages to depend only on vcv/internal/logger instead of importing zerolog directly.
16 | type Logger = zerolog.Logger
17 |
18 | // Init initializes the logger with the specified log level
19 | func Init(level string) {
20 | // Set time format
21 | zerolog.TimeFieldFormat = time.RFC3339Nano
22 |
23 | // Read logging configuration from environment
24 | outputMode := strings.ToLower(strings.TrimSpace(os.Getenv("LOG_OUTPUT")))
25 | if outputMode == "" {
26 | outputMode = "stdout"
27 | }
28 |
29 | format := strings.ToLower(strings.TrimSpace(os.Getenv("LOG_FORMAT")))
30 | if format == "" {
31 | format = "console"
32 | }
33 |
34 | logFilePath := strings.TrimSpace(os.Getenv("LOG_FILE_PATH"))
35 |
36 | stdoutEnabled := outputMode == "stdout" || outputMode == "both"
37 | fileEnabled := outputMode == "file" || outputMode == "both"
38 |
39 | writers := make([]io.Writer, 0, 2)
40 | deferredWarnings := make([]string, 0, 2)
41 |
42 | // Configure stdout writer
43 | if stdoutEnabled {
44 | if format == "json" {
45 | writers = append(writers, os.Stdout)
46 | } else {
47 | consoleWriter := zerolog.ConsoleWriter{
48 | Out: os.Stdout,
49 | TimeFormat: "2006-01-02 15:04:05",
50 | }
51 | writers = append(writers, consoleWriter)
52 | }
53 | }
54 |
55 | // Configure file writer if requested
56 | if fileEnabled {
57 | if logFilePath == "" {
58 | deferredWarnings = append(deferredWarnings, "LOG_OUTPUT requires a file but LOG_FILE_PATH is not set; disabling file logging")
59 | } else {
60 | file, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
61 | if err != nil {
62 | deferredWarnings = append(deferredWarnings, fmt.Sprintf("Failed to open log file '%s', disabling file logging: %v", logFilePath, err))
63 | } else {
64 | if format == "json" {
65 | writers = append(writers, file)
66 | } else {
67 | consoleWriter := zerolog.ConsoleWriter{
68 | Out: file,
69 | TimeFormat: "2006-01-02 15:04:05",
70 | }
71 | writers = append(writers, consoleWriter)
72 | }
73 | }
74 | }
75 | }
76 |
77 | // Fallback: if no writers configured, use stdout console
78 | if len(writers) == 0 {
79 | consoleWriter := zerolog.ConsoleWriter{
80 | Out: os.Stdout,
81 | TimeFormat: "2006-01-02 15:04:05",
82 | }
83 | writers = append(writers, consoleWriter)
84 | deferredWarnings = append(deferredWarnings, "No valid log output configured, falling back to stdout console")
85 | stdoutEnabled = true
86 | fileEnabled = false
87 | logFilePath = ""
88 | }
89 |
90 | var output io.Writer
91 | if len(writers) == 1 {
92 | output = writers[0]
93 | } else {
94 | output = zerolog.MultiLevelWriter(writers...)
95 | }
96 |
97 | // Set the global logger
98 | log.Logger = log.Output(output)
99 |
100 | // Set log level, defaulting to InfoLevel if parsing fails.
101 | lvl, err := zerolog.ParseLevel(strings.ToLower(level))
102 | if err != nil {
103 | lvl = zerolog.InfoLevel
104 | log.Warn().Str("log_level_in", level).Msg("Invalid log level, defaulting to 'info'")
105 | }
106 | zerolog.SetGlobalLevel(lvl)
107 |
108 | // Log any deferred warnings about configuration
109 | for _, msg := range deferredWarnings {
110 | log.Warn().Msg(msg)
111 | }
112 |
113 | log.Info().
114 | Str("level", zerolog.GlobalLevel().String()).
115 | Str("output_mode", outputMode).
116 | Str("format", format).
117 | Bool("stdout_enabled", stdoutEnabled).
118 | Bool("file_enabled", fileEnabled).
119 | Str("log_file_path", logFilePath).
120 | Msg("Logger initialized")
121 | }
122 |
123 | // Get returns a pointer to the configured logger instance
124 | func Get() *zerolog.Logger {
125 | return &log.Logger
126 | }
127 |
128 | // SetOutput changes the destination for log output.
129 | // This is useful for redirecting logs to a file or a buffer during testing.
130 | func SetOutput(w io.Writer) {
131 | log.Logger = log.Output(w)
132 | }
133 |
134 | // Event is an alias for zerolog.Event to allow building log entries without importing zerolog.
135 | type Event = zerolog.Event
136 |
137 | // HTTPEvent logs HTTP request events with standardized fields.
138 | func HTTPEvent(method, path string, status int, durationMs float64) *zerolog.Event {
139 | return log.Info().
140 | Str("event_category", "http").
141 | Str("method", method).
142 | Str("path", path).
143 | Int("status", status).
144 | Float64("duration_ms", durationMs)
145 | }
146 |
147 | // HTTPError logs HTTP error events.
148 | func HTTPError(method, path string, status int, err error) *zerolog.Event {
149 | return log.Error().
150 | Str("event_category", "http").
151 | Str("method", method).
152 | Str("path", path).
153 | Int("status", status).
154 | Err(err)
155 | }
156 |
157 | // PanicEvent logs panic recovery events.
158 | func PanicEvent(err interface{}, stack string) *zerolog.Event {
159 | return log.Error().
160 | Str("event_category", "panic").
161 | Interface("error", err).
162 | Str("stack", stack)
163 | }
164 |
--------------------------------------------------------------------------------
/app/middleware/middleware.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "runtime/debug"
7 | "time"
8 |
9 | "vcv/internal/logger"
10 | )
11 |
12 | // contextKey is a custom type for context keys to avoid collisions.
13 | type contextKey string
14 |
15 | // RequestIDKey is the context key for request ID.
16 | const RequestIDKey contextKey = "request_id"
17 |
18 | // Logger logs HTTP requests with timing information using zerolog.
19 | func Logger(next http.Handler) http.Handler {
20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21 | start := time.Now()
22 | wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
23 | next.ServeHTTP(wrapped, r)
24 | duration := time.Since(start)
25 | requestID := GetRequestID(r.Context())
26 | logger.HTTPEvent(r.Method, r.URL.Path, wrapped.statusCode, float64(duration.Milliseconds())).
27 | Str("request_id", requestID).
28 | Str("remote_addr", r.RemoteAddr).
29 | Str("user_agent", r.UserAgent()).
30 | Msg("HTTP request")
31 | })
32 | }
33 |
34 | // responseWriter wraps http.ResponseWriter to capture status code.
35 | type responseWriter struct {
36 | http.ResponseWriter
37 | statusCode int
38 | }
39 |
40 | // WriteHeader captures the status code.
41 | func (rw *responseWriter) WriteHeader(code int) {
42 | rw.statusCode = code
43 | rw.ResponseWriter.WriteHeader(code)
44 | }
45 |
46 | // Recoverer recovers from panics and returns a 500 error.
47 | func Recoverer(next http.Handler) http.Handler {
48 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49 | defer func() {
50 | if err := recover(); err != nil {
51 | requestID := GetRequestID(r.Context())
52 | logger.PanicEvent(err, string(debug.Stack())).
53 | Str("request_id", requestID).
54 | Str("path", r.URL.Path).
55 | Str("method", r.Method).
56 | Msg("Panic recovered")
57 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
58 | }
59 | }()
60 | next.ServeHTTP(w, r)
61 | })
62 | }
63 |
64 | // CORSConfig holds CORS configuration.
65 | type CORSConfig struct {
66 | AllowedOrigins []string
67 | AllowedMethods []string
68 | AllowedHeaders []string
69 | AllowCredentials bool
70 | MaxAge int
71 | }
72 |
73 | // DefaultCORSConfig returns a default CORS configuration.
74 | func DefaultCORSConfig() CORSConfig {
75 | return CORSConfig{
76 | AllowedOrigins: []string{"*"},
77 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
78 | AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"},
79 | AllowCredentials: false,
80 | MaxAge: 86400,
81 | }
82 | }
83 |
84 | // CORS returns a CORS middleware with the given configuration.
85 | func CORS(config CORSConfig) func(http.Handler) http.Handler {
86 | return func(next http.Handler) http.Handler {
87 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88 | origin := r.Header.Get("Origin")
89 | if origin == "" {
90 | next.ServeHTTP(w, r)
91 | return
92 | }
93 | allowed := false
94 | for _, o := range config.AllowedOrigins {
95 | if o == "*" || o == origin {
96 | allowed = true
97 | break
98 | }
99 | }
100 | if !allowed {
101 | next.ServeHTTP(w, r)
102 | return
103 | }
104 | w.Header().Set("Access-Control-Allow-Origin", origin)
105 | if config.AllowCredentials {
106 | w.Header().Set("Access-Control-Allow-Credentials", "true")
107 | }
108 | if r.Method == http.MethodOptions {
109 | w.Header().Set("Access-Control-Allow-Methods", joinStrings(config.AllowedMethods))
110 | w.Header().Set("Access-Control-Allow-Headers", joinStrings(config.AllowedHeaders))
111 | if config.MaxAge > 0 {
112 | w.Header().Set("Access-Control-Max-Age", itoa(config.MaxAge))
113 | }
114 | w.WriteHeader(http.StatusNoContent)
115 | return
116 | }
117 | next.ServeHTTP(w, r)
118 | })
119 | }
120 | }
121 |
122 | // RequestID adds a unique request ID to each request and stores it in context.
123 | func RequestID(next http.Handler) http.Handler {
124 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125 | requestID := r.Header.Get("X-Request-ID")
126 | if requestID == "" {
127 | requestID = generateRequestID()
128 | }
129 | w.Header().Set("X-Request-ID", requestID)
130 | ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
131 | next.ServeHTTP(w, r.WithContext(ctx))
132 | })
133 | }
134 |
135 | // GetRequestID retrieves the request ID from context.
136 | func GetRequestID(ctx context.Context) string {
137 | if id, ok := ctx.Value(RequestIDKey).(string); ok {
138 | return id
139 | }
140 | return ""
141 | }
142 |
143 | // joinStrings joins strings with comma separator.
144 | func joinStrings(s []string) string {
145 | if len(s) == 0 {
146 | return ""
147 | }
148 | result := s[0]
149 | for i := 1; i < len(s); i++ {
150 | result += ", " + s[i]
151 | }
152 | return result
153 | }
154 |
155 | // itoa converts int to string without importing strconv.
156 | func itoa(n int) string {
157 | if n == 0 {
158 | return "0"
159 | }
160 | var digits []byte
161 | for n > 0 {
162 | digits = append([]byte{byte('0' + n%10)}, digits...)
163 | n /= 10
164 | }
165 | return string(digits)
166 | }
167 |
168 | // generateRequestID generates a simple request ID based on timestamp.
169 | func generateRequestID() string {
170 | return itoa(int(time.Now().UnixNano() % 1000000000))
171 | }
172 |
173 | // SecurityHeaders adds security-related HTTP headers to all responses.
174 | func SecurityHeaders(next http.Handler) http.Handler {
175 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
176 | w.Header().Set("X-Content-Type-Options", "nosniff")
177 | w.Header().Set("X-Frame-Options", "DENY")
178 | w.Header().Set("X-XSS-Protection", "1; mode=block")
179 | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
180 | w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'")
181 | next.ServeHTTP(w, r)
182 | })
183 | }
184 |
--------------------------------------------------------------------------------
/app/middleware/middleware_test.go:
--------------------------------------------------------------------------------
1 | package middleware_test
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "testing"
7 |
8 | "vcv/middleware"
9 | )
10 |
11 | func TestSecurityHeaders(t *testing.T) {
12 | handler := middleware.SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13 | w.WriteHeader(http.StatusOK)
14 | }))
15 |
16 | req := httptest.NewRequest(http.MethodGet, "/", nil)
17 | rec := httptest.NewRecorder()
18 |
19 | handler.ServeHTTP(rec, req)
20 |
21 | tests := []struct {
22 | header string
23 | expected string
24 | }{
25 | {"X-Content-Type-Options", "nosniff"},
26 | {"X-Frame-Options", "DENY"},
27 | {"X-XSS-Protection", "1; mode=block"},
28 | {"Referrer-Policy", "strict-origin-when-cross-origin"},
29 | }
30 |
31 | for _, tt := range tests {
32 | t.Run(tt.header, func(t *testing.T) {
33 | got := rec.Header().Get(tt.header)
34 | if got != tt.expected {
35 | t.Errorf("expected %s=%q, got %q", tt.header, tt.expected, got)
36 | }
37 | })
38 | }
39 |
40 | csp := rec.Header().Get("Content-Security-Policy")
41 | if csp == "" {
42 | t.Error("expected Content-Security-Policy header to be set")
43 | }
44 | }
45 |
46 | func TestRequestID_GeneratesID(t *testing.T) {
47 | handler := middleware.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48 | requestID := middleware.GetRequestID(r.Context())
49 | if requestID == "" {
50 | t.Error("expected request ID in context")
51 | }
52 | w.WriteHeader(http.StatusOK)
53 | }))
54 |
55 | req := httptest.NewRequest(http.MethodGet, "/", nil)
56 | rec := httptest.NewRecorder()
57 |
58 | handler.ServeHTTP(rec, req)
59 |
60 | if rec.Header().Get("X-Request-ID") == "" {
61 | t.Error("expected X-Request-ID header to be set")
62 | }
63 | }
64 |
65 | func TestRequestID_UsesProvidedID(t *testing.T) {
66 | providedID := "test-request-id-123"
67 | handler := middleware.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68 | requestID := middleware.GetRequestID(r.Context())
69 | if requestID != providedID {
70 | t.Errorf("expected request ID %q, got %q", providedID, requestID)
71 | }
72 | w.WriteHeader(http.StatusOK)
73 | }))
74 |
75 | req := httptest.NewRequest(http.MethodGet, "/", nil)
76 | req.Header.Set("X-Request-ID", providedID)
77 | rec := httptest.NewRecorder()
78 |
79 | handler.ServeHTTP(rec, req)
80 |
81 | if rec.Header().Get("X-Request-ID") != providedID {
82 | t.Errorf("expected X-Request-ID header %q, got %q", providedID, rec.Header().Get("X-Request-ID"))
83 | }
84 | }
85 |
86 | func TestRecoverer_HandlesNormalRequest(t *testing.T) {
87 | handler := middleware.Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88 | w.WriteHeader(http.StatusOK)
89 | }))
90 |
91 | req := httptest.NewRequest(http.MethodGet, "/", nil)
92 | rec := httptest.NewRecorder()
93 |
94 | handler.ServeHTTP(rec, req)
95 |
96 | if rec.Code != http.StatusOK {
97 | t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
98 | }
99 | }
100 |
101 | func TestRecoverer_HandlesPanic(t *testing.T) {
102 | handler := middleware.Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103 | panic("test panic")
104 | }))
105 |
106 | req := httptest.NewRequest(http.MethodGet, "/", nil)
107 | rec := httptest.NewRecorder()
108 |
109 | handler.ServeHTTP(rec, req)
110 |
111 | if rec.Code != http.StatusInternalServerError {
112 | t.Errorf("expected status %d, got %d", http.StatusInternalServerError, rec.Code)
113 | }
114 | }
115 |
116 | func TestCORS_NoOrigin(t *testing.T) {
117 | config := middleware.DefaultCORSConfig()
118 | handler := middleware.CORS(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
119 | w.WriteHeader(http.StatusOK)
120 | }))
121 |
122 | req := httptest.NewRequest(http.MethodGet, "/", nil)
123 | rec := httptest.NewRecorder()
124 |
125 | handler.ServeHTTP(rec, req)
126 |
127 | if rec.Header().Get("Access-Control-Allow-Origin") != "" {
128 | t.Error("expected no CORS headers when Origin is not set")
129 | }
130 | }
131 |
132 | func TestCORS_WithOrigin(t *testing.T) {
133 | config := middleware.DefaultCORSConfig()
134 | handler := middleware.CORS(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135 | w.WriteHeader(http.StatusOK)
136 | }))
137 |
138 | req := httptest.NewRequest(http.MethodGet, "/", nil)
139 | req.Header.Set("Origin", "http://example.com")
140 | rec := httptest.NewRecorder()
141 |
142 | handler.ServeHTTP(rec, req)
143 |
144 | if rec.Header().Get("Access-Control-Allow-Origin") != "http://example.com" {
145 | t.Errorf("expected Access-Control-Allow-Origin=http://example.com, got %q", rec.Header().Get("Access-Control-Allow-Origin"))
146 | }
147 | }
148 |
149 | func TestCORS_Preflight(t *testing.T) {
150 | config := middleware.DefaultCORSConfig()
151 | handler := middleware.CORS(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152 | w.WriteHeader(http.StatusOK)
153 | }))
154 |
155 | req := httptest.NewRequest(http.MethodOptions, "/", nil)
156 | req.Header.Set("Origin", "http://example.com")
157 | rec := httptest.NewRecorder()
158 |
159 | handler.ServeHTTP(rec, req)
160 |
161 | if rec.Code != http.StatusNoContent {
162 | t.Errorf("expected status %d for preflight, got %d", http.StatusNoContent, rec.Code)
163 | }
164 | if rec.Header().Get("Access-Control-Allow-Methods") == "" {
165 | t.Error("expected Access-Control-Allow-Methods header for preflight")
166 | }
167 | }
168 |
169 | func TestLogger_LogsRequest(t *testing.T) {
170 | // Create a handler that will be wrapped by Logger
171 | var writeErr error
172 | nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
173 | w.WriteHeader(http.StatusOK)
174 | _, err := w.Write([]byte("response"))
175 | writeErr = err
176 | })
177 |
178 | handler := middleware.Logger(nextHandler)
179 |
180 | req := httptest.NewRequest(http.MethodGet, "/test/path", nil)
181 | rec := httptest.NewRecorder()
182 |
183 | handler.ServeHTTP(rec, req)
184 | if writeErr != nil {
185 | t.Fatalf("expected no write error, got %v", writeErr)
186 | }
187 |
188 | // The Logger middleware should pass through the request
189 | if rec.Code != http.StatusOK {
190 | t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
191 | }
192 |
193 | // Add a new assertion to check the response body
194 | if rec.Body.String() != "response" {
195 | t.Errorf("expected response body %q, got %q", "response", rec.Body.String())
196 | }
197 | }
198 |
--------------------------------------------------------------------------------
/app/README.md:
--------------------------------------------------------------------------------
1 | # VaultCertsViewer — Technical overview
2 |
3 | This document describes the technical structure of VaultCertsViewer (vcv), a single Go binary that embeds a static HTML/CSS/JS UI to browse and manage certificates from one or more HashiCorp Vault PKI mounts.
4 |
5 | ## Architecture
6 |
7 | - **Backend**: Go + chi router, Vault client (`github.com/hashicorp/vault/api`), zerolog-based logging.
8 | - **Frontend**: Plain `index.html`, `styles.css`, `app-htmx.js` served from the embedded filesystem (no Node/bundler).
9 | - **Binary layout**: `app/cmd/server` embeds `/web` assets via Go `embed`; a single executable serves both API and UI.
10 | - **HTMX Integration**: Reactive UI with seamless updates, request management, and error handling.
11 |
12 | ## Directory layout (app/)
13 |
14 | - `cmd/server/main.go` — entrypoint, router, middleware, static file serving, graceful shutdown.
15 | - `cmd/server/web/` — `index.html`, `assets/app-htmx.js`, `assets/styles.css`, `templates/` (HTMX partials).
16 | - `config/` — environment-backed configuration loading with expiration threshold support.
17 | - `internal/cache/` — simple in-memory TTL cache (used by Vault client).
18 | - `internal/handlers/` — HTTP handlers (`certs`, `i18n`, `health`, `ready`, `ui` routes).
19 | - `internal/logger/` — zerolog initialization and structured helpers (HTTP events, panic).
20 | - `internal/vault/` — Vault client implementations with graceful shutdown support.
21 | - `internal/version/` — build version info (injected via ldflags).
22 | - `middleware/` — request ID, HTTP logging, panic recovery, CORS, security headers.
23 |
24 | ## API surface
25 |
26 | | Endpoint | Method | Description |
27 | | ---------- | -------- | ------------- |
28 | | `/` | GET | Embedded UI |
29 | | `/api/cache/invalidate` | POST | Clear Vault cache |
30 | | `/api/certs/{id}/details` | GET | Detailed certificate view |
31 | | `/api/certs/{id}/pem` | GET | PEM content |
32 | | `/api/certs` | GET | List certificates |
33 | | `/api/config` | GET | Application configuration (thresholds) |
34 | | `/api/health` | GET | Liveness probe |
35 | | `/api/i18n` | GET | UI translations (lang via query param) |
36 | | `/api/ready` | GET | Readiness probe |
37 | | `/api/version` | GET | Application version info |
38 | | `/ui/*` | GET | HTMX partial templates for reactive UI |
39 | | `/ui/theme/toggle` | POST | Toggle dark/light theme |
40 | | `/ui/status` | GET | Real-time Vault connection status |
41 |
42 | ## Configuration (env vars)
43 |
44 | | Variable | Default | Description |
45 | | -------- | ------- | ----------- |
46 | | `APP_ENV` | `dev` | Environment: `dev`, `stage`, `prod` |
47 | | `LOG_FILE_PATH` | — | Log file path (if output includes file) |
48 | | `LOG_FORMAT` | `console`/`json` | Log format (env-dependent default) |
49 | | `LOG_LEVEL` | `debug`/`info` | Log level (env-dependent default) |
50 | | `LOG_OUTPUT` | `stdout` | Output: `stdout`, `file`, `both` |
51 | | `PORT` | `52000` | HTTP server port |
52 | | `VAULT_ADDR` | — | Vault server address (required) |
53 | | `VAULT_PKI_MOUNTS` | `pki,pki2` | Comma-separated PKI mount paths |
54 | | `VAULT_READ_TOKEN` | — | Vault read token (required) |
55 | | `VAULT_TLS_INSECURE` | `false` | Skip TLS verification (dev only) |
56 | | `VCV_EXPIRE_CRITICAL` | `7` | Critical expiration threshold (days) |
57 | | `VCV_EXPIRE_WARNING` | `30` | Warning expiration threshold (days) |
58 |
59 | ## Security
60 |
61 | - **Container hardening**: read-only filesystem, no-new-privileges, dropped capabilities.
62 | - **Graceful shutdown**: proper cleanup of HTTP server and background goroutines.
63 |
64 | ## Logging
65 |
66 | - Initialized in `cmd/server/main.go` via `internal/logger.Init`.
67 | - Middlewares emit structured HTTP events with `request_id`, status, duration.
68 | - Handlers use `HTTPEvent`/`HTTPError` helpers; panic recovery logs stack traces.
69 | - Version info logged at startup.
70 |
71 | ## Internationalization
72 |
73 | - Languages: en, fr, es, de, it.
74 | - `/api/i18n` returns messages; the UI selects language via header dropdown or `?lang=xx`.
75 | - Short day labels (`daysRemainingShort`) and expiry filters are translated.
76 | - Toast notifications for Vault connection status are fully translated.
77 |
78 | ## Frontend Features
79 |
80 | ### HTMX Integration
81 |
82 | - Reactive UI with partial template updates
83 | - Request synchronization and automatic cancellation
84 | - Intelligent retry with exponential backoff
85 | - URL state management for deep-linking
86 | - Loading states and skeleton screens
87 |
88 | ### User Experience
89 |
90 | - Real-time search with debouncing (300ms)
91 | - Visual loading indicators on refresh button
92 | - Certificate status badges (valid/expired/revoked)
93 | - Vault connection monitoring with toast notifications
94 | - Responsive design with sticky header
95 | - Dark/light theme persistence
96 | - Modal mount selector for multi-PKI support
97 | - Configurable pagination (25/50/75/100/all)
98 | - Sortable columns with visual indicators
99 |
100 | ## Build & run
101 |
102 | ### Production
103 |
104 | See README.md on the root path for production deployment instructions.
105 |
106 | ### Development
107 |
108 | A HashiCorp Vault server is required to run the application in development mode. Thus, a container with an init script is provided in `docker-compose.dev.yml`. It will initialize a Vault server with a PKI mount and some certs.
109 |
110 | ```bash
111 | make dev
112 | ```
113 |
114 | Binary serves UI and API at `http://localhost:52000`.
115 |
116 | ## Testing
117 |
118 | ```bash
119 | cd app && go test ./...
120 | ```
121 |
122 | ### Test Coverage
123 |
124 | - Unit tests for all major packages
125 | - Mock Vault client for offline testing
126 | - HTTP handler tests with httptest.Server
127 | - Configuration validation tests
128 | - Internationalization tests
129 |
130 | Test targets:
131 |
132 | - `make test-offline`: Run tests without Vault dependency
133 | - `make test-dev`: Run tests against dev Vault instance
134 |
135 | ## Development notes
136 |
137 | - No external frontend toolchain; edit `app-htmx.js`/`styles.css` directly.
138 | - Request IDs are added to all responses; include them when correlating logs.
139 | - Use `VAULT_TLS_INSECURE=true` only in development environments.
140 | - HTMX partial templates are in `cmd/server/web/templates/`.
141 | - JavaScript uses modern ES6+ features with browser-native APIs.
142 | - CSS uses custom properties for theming and responsive design.
143 |
144 | ## Performance Considerations
145 |
146 | - In-memory caching with configurable TTL (default 5 minutes)
147 | - Request deduplication for concurrent identical requests
148 | - Efficient DOM updates via HTMX partial swapping
149 | - Lazy loading of certificate details
150 | - Optimized search with client-side filtering
151 |
--------------------------------------------------------------------------------
/app/internal/logger/logger_test.go:
--------------------------------------------------------------------------------
1 | package logger_test
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "os"
8 | "strings"
9 | "testing"
10 |
11 | "vcv/internal/logger"
12 | )
13 |
14 | // setEnvHelper sets an environment variable and fails the test if it cannot.
15 | func setEnvHelper(t *testing.T, key, value string) {
16 | t.Helper()
17 | if err := os.Setenv(key, value); err != nil {
18 | t.Fatalf("failed to set env %s: %v", key, err)
19 | }
20 | }
21 |
22 | // unsetEnvHelper unsets an environment variable and fails the test if it cannot.
23 | func unsetEnvHelper(t *testing.T, key string) {
24 | t.Helper()
25 | if err := os.Unsetenv(key); err != nil {
26 | t.Fatalf("failed to unset env %s: %v", key, err)
27 | }
28 | }
29 |
30 | // TestInit verifies that the logger initializes correctly with various log levels.
31 | func TestInit(t *testing.T) {
32 | tests := []struct {
33 | name string
34 | level string
35 | logLevel string // level to log at
36 | wantLog bool // whether we expect the message to appear
37 | }{
38 | {"debug level logs debug", "debug", "debug", true},
39 | {"debug level logs info", "debug", "info", true},
40 | {"info level logs info", "info", "info", true},
41 | {"info level skips debug", "info", "debug", false},
42 | {"warn level logs warn", "warn", "warn", true},
43 | {"warn level skips info", "warn", "info", false},
44 | {"error level logs error", "error", "error", true},
45 | {"error level skips warn", "error", "warn", false},
46 | {"invalid level defaults to info", "invalid", "info", true},
47 | }
48 |
49 | for _, tt := range tests {
50 | t.Run(tt.name, func(t *testing.T) {
51 | // Reset environment
52 | unsetEnvHelper(t, "LOG_OUTPUT")
53 | unsetEnvHelper(t, "LOG_FILE_PATH")
54 | unsetEnvHelper(t, "LOG_FORMAT")
55 |
56 | // Capture output
57 | var buf bytes.Buffer
58 | logger.Init(tt.level)
59 | logger.SetOutput(&buf)
60 |
61 | // Log a message at the specified level
62 | switch tt.logLevel {
63 | case "debug":
64 | logger.Get().Debug().Msg("test message")
65 | case "info":
66 | logger.Get().Info().Msg("test message")
67 | case "warn":
68 | logger.Get().Warn().Msg("test message")
69 | case "error":
70 | logger.Get().Error().Msg("test message")
71 | }
72 |
73 | hasMessage := strings.Contains(buf.String(), "test message")
74 | if tt.wantLog && !hasMessage {
75 | t.Errorf("Expected log output to contain 'test message', got: %s", buf.String())
76 | }
77 | if !tt.wantLog && hasMessage {
78 | t.Errorf("Expected log output NOT to contain 'test message', got: %s", buf.String())
79 | }
80 | })
81 | }
82 | }
83 |
84 | // TestLoggerGet verifies that Get returns a non-nil logger.
85 | func TestLoggerGet(t *testing.T) {
86 | logger.Init("info")
87 | log := logger.Get()
88 | if log == nil {
89 | t.Error("Expected Get() to return a non-nil logger")
90 | }
91 | }
92 |
93 | // TestSetOutput verifies that SetOutput changes the log destination.
94 | func TestSetOutput(t *testing.T) {
95 | logger.Init("info")
96 |
97 | var buf bytes.Buffer
98 | logger.SetOutput(&buf)
99 |
100 | logger.Get().Info().Msg("custom output test")
101 |
102 | if !strings.Contains(buf.String(), "custom output test") {
103 | t.Errorf("Expected log output to contain 'custom output test', got: %s", buf.String())
104 | }
105 | }
106 |
107 | // TestJSONFormat verifies that JSON format produces valid JSON output.
108 | func TestJSONFormat(t *testing.T) {
109 | setEnvHelper(t, "LOG_OUTPUT", "stdout")
110 | setEnvHelper(t, "LOG_FORMAT", "json")
111 | defer func() {
112 | unsetEnvHelper(t, "LOG_OUTPUT")
113 | unsetEnvHelper(t, "LOG_FORMAT")
114 | }()
115 |
116 | logger.Init("info")
117 |
118 | var buf bytes.Buffer
119 | logger.SetOutput(&buf)
120 |
121 | logger.Get().Info().Str("key", "value").Msg("json test")
122 |
123 | // Parse the JSON output
124 | output := strings.TrimSpace(buf.String())
125 | var result map[string]interface{}
126 | if err := json.Unmarshal([]byte(output), &result); err != nil {
127 | t.Errorf("Expected valid JSON output, got error: %v, output: %s", err, output)
128 | }
129 |
130 | // Verify expected fields
131 | if result["message"] != "json test" {
132 | t.Errorf("Expected message 'json test', got: %v", result["message"])
133 | }
134 | if result["key"] != "value" {
135 | t.Errorf("Expected key 'value', got: %v", result["key"])
136 | }
137 | }
138 |
139 | // TestHTTPEvent verifies that HTTPEvent logs HTTP request events correctly.
140 | func TestHTTPEvent(t *testing.T) {
141 | logger.Init("debug")
142 |
143 | var buf bytes.Buffer
144 | logger.SetOutput(&buf)
145 |
146 | logger.HTTPEvent("GET", "/api/test", 200, 150).Msg("")
147 |
148 | output := buf.String()
149 | if !strings.Contains(output, `"method":"GET"`) {
150 | t.Errorf("Expected log to contain HTTP method 'GET', got: %s", output)
151 | }
152 | if !strings.Contains(output, `"path":"/api/test"`) {
153 | t.Errorf("Expected log to contain path '/api/test', got: %s", output)
154 | }
155 | if !strings.Contains(output, `"status":200`) {
156 | t.Errorf("Expected log to contain status code '200', got: %s", output)
157 | }
158 | if !strings.Contains(output, `"duration_ms":150`) {
159 | t.Errorf("Expected log to contain duration '150', got: %s", output)
160 | }
161 | }
162 |
163 | // TestHTTPError verifies that HTTPError logs HTTP errors correctly.
164 | func TestHTTPError(t *testing.T) {
165 | logger.Init("debug")
166 |
167 | var buf bytes.Buffer
168 | logger.SetOutput(&buf)
169 |
170 | err := fmt.Errorf("test error")
171 | logger.HTTPError("POST", "/api/error", 500, err).Msg("")
172 |
173 | output := buf.String()
174 | if !strings.Contains(output, `"method":"POST"`) {
175 | t.Errorf("Expected log to contain HTTP method 'POST', got: %s", output)
176 | }
177 | if !strings.Contains(output, `"path":"/api/error"`) {
178 | t.Errorf("Expected log to contain path '/api/error', got: %s", output)
179 | }
180 | if !strings.Contains(output, `"status":500`) {
181 | t.Errorf("Expected log to contain status code '500', got: %s", output)
182 | }
183 | if !strings.Contains(output, `"error":"test error"`) {
184 | t.Errorf("Expected log to contain error message, got: %s", output)
185 | }
186 | }
187 |
188 | // TestPanicEvent verifies that PanicEvent logs panic events correctly.
189 | func TestPanicEvent(t *testing.T) {
190 | logger.Init("debug")
191 |
192 | var buf bytes.Buffer
193 | logger.SetOutput(&buf)
194 |
195 | logger.PanicEvent("panic message", "stack trace").Msg("")
196 |
197 | output := buf.String()
198 | if !strings.Contains(output, `"error":"panic message"`) {
199 | t.Errorf("Expected log to contain panic message, got: %s", output)
200 | }
201 | if !strings.Contains(output, `"stack":"stack trace"`) {
202 | t.Errorf("Expected log to contain stack trace, got: %s", output)
203 | }
204 | }
205 |
--------------------------------------------------------------------------------
/app/internal/vault/real_client_test.go:
--------------------------------------------------------------------------------
1 | package vault
2 |
3 | import (
4 | "context"
5 | "crypto/rand"
6 | "crypto/rsa"
7 | "crypto/x509"
8 | "crypto/x509/pkix"
9 | "encoding/json"
10 | "encoding/pem"
11 | "math/big"
12 | "net/http"
13 | "net/http/httptest"
14 | "testing"
15 | "time"
16 |
17 | "vcv/config"
18 | "vcv/internal/cache"
19 |
20 | "github.com/hashicorp/vault/api"
21 | )
22 |
23 | type vaultTestServerState struct {
24 | certificatePEM string
25 | }
26 |
27 | func newVaultTestCertificatePEM(t *testing.T) string {
28 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
29 | if err != nil {
30 | t.Fatalf("failed to generate key: %v", err)
31 | }
32 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
33 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
34 | if err != nil {
35 | t.Fatalf("failed to generate serial: %v", err)
36 | }
37 | template := x509.Certificate{
38 | SerialNumber: serialNumber,
39 | Subject: pkix.Name{
40 | CommonName: "test.example.com",
41 | },
42 | NotBefore: time.Now().Add(-1 * time.Hour),
43 | NotAfter: time.Now().Add(24 * time.Hour),
44 | DNSNames: []string{"test.example.com"},
45 | }
46 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
47 | if err != nil {
48 | t.Fatalf("failed to create certificate: %v", err)
49 | }
50 | block := pem.Block{Type: "CERTIFICATE", Bytes: derBytes}
51 | return string(pem.EncodeToMemory(&block))
52 | }
53 |
54 | func newVaultTestServer(state vaultTestServerState) *httptest.Server {
55 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56 | w.Header().Set("Content-Type", "application/json")
57 | if r.URL.Path == "/v1/sys/health" {
58 | w.WriteHeader(http.StatusOK)
59 | _ = json.NewEncoder(w).Encode(map[string]interface{}{"initialized": true, "sealed": false})
60 | return
61 | }
62 | if (r.Method == "LIST" || (r.Method == http.MethodGet && r.URL.Query().Get("list") == "true")) && r.URL.Path == "/v1/pki/certs" {
63 | w.WriteHeader(http.StatusOK)
64 | _ = json.NewEncoder(w).Encode(map[string]interface{}{"data": map[string]interface{}{"keys": []string{"aa", "bb"}}})
65 | return
66 | }
67 | if (r.Method == "LIST" || (r.Method == http.MethodGet && r.URL.Query().Get("list") == "true")) && r.URL.Path == "/v1/pki/certs/revoked" {
68 | w.WriteHeader(http.StatusOK)
69 | _ = json.NewEncoder(w).Encode(map[string]interface{}{"data": map[string]interface{}{"keys": []string{"bb"}}})
70 | return
71 | }
72 | if r.Method == http.MethodGet && (r.URL.Path == "/v1/pki/cert/aa" || r.URL.Path == "/v1/pki/cert/bb") {
73 | w.WriteHeader(http.StatusOK)
74 | _ = json.NewEncoder(w).Encode(map[string]interface{}{"data": map[string]interface{}{"certificate": state.certificatePEM}})
75 | return
76 | }
77 | w.WriteHeader(http.StatusNotFound)
78 | })
79 | return httptest.NewServer(handler)
80 | }
81 |
82 | func newRealClientForTest(t *testing.T, serverURL string, mounts []string) *realClient {
83 | clientConfig := api.DefaultConfig()
84 | if clientConfig == nil {
85 | t.Fatalf("expected default config")
86 | }
87 | clientConfig.Address = serverURL
88 | apiClient, err := api.NewClient(clientConfig)
89 | if err != nil {
90 | t.Fatalf("failed to create api client: %v", err)
91 | }
92 | apiClient.SetToken("token")
93 | return &realClient{client: apiClient, mounts: mounts, addr: serverURL, cache: cache.New(5 * time.Minute), stopChan: make(chan struct{})}
94 | }
95 |
96 | func TestNewClientFromConfig_Validation(t *testing.T) {
97 | tests := []struct {
98 | name string
99 | cfg config.VaultConfig
100 | }{
101 | {name: "empty address", cfg: config.VaultConfig{Addr: "", ReadToken: "token"}},
102 | {name: "empty token", cfg: config.VaultConfig{Addr: "http://localhost:8200", ReadToken: ""}},
103 | }
104 | for _, tt := range tests {
105 | t.Run(tt.name, func(t *testing.T) {
106 | client, err := NewClientFromConfig(tt.cfg)
107 | if err == nil {
108 | t.Fatalf("expected error")
109 | }
110 | if client != nil {
111 | t.Fatalf("expected nil client")
112 | }
113 | })
114 | }
115 | }
116 |
117 | func TestParseMountAndSerial(t *testing.T) {
118 | client := &realClient{mounts: []string{"pki", "pki_dev"}}
119 | tests := []struct {
120 | name string
121 | value string
122 | expectedMnt string
123 | expectedSer string
124 | expectErr bool
125 | }{
126 | {name: "prefixed configured mount", value: "pki:aa", expectedMnt: "pki", expectedSer: "aa", expectErr: false},
127 | {name: "prefixed unconfigured mount", value: "unknown:aa", expectErr: true},
128 | {name: "legacy no prefix", value: "aa", expectedMnt: "pki", expectedSer: "aa", expectErr: false},
129 | }
130 | for _, tt := range tests {
131 | t.Run(tt.name, func(t *testing.T) {
132 | mount, serial, err := client.parseMountAndSerial(tt.value)
133 | if tt.expectErr {
134 | if err == nil {
135 | t.Fatalf("expected error")
136 | }
137 | return
138 | }
139 | if err != nil {
140 | t.Fatalf("unexpected error: %v", err)
141 | }
142 | if mount != tt.expectedMnt {
143 | t.Fatalf("expected mount %q, got %q", tt.expectedMnt, mount)
144 | }
145 | if serial != tt.expectedSer {
146 | t.Fatalf("expected serial %q, got %q", tt.expectedSer, serial)
147 | }
148 | })
149 | }
150 | clientNoMounts := &realClient{mounts: []string{}}
151 | _, _, err := clientNoMounts.parseMountAndSerial("aa")
152 | if err == nil {
153 | t.Fatalf("expected error")
154 | }
155 | }
156 |
157 | func TestCheckConnection(t *testing.T) {
158 | certificatePEM := newVaultTestCertificatePEM(t)
159 | server := newVaultTestServer(vaultTestServerState{certificatePEM: certificatePEM})
160 | defer server.Close()
161 | client := newRealClientForTest(t, server.URL, []string{"pki"})
162 | err := client.CheckConnection(context.Background())
163 | if err != nil {
164 | t.Fatalf("expected no error, got %v", err)
165 | }
166 | }
167 |
168 | func TestRealClient_ListCertificates_And_Details(t *testing.T) {
169 | certificatePEM := newVaultTestCertificatePEM(t)
170 | server := newVaultTestServer(vaultTestServerState{certificatePEM: certificatePEM})
171 | defer server.Close()
172 | client := newRealClientForTest(t, server.URL, []string{"pki"})
173 | ctx := context.Background()
174 | certificates, err := client.ListCertificates(ctx)
175 | if err != nil {
176 | t.Fatalf("expected no error, got %v", err)
177 | }
178 | if len(certificates) != 2 {
179 | t.Fatalf("expected 2 certificates, got %d", len(certificates))
180 | }
181 | details, err := client.GetCertificateDetails(ctx, "pki:aa")
182 | if err != nil {
183 | t.Fatalf("expected no error, got %v", err)
184 | }
185 | if details.SerialNumber != "aa" {
186 | t.Fatalf("expected serial %q, got %q", "aa", details.SerialNumber)
187 | }
188 | if details.PEM == "" {
189 | t.Fatalf("expected pem")
190 | }
191 | pemResponse, err := client.GetCertificatePEM(ctx, "pki:bb")
192 | if err != nil {
193 | t.Fatalf("expected no error, got %v", err)
194 | }
195 | if pemResponse.SerialNumber != "bb" {
196 | t.Fatalf("expected serial %q, got %q", "bb", pemResponse.SerialNumber)
197 | }
198 | if pemResponse.PEM == "" {
199 | t.Fatalf("expected pem")
200 | }
201 | client.InvalidateCache()
202 | client.Shutdown()
203 | }
204 |
--------------------------------------------------------------------------------
/app/internal/handlers/i18n_test.go:
--------------------------------------------------------------------------------
1 | package handlers_test
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "vcv/internal/handlers"
10 | "vcv/internal/i18n"
11 |
12 | "github.com/go-chi/chi/v5"
13 | )
14 |
15 | func TestRegisterI18nRoutes_Success(t *testing.T) {
16 | router := chi.NewRouter()
17 | handlers.RegisterI18nRoutes(router)
18 |
19 | req := httptest.NewRequest(http.MethodGet, "/api/i18n", nil)
20 | rec := httptest.NewRecorder()
21 |
22 | router.ServeHTTP(rec, req)
23 |
24 | if rec.Code != http.StatusOK {
25 | t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
26 | }
27 |
28 | // Verify JSON response
29 | var response i18n.Response
30 | if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
31 | t.Fatalf("failed to unmarshal response: %v", err)
32 | }
33 |
34 | if response.Language == "" {
35 | t.Error("expected language to be set")
36 | }
37 |
38 | if len(response.Messages.AppTitle) == 0 {
39 | t.Error("expected messages to be populated")
40 | }
41 | }
42 |
43 | func TestRegisterI18nRoutes_WithQueryLang(t *testing.T) {
44 | router := chi.NewRouter()
45 | handlers.RegisterI18nRoutes(router)
46 |
47 | req := httptest.NewRequest(http.MethodGet, "/api/i18n?lang=fr", nil)
48 | rec := httptest.NewRecorder()
49 |
50 | router.ServeHTTP(rec, req)
51 |
52 | if rec.Code != http.StatusOK {
53 | t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
54 | }
55 |
56 | var response i18n.Response
57 | if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
58 | t.Fatalf("failed to unmarshal response: %v", err)
59 | }
60 |
61 | if response.Language != i18n.LanguageFrench {
62 | t.Errorf("expected language %s, got %s", i18n.LanguageFrench, response.Language)
63 | }
64 | }
65 |
66 | func TestRegisterI18nRoutes_WithHXCurrentURL(t *testing.T) {
67 | router := chi.NewRouter()
68 | handlers.RegisterI18nRoutes(router)
69 |
70 | req := httptest.NewRequest(http.MethodGet, "/api/i18n", nil)
71 | req.Header.Set("HX-Current-URL", "https://example.com?lang=es")
72 | rec := httptest.NewRecorder()
73 |
74 | router.ServeHTTP(rec, req)
75 |
76 | if rec.Code != http.StatusOK {
77 | t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
78 | }
79 |
80 | var response i18n.Response
81 | if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
82 | t.Fatalf("failed to unmarshal response: %v", err)
83 | }
84 |
85 | if response.Language != i18n.LanguageSpanish {
86 | t.Errorf("expected language %s, got %s", i18n.LanguageSpanish, response.Language)
87 | }
88 | }
89 |
90 | func TestRegisterI18nRoutes_WithAcceptLanguage(t *testing.T) {
91 | router := chi.NewRouter()
92 | handlers.RegisterI18nRoutes(router)
93 |
94 | req := httptest.NewRequest(http.MethodGet, "/api/i18n", nil)
95 | req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8")
96 | rec := httptest.NewRecorder()
97 |
98 | router.ServeHTTP(rec, req)
99 |
100 | if rec.Code != http.StatusOK {
101 | t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
102 | }
103 |
104 | var response i18n.Response
105 | if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
106 | t.Fatalf("failed to unmarshal response: %v", err)
107 | }
108 |
109 | // Should default to French based on Accept-Language
110 | if response.Language != i18n.LanguageFrench {
111 | t.Errorf("expected language %s, got %s", i18n.LanguageFrench, response.Language)
112 | }
113 | }
114 |
115 | func TestRegisterI18nRoutes_InvalidQueryLang(t *testing.T) {
116 | router := chi.NewRouter()
117 | handlers.RegisterI18nRoutes(router)
118 |
119 | req := httptest.NewRequest(http.MethodGet, "/api/i18n?lang=invalid", nil)
120 | rec := httptest.NewRecorder()
121 |
122 | router.ServeHTTP(rec, req)
123 |
124 | if rec.Code != http.StatusOK {
125 | t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
126 | }
127 |
128 | var response i18n.Response
129 | if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
130 | t.Fatalf("failed to unmarshal response: %v", err)
131 | }
132 |
133 | // Should fall back to Accept-Language or default
134 | if response.Language == "" {
135 | t.Error("expected language to be set even with invalid query param")
136 | }
137 | }
138 |
139 | func TestRegisterI18nRoutes_InvalidHXCurrentURL(t *testing.T) {
140 | router := chi.NewRouter()
141 | handlers.RegisterI18nRoutes(router)
142 |
143 | req := httptest.NewRequest(http.MethodGet, "/api/i18n", nil)
144 | req.Header.Set("HX-Current-URL", "not-a-valid-url")
145 | rec := httptest.NewRecorder()
146 |
147 | router.ServeHTTP(rec, req)
148 |
149 | if rec.Code != http.StatusOK {
150 | t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
151 | }
152 |
153 | // Should handle invalid URL gracefully
154 | var response i18n.Response
155 | if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
156 | t.Fatalf("failed to unmarshal response: %v", err)
157 | }
158 | }
159 |
160 | func TestResolveLanguage_QueryParamPriority(t *testing.T) {
161 | // Test that query param takes priority over other methods
162 | req := httptest.NewRequest(http.MethodGet, "/api/i18n?lang=de", nil)
163 | req.Header.Set("HX-Current-URL", "https://example.com?lang=fr")
164 | req.Header.Set("Accept-Language", "es-ES,es;q=0.9")
165 |
166 | // We need to access the resolveLanguage function through the handler
167 | // Since it's not exported, we test it indirectly via the handler
168 | router := chi.NewRouter()
169 | handlers.RegisterI18nRoutes(router)
170 | rec := httptest.NewRecorder()
171 |
172 | router.ServeHTTP(rec, req)
173 |
174 | var response i18n.Response
175 | if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
176 | t.Fatalf("failed to unmarshal response: %v", err)
177 | }
178 |
179 | // Query param should win
180 | if response.Language != i18n.LanguageGerman {
181 | t.Errorf("expected language %s from query param, got %s", i18n.LanguageGerman, response.Language)
182 | }
183 | }
184 |
185 | func TestResolveLanguage_HXCurrentURLOverAcceptLanguage(t *testing.T) {
186 | // Test that HX-Current-URL takes priority over Accept-Language
187 | req := httptest.NewRequest(http.MethodGet, "/api/i18n", nil)
188 | req.Header.Set("HX-Current-URL", "https://example.com?lang=it")
189 | req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9")
190 |
191 | router := chi.NewRouter()
192 | handlers.RegisterI18nRoutes(router)
193 | rec := httptest.NewRecorder()
194 |
195 | router.ServeHTTP(rec, req)
196 |
197 | var response i18n.Response
198 | if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
199 | t.Fatalf("failed to unmarshal response: %v", err)
200 | }
201 |
202 | // HX-Current-URL should win over Accept-Language
203 | if response.Language != i18n.LanguageItalian {
204 | t.Errorf("expected language %s from HX-Current-URL, got %s", i18n.LanguageItalian, response.Language)
205 | }
206 | }
207 |
208 | func TestResolveLanguage_ParsedURLWithInvalidLang(t *testing.T) {
209 | // Test when HX-Current-URL has an invalid language
210 | req := httptest.NewRequest(http.MethodGet, "/api/i18n", nil)
211 | req.Header.Set("HX-Current-URL", "https://example.com?lang=invalid")
212 | req.Header.Set("Accept-Language", "de-DE,de;q=0.9")
213 |
214 | router := chi.NewRouter()
215 | handlers.RegisterI18nRoutes(router)
216 | rec := httptest.NewRecorder()
217 |
218 | router.ServeHTTP(rec, req)
219 |
220 | var response i18n.Response
221 | if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
222 | t.Fatalf("failed to unmarshal response: %v", err)
223 | }
224 |
225 | // Should fall back to Accept-Language
226 | if response.Language != i18n.LanguageGerman {
227 | t.Errorf("expected language %s from Accept-Language, got %s", i18n.LanguageGerman, response.Language)
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/app/go.sum:
--------------------------------------------------------------------------------
1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
3 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
4 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
7 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
8 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
12 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
13 | github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
14 | github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
15 | github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
16 | github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
17 | github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
18 | github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
19 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
20 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
21 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
22 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
23 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
24 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
25 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
26 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
27 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
28 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
29 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
30 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
31 | github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
32 | github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
33 | github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
34 | github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
35 | github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
36 | github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
37 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
38 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
39 | github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
40 | github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
41 | github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
42 | github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
43 | github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0=
44 | github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM=
45 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
46 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
47 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
48 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
49 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
50 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
51 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
52 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
53 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
54 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
55 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
56 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
57 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
58 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
59 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
60 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
61 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
62 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
63 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
64 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
65 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
66 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
67 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
68 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
69 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
70 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
71 | github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
72 | github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
73 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
74 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
75 | github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
76 | github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
77 | github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
78 | github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
79 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
80 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
81 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
82 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
83 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
84 | github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
85 | github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
86 | github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
87 | github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
88 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
89 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
90 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
91 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
92 | go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
93 | go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
94 | golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
95 | golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
96 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
97 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
98 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
99 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
100 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
101 | golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
102 | golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
103 | golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
104 | golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
105 | google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
106 | google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
107 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
108 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
109 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
110 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
111 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
112 |
--------------------------------------------------------------------------------
/app/internal/handlers/certs.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | "net/url"
7 | "strings"
8 |
9 | "github.com/go-chi/chi/v5"
10 |
11 | "vcv/internal/certs"
12 | "vcv/internal/logger"
13 | "vcv/internal/vault"
14 | "vcv/middleware"
15 | )
16 |
17 | const mountsAllSentinel = "__all__"
18 |
19 | func RegisterCertRoutes(r chi.Router, vaultClient vault.Client) {
20 | r.Get("/api/certs", func(w http.ResponseWriter, req *http.Request) {
21 | // Parse mount filter from query parameters
22 | selectedMounts := parseMountsQueryParam(req.URL.Query())
23 |
24 | certificates, err := vaultClient.ListCertificates(req.Context())
25 | if err != nil {
26 | requestID := middleware.GetRequestID(req.Context())
27 | logger.HTTPError(req.Method, req.URL.Path, http.StatusInternalServerError, err).
28 | Str("request_id", requestID).
29 | Msg("failed to list certificates")
30 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
31 | return
32 | }
33 |
34 | // Filter certificates by selected mounts
35 | filteredCertificates := filterCertificatesByMounts(certificates, selectedMounts)
36 |
37 | w.Header().Set("Content-Type", "application/json")
38 | if err := json.NewEncoder(w).Encode(filteredCertificates); err != nil {
39 | requestID := middleware.GetRequestID(req.Context())
40 | logger.HTTPError(req.Method, req.URL.Path, http.StatusInternalServerError, err).
41 | Str("request_id", requestID).
42 | Msg("failed to encode certificates response")
43 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
44 | return
45 | }
46 | requestID := middleware.GetRequestID(req.Context())
47 | logger.HTTPEvent(req.Method, req.URL.Path, http.StatusOK, 0).
48 | Str("request_id", requestID).
49 | Int("count", len(filteredCertificates)).
50 | Strs("mounts", selectedMounts).
51 | Msg("listed certificates")
52 | })
53 |
54 | r.Get("/api/certs/{id}/details", func(w http.ResponseWriter, req *http.Request) {
55 | certificateIDParam := chi.URLParam(req, "id")
56 | if certificateIDParam == "" {
57 | requestID := middleware.GetRequestID(req.Context())
58 | logger.HTTPError(req.Method, req.URL.Path, http.StatusBadRequest, nil).
59 | Str("request_id", requestID).
60 | Msg("missing certificate id in path")
61 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
62 | return
63 | }
64 | certificateID, err := url.PathUnescape(certificateIDParam)
65 | if err != nil {
66 | requestID := middleware.GetRequestID(req.Context())
67 | logger.HTTPError(req.Method, req.URL.Path, http.StatusBadRequest, err).
68 | Str("request_id", requestID).
69 | Msg("failed to decode certificate id")
70 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
71 | return
72 | }
73 |
74 | details, err := vaultClient.GetCertificateDetails(req.Context(), certificateID)
75 | if err != nil {
76 | requestID := middleware.GetRequestID(req.Context())
77 | logger.HTTPError(req.Method, req.URL.Path, http.StatusInternalServerError, err).
78 | Str("request_id", requestID).
79 | Str("serial_number", certificateID).
80 | Msg("failed to get certificate details")
81 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
82 | return
83 | }
84 |
85 | w.Header().Set("Content-Type", "application/json")
86 | if err := json.NewEncoder(w).Encode(details); err != nil {
87 | requestID := middleware.GetRequestID(req.Context())
88 | logger.HTTPError(req.Method, req.URL.Path, http.StatusInternalServerError, err).
89 | Str("request_id", requestID).
90 | Msg("failed to encode certificate details response")
91 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
92 | return
93 | }
94 | requestID := middleware.GetRequestID(req.Context())
95 | logger.HTTPEvent(req.Method, req.URL.Path, http.StatusOK, 0).
96 | Str("request_id", requestID).
97 | Str("serial_number", certificateID).
98 | Msg("fetched certificate details")
99 | })
100 |
101 | r.Get("/api/certs/{id}/pem", func(w http.ResponseWriter, req *http.Request) {
102 | certificateIDParam := chi.URLParam(req, "id")
103 | if certificateIDParam == "" {
104 | requestID := middleware.GetRequestID(req.Context())
105 | logger.HTTPError(req.Method, req.URL.Path, http.StatusBadRequest, nil).
106 | Str("request_id", requestID).
107 | Msg("missing certificate id in path")
108 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
109 | return
110 | }
111 | certificateID, err := url.PathUnescape(certificateIDParam)
112 | if err != nil {
113 | requestID := middleware.GetRequestID(req.Context())
114 | logger.HTTPError(req.Method, req.URL.Path, http.StatusBadRequest, err).
115 | Str("request_id", requestID).
116 | Msg("failed to decode certificate id")
117 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
118 | return
119 | }
120 |
121 | pemResponse, err := vaultClient.GetCertificatePEM(req.Context(), certificateID)
122 | if err != nil {
123 | requestID := middleware.GetRequestID(req.Context())
124 | logger.HTTPError(req.Method, req.URL.Path, http.StatusInternalServerError, err).
125 | Str("request_id", requestID).
126 | Str("serial_number", certificateID).
127 | Msg("failed to get certificate PEM")
128 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
129 | return
130 | }
131 |
132 | w.Header().Set("Content-Type", "application/json")
133 | if err := json.NewEncoder(w).Encode(pemResponse); err != nil {
134 | requestID := middleware.GetRequestID(req.Context())
135 | logger.HTTPError(req.Method, req.URL.Path, http.StatusInternalServerError, err).
136 | Str("request_id", requestID).
137 | Msg("failed to encode certificate PEM response")
138 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
139 | return
140 | }
141 | requestID := middleware.GetRequestID(req.Context())
142 | logger.HTTPEvent(req.Method, req.URL.Path, http.StatusOK, 0).
143 | Str("request_id", requestID).
144 | Str("serial_number", certificateID).
145 | Msg("served certificate PEM")
146 | })
147 |
148 | r.Get("/api/certs/{id}/pem/download", func(w http.ResponseWriter, req *http.Request) {
149 | certificateIDParam := chi.URLParam(req, "id")
150 | if certificateIDParam == "" {
151 | requestID := middleware.GetRequestID(req.Context())
152 | logger.HTTPError(req.Method, req.URL.Path, http.StatusBadRequest, nil).
153 | Str("request_id", requestID).
154 | Msg("missing certificate id in path")
155 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
156 | return
157 | }
158 | certificateID, err := url.PathUnescape(certificateIDParam)
159 | if err != nil {
160 | requestID := middleware.GetRequestID(req.Context())
161 | logger.HTTPError(req.Method, req.URL.Path, http.StatusBadRequest, err).
162 | Str("request_id", requestID).
163 | Msg("failed to decode certificate id")
164 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
165 | return
166 | }
167 | pemResponse, err := vaultClient.GetCertificatePEM(req.Context(), certificateID)
168 | if err != nil {
169 | requestID := middleware.GetRequestID(req.Context())
170 | logger.HTTPError(req.Method, req.URL.Path, http.StatusInternalServerError, err).
171 | Str("request_id", requestID).
172 | Str("serial_number", certificateID).
173 | Msg("failed to get certificate PEM")
174 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
175 | return
176 | }
177 | filename := buildPEMDownloadFilename(pemResponse.SerialNumber)
178 | w.Header().Set("Content-Type", "application/x-pem-file")
179 | w.Header().Set("Content-Disposition", "attachment; filename="+filename)
180 | w.WriteHeader(http.StatusOK)
181 | if _, writeErr := w.Write([]byte(pemResponse.PEM)); writeErr != nil {
182 | requestID := middleware.GetRequestID(req.Context())
183 | logger.HTTPError(req.Method, req.URL.Path, http.StatusInternalServerError, writeErr).
184 | Str("request_id", requestID).
185 | Msg("failed to write certificate PEM download")
186 | return
187 | }
188 | requestID := middleware.GetRequestID(req.Context())
189 | logger.HTTPEvent(req.Method, req.URL.Path, http.StatusOK, 0).
190 | Str("request_id", requestID).
191 | Str("serial_number", certificateID).
192 | Msg("downloaded certificate PEM")
193 | })
194 |
195 | r.Post("/api/cache/invalidate", func(w http.ResponseWriter, req *http.Request) {
196 | vaultClient.InvalidateCache()
197 | w.WriteHeader(http.StatusNoContent)
198 | requestID := middleware.GetRequestID(req.Context())
199 | logger.HTTPEvent(req.Method, req.URL.Path, http.StatusNoContent, 0).
200 | Str("request_id", requestID).
201 | Msg("invalidated cache")
202 | })
203 | }
204 |
205 | func parseMountsQueryParam(query url.Values) []string {
206 | _, present := query["mounts"]
207 | if !present {
208 | return nil
209 | }
210 | raw := strings.TrimSpace(query.Get("mounts"))
211 | if raw == mountsAllSentinel {
212 | return nil
213 | }
214 | if raw == "" {
215 | return []string{}
216 | }
217 | parts := strings.Split(raw, ",")
218 | mounts := make([]string, 0, len(parts))
219 | for _, part := range parts {
220 | trimmed := strings.TrimSpace(part)
221 | if trimmed == "" {
222 | continue
223 | }
224 | mounts = append(mounts, trimmed)
225 | }
226 | return mounts
227 | }
228 |
229 | // filterCertificatesByMounts filters certificates by the specified mounts
230 | func filterCertificatesByMounts(certificates []certs.Certificate, selectedMounts []string) []certs.Certificate {
231 | if selectedMounts == nil {
232 | return certificates
233 | }
234 | if len(selectedMounts) == 0 {
235 | return []certs.Certificate{}
236 | }
237 |
238 | var filtered []certs.Certificate
239 | for _, cert := range certificates {
240 | // Extract mount from certificate ID (format: "mount:serial")
241 | parts := strings.SplitN(cert.ID, ":", 2)
242 | if len(parts) >= 1 {
243 | mount := parts[0]
244 | for _, selectedMount := range selectedMounts {
245 | if mount == selectedMount {
246 | filtered = append(filtered, cert)
247 | break
248 | }
249 | }
250 | }
251 | }
252 |
253 | return filtered
254 | }
255 |
256 | func buildPEMDownloadFilename(serialNumber string) string {
257 | replacer := strings.NewReplacer(":", "-", "/", "-", "\\", "-", "..", "-")
258 | safe := replacer.Replace(serialNumber)
259 | if safe == "" {
260 | return "certificate.pem"
261 | }
262 | return "certificate-" + safe + ".pem"
263 | }
264 |
--------------------------------------------------------------------------------
/app/internal/handlers/ui_test.go:
--------------------------------------------------------------------------------
1 | package handlers_test
2 |
3 | import (
4 | "errors"
5 | "io/fs"
6 | "net/http"
7 | "net/http/httptest"
8 | "strings"
9 | "testing"
10 | "testing/fstest"
11 | "time"
12 |
13 | "github.com/go-chi/chi/v5"
14 | "github.com/stretchr/testify/assert"
15 | "github.com/stretchr/testify/mock"
16 |
17 | "vcv/config"
18 | "vcv/internal/certs"
19 | "vcv/internal/handlers"
20 | "vcv/internal/vault"
21 | "vcv/middleware"
22 | )
23 |
24 | func setupUIRouter(mockVault *vault.MockClient, webFS fs.FS) *chi.Mux {
25 | router := chi.NewRouter()
26 | router.Use(middleware.RequestID)
27 | handlers.RegisterUIRoutes(router, mockVault, webFS, config.ExpirationThresholds{Critical: 7, Warning: 30})
28 | return router
29 | }
30 |
31 | func TestToggleThemeFragment(t *testing.T) {
32 | webFS := fstest.MapFS{
33 | "templates/cert-details.html": &fstest.MapFile{Data: []byte("")},
34 | "templates/footer-status.html": &fstest.MapFile{Data: []byte("")},
35 | "templates/certs-fragment.html": &fstest.MapFile{Data: []byte("{{define \"certs-fragment\"}}{{end}}")},
36 | "templates/certs-rows.html": &fstest.MapFile{Data: []byte("{{define \"certs-rows\"}}{{end}}")},
37 | "templates/certs-state.html": &fstest.MapFile{Data: []byte("{{define \"certs-state\"}}{{end}}")},
38 | "templates/certs-pagination.html": &fstest.MapFile{Data: []byte("{{define \"certs-pagination\"}}{{end}}")},
39 | "templates/certs-sort.html": &fstest.MapFile{Data: []byte("{{define \"certs-sort\"}}{{end}}")},
40 | "templates/dashboard-fragment.html": &fstest.MapFile{Data: []byte("{{define \"dashboard-fragment\"}}{{end}}")},
41 | "templates/theme-toggle-fragment.html": &fstest.MapFile{Data: []byte("{{.Icon}}")},
42 | }
43 | mockVault := &vault.MockClient{}
44 | router := setupUIRouter(mockVault, webFS)
45 | req := httptest.NewRequest(http.MethodPost, "/ui/theme/toggle", strings.NewReader("theme=light"))
46 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
47 | rec := httptest.NewRecorder()
48 | router.ServeHTTP(rec, req)
49 | assert.Equal(t, http.StatusOK, rec.Code)
50 | body := rec.Body.String()
51 | assert.Contains(t, body, "id=\"theme-icon\"")
52 | assert.Contains(t, body, "value=\"dark\"")
53 | }
54 |
55 | func TestGetCertificateDetailsUI(t *testing.T) {
56 | webFS := fstest.MapFS{
57 | "templates/cert-details.html": &fstest.MapFile{Data: []byte("{{.CertificateID}}
")},
58 | "templates/footer-status.html": &fstest.MapFile{Data: []byte("{{.VersionText}}
")},
59 | "templates/certs-fragment.html": &fstest.MapFile{Data: []byte("{{template \"certs-rows\" .}}{{template \"dashboard-fragment\" .}}{{template \"certs-state\" .}}{{template \"certs-pagination\" .}}{{template \"certs-sort\" .}}")},
60 | "templates/certs-rows.html": &fstest.MapFile{Data: []byte("{{define \"certs-rows\"}}{{range .Rows}}{{.CommonName}}
{{end}}{{end}}")},
61 | "templates/dashboard-fragment.html": &fstest.MapFile{Data: []byte("{{define \"dashboard-fragment\"}}{{end}}")},
62 | "templates/theme-toggle-fragment.html": &fstest.MapFile{Data: []byte("{{.Icon}}")},
63 | "templates/certs-state.html": &fstest.MapFile{Data: []byte("{{define \"certs-state\"}}{{end}}")},
64 | "templates/certs-pagination.html": &fstest.MapFile{Data: []byte("{{define \"certs-pagination\"}}{{end}}")},
65 | "templates/certs-sort.html": &fstest.MapFile{Data: []byte("{{define \"certs-sort\"}}{{end}}")},
66 | }
67 | tests := []struct {
68 | name string
69 | path string
70 | setupMock func(mockVault *vault.MockClient)
71 | expectedStatus int
72 | expectedBodyContains string
73 | }{
74 | {
75 | name: "success unescapes id",
76 | path: "/ui/certs/pki_dev%3A33%3Aaa/details",
77 | setupMock: func(mockVault *vault.MockClient) {
78 | mockVault.On("GetCertificateDetails", mock.Anything, "pki_dev:33:aa").Return(certs.DetailedCertificate{Certificate: certs.Certificate{ID: "pki_dev:33:aa", CommonName: "cn", ExpiresAt: time.Now()}, SerialNumber: "33:aa"}, nil)
79 | },
80 | expectedStatus: http.StatusOK,
81 | expectedBodyContains: "pki_dev:33:aa",
82 | },
83 | {
84 | name: "bad request when id is missing",
85 | path: "/ui/certs//details",
86 | setupMock: func(mockVault *vault.MockClient) {},
87 | expectedStatus: http.StatusBadRequest,
88 | expectedBodyContains: "",
89 | },
90 | {
91 | name: "internal server error when vault fails",
92 | path: "/ui/certs/pki_dev%3A33%3Aaa/details",
93 | setupMock: func(mockVault *vault.MockClient) {
94 | mockVault.On("GetCertificateDetails", mock.Anything, "pki_dev:33:aa").Return(certs.DetailedCertificate{}, errors.New("boom"))
95 | },
96 | expectedStatus: http.StatusInternalServerError,
97 | expectedBodyContains: "",
98 | },
99 | }
100 | for _, tt := range tests {
101 | tt := tt
102 | t.Run(tt.name, func(t *testing.T) {
103 | mockVault := new(vault.MockClient)
104 | tt.setupMock(mockVault)
105 | router := setupUIRouter(mockVault, webFS)
106 | req := httptest.NewRequest(http.MethodGet, tt.path, nil)
107 | rec := httptest.NewRecorder()
108 | router.ServeHTTP(rec, req)
109 | assert.Equal(t, tt.expectedStatus, rec.Code)
110 | if tt.expectedBodyContains != "" {
111 | assert.Contains(t, rec.Body.String(), tt.expectedBodyContains)
112 | }
113 | mockVault.AssertExpectations(t)
114 | })
115 | }
116 | }
117 |
118 | func TestGetCertificatesFragment(t *testing.T) {
119 | webFS := fstest.MapFS{
120 | "templates/cert-details.html": &fstest.MapFile{Data: []byte("{{.CertificateID}}
")},
121 | "templates/footer-status.html": &fstest.MapFile{Data: []byte("{{.VersionText}}
")},
122 | "templates/certs-fragment.html": &fstest.MapFile{Data: []byte("{{template \"certs-rows\" .}}{{template \"certs-state\" .}}{{template \"certs-pagination\" .}}{{template \"certs-sort\" .}}{{template \"dashboard-fragment\" .}}")},
123 | "templates/certs-rows.html": &fstest.MapFile{Data: []byte("{{define \"certs-rows\"}}{{range .Rows}}{{.CommonName}}
{{end}}{{end}}")},
124 | "templates/dashboard-fragment.html": &fstest.MapFile{Data: []byte("{{define \"dashboard-fragment\"}}{{end}}")},
125 | "templates/theme-toggle-fragment.html": &fstest.MapFile{Data: []byte("{{.Icon}}")},
126 | "templates/certs-state.html": &fstest.MapFile{Data: []byte("{{define \"certs-state\"}}{{end}}")},
127 | "templates/certs-pagination.html": &fstest.MapFile{Data: []byte("{{define \"certs-pagination\"}}{{end}}")},
128 | "templates/certs-sort.html": &fstest.MapFile{Data: []byte("{{define \"certs-sort\"}}{{end}}")},
129 | }
130 | certificates := []certs.Certificate{
131 | {ID: "pki:a", CommonName: "alpha.example", Sans: []string{"alpha"}, CreatedAt: time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)},
132 | {ID: "pki:b", CommonName: "beta.example", Sans: []string{"beta"}, CreatedAt: time.Date(2025, 2, 1, 10, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2027, 1, 1, 10, 0, 0, 0, time.UTC)},
133 | {ID: "pki:c", CommonName: "gamma.example", Sans: []string{"gamma"}, CreatedAt: time.Date(2025, 3, 1, 10, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2028, 1, 1, 10, 0, 0, 0, time.UTC)},
134 | }
135 | tests := []struct {
136 | name string
137 | path string
138 | headerTrigger string
139 | assertBody func(t *testing.T, body string)
140 | }{
141 | {
142 | name: "success renders rows",
143 | path: "/ui/certs",
144 | assertBody: func(t *testing.T, body string) {
145 | assert.Contains(t, body, "alpha.example")
146 | assert.Contains(t, body, "beta.example")
147 | assert.Contains(t, body, "gamma.example")
148 | },
149 | },
150 | {
151 | name: "search filters",
152 | path: "/ui/certs?search=beta",
153 | assertBody: func(t *testing.T, body string) {
154 | assert.NotContains(t, body, "alpha.example")
155 | assert.Contains(t, body, "beta.example")
156 | assert.NotContains(t, body, "gamma.example")
157 | },
158 | },
159 | {
160 | name: "pagination next advances page",
161 | path: "/ui/certs?pageSize=1&page=0&pageAction=next",
162 | assertBody: func(t *testing.T, body string) {
163 | assert.Contains(t, body, "beta.example")
164 | assert.Contains(t, body, "id=\"vcv-page\" value=\"1\"")
165 | },
166 | },
167 | {
168 | name: "sort toggle changes direction",
169 | path: "/ui/certs?sortKey=commonName&sortDir=asc&sort=commonName",
170 | assertBody: func(t *testing.T, body string) {
171 | assert.Contains(t, body, "id=\"vcv-sort-dir\" value=\"desc\"")
172 | },
173 | },
174 | {
175 | name: "mounts empty returns no rows",
176 | path: "/ui/certs?mounts=",
177 | assertBody: func(t *testing.T, body string) {
178 | assert.NotContains(t, body, "alpha.example")
179 | assert.NotContains(t, body, "beta.example")
180 | assert.NotContains(t, body, "gamma.example")
181 | },
182 | },
183 | {
184 | name: "mounts sentinel returns all rows",
185 | path: "/ui/certs?mounts=__all__",
186 | assertBody: func(t *testing.T, body string) {
187 | assert.Contains(t, body, "alpha.example")
188 | assert.Contains(t, body, "beta.example")
189 | assert.Contains(t, body, "gamma.example")
190 | },
191 | },
192 | }
193 | for _, testCase := range tests {
194 | t.Run(testCase.name, func(t *testing.T) {
195 | mockVault := &vault.MockClient{}
196 | mockVault.On("ListCertificates", mock.Anything).Return(certificates, nil)
197 | router := setupUIRouter(mockVault, webFS)
198 | req := httptest.NewRequest(http.MethodGet, testCase.path, nil)
199 | if testCase.headerTrigger != "" {
200 | req.Header.Set("HX-Trigger", testCase.headerTrigger)
201 | }
202 | recorder := httptest.NewRecorder()
203 | router.ServeHTTP(recorder, req)
204 | assert.Equal(t, http.StatusOK, recorder.Code)
205 | body := recorder.Body.String()
206 | testCase.assertBody(t, body)
207 | mockVault.AssertExpectations(t)
208 | })
209 | }
210 | }
211 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # VaultCertsViewer 🔐
2 |
3 | VaultCertsViewer (vcv) is a lightweight web UI that lists and inspects certificates stored in one or more HashiCorp Vault PKI mounts, especially their expiration dates and SANs.
4 |
5 | VaultCertsViewer can simultaneously monitor multiple PKI engines through a single interface, with a modal selector to choose which mounts to display. At the moment, VCV can be connected to only one Vault instance. If you have (example) five Vault instances, you have to create five VCV instances.
6 |
7 | ## ✨ What it does
8 |
9 | - Discovers all certificates in one or more Vault PKI mounts and shows them in a searchable, filterable table.
10 | - Multi-PKI engine support: Select which mounts to display via an intuitive modal interface with real-time certificate count badges.
11 | - Shows common names (CN) and SANs.
12 | - Displays status distribution (valid / expired / revoked) and upcoming expirations.
13 | - Highlights certificates expiring soon (7/30 days) and shows details (CN, SAN, fingerprints, issuer, validity).
14 | - Lets you pick UI language (en, fr, es, de, it) and theme (light/dark).
15 | - Real-time Vault connection status with toast notifications when connection is lost/restored.
16 |
17 | ## 🎯 Why it exists
18 |
19 | The native Vault UI is heavy and not convenient for quickly checking certificate expirations and details. VaultCertsViewer gives platform / security / ops teams a fast, **read-only** view of the Vault PKI inventory with only the essential information.
20 |
21 | ## 👥 Who should use it
22 |
23 | - Teams operating Vault PKI who need visibility on their certificates.
24 | - Operators who want a ready-to-use browser view alongside Vault CLI or Web UI.
25 |
26 | ## 🚀 How to deploy and use
27 |
28 | In HashiCorp Vault, create a read-only role and token for the API to reach the target PKI engines. For multiple mounts, you can either specify each mount explicitly or use wildcard patterns:
29 |
30 | ```bash
31 | # Option 1: Explicit mounts (recommended for production). Replace 'pki' and 'pki2' with your actual mount names.
32 | vault policy write vcv - <<'EOF'
33 | path "pki/certs" { capabilities = ["list"] }
34 | path "pki/certs/*" { capabilities = ["read","list"] }
35 | path "pki2/certs" { capabilities = ["list"] }
36 | path "pki2/certs/*" { capabilities = ["read","list"] }
37 | path "sys/health" { capabilities = ["read"] }
38 | EOF
39 |
40 | # Option 2: Wildcard pattern (for dynamic environments)
41 | vault policy write vcv - <<'EOF'
42 | path "pki*/certs" { capabilities = ["list"] }
43 | path "pki*/certs/*" { capabilities = ["read","list"] }
44 | path "sys/health" { capabilities = ["read"] }
45 | EOF
46 |
47 | vault write auth/token/roles/vcv allowed_policies="vcv" orphan=true period="24h"
48 | vault token create -role="vcv" -policy="vcv" -period="24h" -renewable=true
49 | ```
50 |
51 | This dedicated token limits permissions to certificate listing/reading, can be renewed, and is used as `VAULT_READ_TOKEN` by the app.
52 |
53 | ## 🧩 Multi-PKI engine support
54 |
55 | VaultCertsViewer can monitor multiple PKI engines simultaneously through a single web interface:
56 |
57 | - **Mount selection**: Click the mount selector button in the header to open a modal showing all available PKI engines
58 | - **Real-time counts**: Each mount displays a badge showing the number of certificates it contains
59 | - **Flexible configuration**: Specify mounts using comma-separated values in `VAULT_PKI_MOUNTS` (e.g., `pki,pki2,pki-prod`)
60 | - **Independent views**: Select or deselect any combination of mounts to customize your certificate view
61 | - **Dashboard**: All selected mounts are aggregated in the same table, dashboard, and metrics
62 | - **Real-time search**: Instant filtering as you type in the search box with 300ms debouncing
63 | - **Status filtering**: Quick filters for valid/expired/revoked certificates
64 | - **Expiry timeline**: Visual timeline showing certificate expiration distribution
65 | - **Pagination**: Configurable page size (25/50/75/100/all) with navigation controls
66 | - **Sort options**: Sort by common name, expiration date, or serial number
67 |
68 | This approach eliminates the need to deploy multiple vcv instances when you have several PKI engines to monitor.
69 |
70 | ### 🐳 docker-compose
71 |
72 | Grab `docker-compose.yml`, put it in a directory and create `.env` file with these variables:
73 |
74 | ```text
75 | # Change with your actual Vault configuration
76 | APP_ENV=prod
77 | LOG_FILE_PATH=/var/log/app/vcv.log
78 | LOG_FORMAT=json
79 | LOG_LEVEL=info
80 | LOG_OUTPUT=stdout # 'file', 'stdout' or 'both'
81 | PORT=52000
82 | VAULT_ADDR=https://your-vault-address:8200
83 | VAULT_PKI_MOUNTS=pki,pki2
84 | VAULT_READ_TOKEN=s.YourGeneratedTokenHere
85 | VAULT_TLS_INSECURE=false
86 | VCV_EXPIRE_CRITICAL=7
87 | VCV_EXPIRE_WARNING=30
88 | ```
89 |
90 | Do not forget to change the values with yours.
91 |
92 | then launch instance:
93 |
94 | ```bash
95 | docker compose up -d
96 | ```
97 |
98 | No storage needed, unless you want to log to a file.
99 |
100 | ### 🐳 docker run
101 |
102 | Start the container with this command:
103 |
104 | ```bash
105 | docker run -d \
106 | -e "APP_ENV=prod" \
107 | -e "LOG_FORMAT=json" \
108 | -e "LOG_OUTPUT=stdout" \
109 | -e "VAULT_ADDR=http://changeme:8200" \
110 | -e "VAULT_READ_TOKEN=changeme" \
111 | -e "VAULT_PKI_MOUNTS=changeme,changeme2" \
112 | -e "VAULT_TLS_INSECURE=true" \
113 | -e "VCV_EXPIRE_CRITICAL=7" \
114 | -e "VCV_EXPIRE_WARNING=30" \
115 | -e "LOG_LEVEL=info" \
116 | --cap-drop=ALL --read-only --security-opt no-new-privileges:true \
117 | -p 52000:52000 jhmmt/vcv:1.3
118 | ```
119 |
120 | ## ⏱️ Certificate expiration thresholds
121 |
122 | By default, VaultCertsViewer alerts on certificates expiring within **7 days** (critical) and **30 days** (warning). You can customize these thresholds using environment variables:
123 |
124 | ```text
125 | VCV_EXPIRE_CRITICAL=14 # Critical alert threshold (days)
126 | VCV_EXPIRE_WARNING=60 # Warning alert threshold (days)
127 | ```
128 |
129 | These values control:
130 |
131 | - The notification banner at the top of the page
132 | - The color coding in the certificate table (red for critical, yellow for warning)
133 | - The timeline visualization on the dashboard
134 | - The "expiring soon" count in the dashboard
135 |
136 | ## 🌍 Translations
137 |
138 | The UI is localized in English, French, Spanish, German, and Italian. Language is selectable in the header or via `?lang=xx`.
139 |
140 | ## 📊 Export metrics to Prometheus
141 |
142 | Metrics are exposed at `/metrics` endpoint.
143 |
144 | - vcv_cache_size
145 | - vcv_certificate_expiry_timestamp_seconds{serial_number, common_name, status}
146 | - vcv_certificate_exporter_last_scrape_success
147 | - vcv_certificates_expired_count
148 | - vcv_certificates_expires_soon_count Number of certificates expiring soon within threshold window
149 | - vcv_certificates_last_fetch_timestamp_seconds
150 | - vcv_certificates_total{status}
151 | - vcv_vault_connected
152 |
153 | To scrape metrics, add this to your Prometheus config:
154 |
155 | ```yaml
156 | scrape_configs:
157 | - job_name: vcv
158 | static_configs:
159 | - targets: ['localhost:52000']
160 | metrics_path: /metrics
161 | ```
162 |
163 | Example scrape output (truncated):
164 |
165 | ```bash
166 | $ curl -v http://localhost:52000/metrics
167 | ...
168 | # HELP vcv_cache_size Number of items currently cached
169 | # TYPE vcv_cache_size gauge
170 | vcv_cache_size 0
171 | # HELP vcv_certificate_expiry_timestamp_seconds Certificate expiration timestamp in seconds since epoch
172 | # TYPE vcv_certificate_expiry_timestamp_seconds gauge
173 | vcv_certificate_expiry_timestamp_seconds{common_name="api.internal",serial_number="52:e3:c0:23:ba:f4:51:ae:1b:59:24:4a:d1:03:e1:a7:8a:96:a7:80",status="active"} 1.767710142e+09
174 | vcv_certificate_expiry_timestamp_seconds{common_name="example.internal",serial_number="35:1b:ff:d3:e2:f3:53:14:b1:7f:9e:d3:77:a6:25:72:a2:63:15:99",status="active"} 1.767710142e+09
175 | vcv_certificate_expiry_timestamp_seconds{common_name="expired.internal",serial_number="74:5a:ed:76:98:b1:c8:e3:d7:a5:bb:a2:67:7f:f6:4f:2a:31:48:18",status="active"} 1.765118144e+09
176 | vcv_certificate_expiry_timestamp_seconds{common_name="expiring-soon.internal",serial_number="36:c6:0b:ef:2c:a5:2f:08:89:6a:13:fe:2a:9e:43:84:38:a4:a9:af",status="active"} 1.765204542e+09
177 | vcv_certificate_expiry_timestamp_seconds{common_name="expiring-week.internal",serial_number="47:c9:8f:71:2a:d7:14:49:96:64:af:d6:15:ec:e9:86:a6:59:cf:26",status="active"} 1.765722942e+09
178 | vcv_certificate_expiry_timestamp_seconds{common_name="revoked.internal",serial_number="2d:08:41:de:10:5a:21:0e:63:0d:5d:8e:f9:4e:ce:4b:7b:31:2e:2d",status="revoked"} 1.767710145e+09
179 | vcv_certificate_expiry_timestamp_seconds{common_name="vcv.local",serial_number="48:88:7a:6a:65:85:85:8b:0a:2a:12:7f:a7:6f:dc:62:3a:f2:7a:ba",status="active"} 1.796654141e+09
180 | # HELP vcv_certificate_exporter_last_scrape_success Whether the last scrape succeeded (1) or failed (0)
181 | # TYPE vcv_certificate_exporter_last_scrape_success gauge
182 | vcv_certificate_exporter_last_scrape_success 1
183 | # HELP vcv_certificates_expired_count Number of expired certificates
184 | # TYPE vcv_certificates_expired_count gauge
185 | vcv_certificates_expired_count 1
186 | # HELP vcv_certificates_expires_soon_count Number of certificates expiring soon within threshold window
187 | # TYPE vcv_certificates_expires_soon_count gauge
188 | vcv_certificates_expires_soon_count 4
189 | # HELP vcv_certificates_last_fetch_timestamp_seconds Timestamp of last successful certificates fetch
190 | # TYPE vcv_certificates_last_fetch_timestamp_seconds gauge
191 | vcv_certificates_last_fetch_timestamp_seconds 1.765118171e+09
192 | # HELP vcv_certificates_total Total certificates grouped by status
193 | # TYPE vcv_certificates_total gauge
194 | vcv_certificates_total{status="active"} 6
195 | vcv_certificates_total{status="revoked"} 1
196 | # HELP vcv_vault_connected Vault connection status (1=connected,0=disconnected)
197 | # TYPE vcv_vault_connected gauge
198 | vcv_vault_connected 1
199 | ```
200 |
201 | If you are using AlertManager, you can create alerts based on these metrics. For example, using only the expiry timestamp and generic counters:
202 |
203 | ```yaml
204 | - alert: VCVExpiredCerts
205 | expr: vcv_certificates_expired_count > 0
206 |
207 | - alert: VCVExpiringSoon_14d
208 | expr: (vcv_certificate_expiry_timestamp_seconds - time()) / 86400 < 14
209 |
210 | - alert: VCVStaleData
211 | expr: time() - vcv_certificates_last_fetch_timestamp_seconds > 3600
212 |
213 | - alert: VCVVaultDown
214 | expr: vcv_vault_connected == 0
215 | ```
216 |
217 | You can adjust the "soon" window (here 14 days) directly in PromQL without changing the exporter.
218 |
219 | ## 🔎 More details
220 |
221 | - Technical documentation: [app/README.md](app/README.md)
222 | - French overview: [README.fr.md](README.fr.md)
223 | - Docker hub: [jhmmt/vcv](https://hub.docker.com/r/jhmmt/vcv)
224 | - Source code: [github.com/julienhmmt/vcv](https://github.com/julienhmmt/vcv)
225 |
226 | ## 🖼️ Picture of the app
227 |
228 | 
229 |
230 | 
231 |
232 | 
233 |
--------------------------------------------------------------------------------
/README.fr.md:
--------------------------------------------------------------------------------
1 | # VaultCertsViewer 🔐
2 |
3 | VaultCertsViewer (vcv) est une interface web légère qui permet de lister et de consulter les certificats stockés dans un ou plusieurs coffres 'pki' d'HashiCorp Vault. Elle affiche notamment les noms communs, les SAN et surtout les dates d'expiration des certificats.
4 |
5 | VaultCertsViewer (vcv) peut surveiller simultanément plusieurs moteurs PKI via une seule interface, avec un sélecteur modal pour choisir les montages à afficher. Pour l'instant, VCV ne peut être connecté qu'à un seul Vault. Si vous avez (par exemple) cinq instances Vault, vous devrez créez cinq instances VCV.
6 |
7 | ## ✨ Quelles sont les fonctionnalités ?
8 |
9 | - Découvre tous les certificats d'une ou plusieurs moteurs PKI dans Vault et les affiche dans un tableau filtrable et recherchable.
10 | - Support multi-moteurs PKI : Sélectionnez les montages à afficher via une interface modale intuitive avec des badges de comptage de certificats en temps réel.
11 | - Affichage des noms communs (CN) et des SANs des certificats.
12 | - Affiche la répartition des statuts (valide / expiré / révoqué) et les dates d'expirations à venir.
13 | - Met en avant les certificats qui expirent bientôt (7/30 jours) et affiche les détails (CN, SAN, empreintes, émetteur, validité).
14 | - Choix de la langue de l'UI (en, fr, es, de, it) et le thème (clair/sombre).
15 | - Surveillance en temps réel de la connexion Vault avec notifications toast en cas de perte/rétablissement.
16 |
17 | ## 🎯 Pourquoi cet outil existe-t-il ?
18 |
19 | L'interface de Vault est trop lourde et complexe pour consulter les certificats. Elle ne permet pas **facilement** et rapidement de consulter les dates d'expiration et les détails des certificats.
20 |
21 | VaultCertsViewer permet aux équipes plateforme / sécurité / ops une vue rapide et en **lecture seule** sur l'inventaire PKI Vault avec les seules informations nécessaires et utiles.
22 |
23 | ## 👥 À qui s'adresse-t-il ?
24 |
25 | - Aux equipes exploitant l'outil Vault PKI qui ont besoin de visibilité sur leurs certificats.
26 | - Aux opérateurs qui veulent une vue navigateur prête à l’emploi, à côté de la CLI ou de la Web UI de Vault.
27 |
28 | ## 🚀 Comment le déployer et l'utiliser ?
29 |
30 | Dans HashiCorp Vault, créez un rôle et un jeton en lecture seule pour l'API afin d'accéder aux certificats des moteurs PKI ciblés. Pour plusieurs montages, vous pouvez spécifier chaque montage explicitement ou utiliser des motifs génériques :
31 |
32 | ```bash
33 | # Option 1 : Montages explicites (recommandé pour la production). Remplacez 'pki' et 'pki2' par vos montages réels.
34 | vault policy write vcv - <<'EOF'
35 | path "pki/certs" { capabilities = ["list"] }
36 | path "pki/certs/*" { capabilities = ["read","list"] }
37 | path "pki2/certs" { capabilities = ["list"] }
38 | path "pki2/certs/*" { capabilities = ["read","list"] }
39 | path "sys/health" { capabilities = ["read"] }
40 | EOF
41 |
42 | # Option 2 : Motif générique (pour environnements dynamiques)
43 | vault policy write vcv - <<'EOF'
44 | path "pki*/certs" { capabilities = ["list"] }
45 | path "pki*/certs/*" { capabilities = ["read","list"] }
46 | path "sys/health" { capabilities = ["read"] }
47 | EOF
48 |
49 | vault write auth/token/roles/vcv allowed_policies="vcv" orphan=true period="24h"
50 | vault token create -role="vcv" -policy="vcv" -period="24h" -renewable=true
51 | ```
52 |
53 | Ce jeton dédié limite les droits à la consultation des certificats, peut être renouvelé et sert de valeur `VAULT_READ_TOKEN` pour l'application.
54 |
55 | ## 🧩 Support multi-moteurs PKI
56 |
57 | VaultCertsViewer peut surveiller simultanément plusieurs moteurs PKI via une seule interface web :
58 |
59 | - **Sélection des montages** : Cliquez sur le bouton de sélecteur de montage dans l'en-tête pour ouvrir une fenêtre modale montrant tous les moteurs PKI disponibles
60 | - **Comptages en temps réel** : Chaque montage affiche un badge indiquant le nombre de certificats qu'il contient
61 | - **Configuration flexible** : Spécifiez les montages en utilisant des valeurs séparées par des virgules dans `VAULT_PKI_MOUNTS` (par exemple, `pki,pki2,pki-prod`)
62 | - **Vues indépendantes** : Sélectionnez ou désélectionnez n'importe quelle combinaison de montages pour personnaliser votre vue des certificats
63 | - **Tableau de bord** : Tous les montages sélectionnés sont agrégés dans le même tableau, tableau de bord et métriques
64 | - **Recherche en temps réel** : Filtrage instantané pendant la saisie avec délai de 300ms
65 | - **Filtrage par statut** : Filtres rapides pour les certificats valides/expirés/révoqués
66 | - **Timeline d'expiration** : Visualisation temporelle de la distribution des expirations
67 | - **Pagination** : Taille de page configurable (25/50/75/100/tout) avec contrôles de navigation
68 | - **Options de tri** : Tri par nom commun, date d'expiration ou numéro de série
69 |
70 | Cette approche élimine le besoin de déployer plusieurs instances vcv lorsque vous avez plusieurs moteurs PKI à surveiller.
71 |
72 | ### 🐳 docker-compose
73 |
74 | Récupérez le fichier `docker-compose.yml`, placez-le dans un répertoire de votre machine, et utilisez soit les variables d'environnement dans le fichier docker-compose, soit créez un fichier `.env` avec les variables suivantes :
75 |
76 | ```text
77 | # Change with your actual Vault configuration
78 | APP_ENV=prod
79 | LOG_FILE_PATH=/var/log/app/vcv.log
80 | LOG_FORMAT=json
81 | LOG_LEVEL=info
82 | LOG_OUTPUT=stdout # 'file', 'stdout' or 'both'
83 | PORT=52000
84 | VAULT_ADDR=https://your-vault-address:8200
85 | VAULT_PKI_MOUNTS=pki,pki2
86 | VAULT_READ_TOKEN=s.YourGeneratedTokenHere
87 | VAULT_TLS_INSECURE=false
88 | VCV_EXPIRE_CRITICAL=7
89 | VCV_EXPIRE_WARNING=30
90 | ```
91 |
92 | N'oubliez pas de changer les valeurs par vos propres valeurs.
93 |
94 | Lancez ensuite la commande suivante :
95 |
96 | ```bash
97 | docker compose up -d
98 | ```
99 |
100 | Il n'y a pas besoin de stockage, sauf si vous souhaitez envoyer les journaux d'événements dans un fichier.
101 |
102 | ### 🐳 docker run
103 |
104 | Lancez rapidement le container avec cette commande:
105 |
106 | ```bash
107 | docker run -d \
108 | -e "APP_ENV=prod" \
109 | -e "LOG_FORMAT=json" \
110 | -e "LOG_OUTPUT=stdout" \
111 | -e "VAULT_ADDR=http://changeme:8200" \
112 | -e "VAULT_READ_TOKEN=changeme" \
113 | -e "VAULT_PKI_MOUNTS=changeme,changeme2" \
114 | -e "VAULT_TLS_INSECURE=true" \
115 | -e "VCV_EXPIRE_CRITICAL=7" \
116 | -e "VCV_EXPIRE_WARNING=30" \
117 | -e "LOG_LEVEL=info" \
118 | --cap-drop=ALL --read-only --security-opt no-new-privileges:true \
119 | -p 52000:52000 jhmmt/vcv:1.3
120 | ```
121 |
122 | ## ⏱️ Seuils d'expiration des certificats
123 |
124 | Par défaut, VaultCertsViewer alerte sur les certificats expirant dans **7 jours** (critique) et **30 jours** (avertissement). Vous pouvez personnaliser ces seuils avec les variables d'environnement :
125 |
126 | ```text
127 | VCV_EXPIRE_CRITICAL=14 # Seuil d'alerte critique (jours)
128 | VCV_EXPIRE_WARNING=60 # Seuil d'alerte avertissement (jours)
129 | ```
130 |
131 | Ces valeurs contrôlent :
132 |
133 | - La banneau de notification en haut de la page
134 | - Le code couleur dans le tableau des certificats (rouge pour critique, jaune pour avertissement)
135 | - La visualisation de la chronologie sur le tableau de bord
136 | - Le nombre de certificats « expirant bientôt » dans le tableau de bord
137 |
138 | ## 🌍 Multilingue
139 |
140 | L'UI est localisée en *anglais*, *français*, *espagnol*, *allemand* et *italien*. La langue se choisit dans l'en-tête via un bouton ou saisissant dans l'URL le composant `?lang=xx`.
141 |
142 | ## 📊 Exporter des métriques vers Prometheus
143 |
144 | Les métriques sont exposées sur l’endpoint `/metrics`.
145 |
146 | - vcv_cache_size
147 | - vcv_certificate_expiry_timestamp_seconds{serial_number, common_name, status}
148 | - vcv_certificate_exporter_last_scrape_success
149 | - vcv_certificates_expired_count
150 | - vcv_certificates_expires_soon_count Nombre de certificats expirant bientôt dans la fenêtre de seuil
151 | - vcv_certificates_last_fetch_timestamp_seconds
152 | - vcv_certificates_total{status}
153 | - vcv_vault_connected
154 |
155 | Pour configurer le scraping côté Prometheus :
156 |
157 | ```yaml
158 | scrape_configs:
159 | - job_name: vcv
160 | static_configs:
161 | - targets: ['localhost:52000']
162 | metrics_path: /metrics
163 | ```
164 |
165 | Example scrape output (truncated):
166 |
167 | ```bash
168 | $ curl -v http://localhost:52000/metrics
169 | ...
170 | # HELP vcv_cache_size Number of items currently cached
171 | # TYPE vcv_cache_size gauge
172 | vcv_cache_size 0
173 | # HELP vcv_certificate_expiry_timestamp_seconds Certificate expiration timestamp in seconds since epoch
174 | # TYPE vcv_certificate_expiry_timestamp_seconds gauge
175 | vcv_certificate_expiry_timestamp_seconds{common_name="api.internal",serial_number="52:e3:c0:23:ba:f4:51:ae:1b:59:24:4a:d1:03:e1:a7:8a:96:a7:80",status="active"} 1.767710142e+09
176 | vcv_certificate_expiry_timestamp_seconds{common_name="example.internal",serial_number="35:1b:ff:d3:e2:f3:53:14:b1:7f:9e:d3:77:a6:25:72:a2:63:15:99",status="active"} 1.767710142e+09
177 | vcv_certificate_expiry_timestamp_seconds{common_name="expired.internal",serial_number="74:5a:ed:76:98:b1:c8:e3:d7:a5:bb:a2:67:7f:f6:4f:2a:31:48:18",status="active"} 1.765118144e+09
178 | vcv_certificate_expiry_timestamp_seconds{common_name="expiring-soon.internal",serial_number="36:c6:0b:ef:2c:a5:2f:08:89:6a:13:fe:2a:9e:43:84:38:a4:a9:af",status="active"} 1.765204542e+09
179 | vcv_certificate_expiry_timestamp_seconds{common_name="expiring-week.internal",serial_number="47:c9:8f:71:2a:d7:14:49:96:64:af:d6:15:ec:e9:86:a6:59:cf:26",status="active"} 1.765722942e+09
180 | vcv_certificate_expiry_timestamp_seconds{common_name="revoked.internal",serial_number="2d:08:41:de:10:5a:21:0e:63:0d:5d:8e:f9:4e:ce:4b:7b:31:2e:2d",status="revoked"} 1.767710145e+09
181 | vcv_certificate_expiry_timestamp_seconds{common_name="vcv.local",serial_number="48:88:7a:6a:65:85:85:8b:0a:2a:12:7f:a7:6f:dc:62:3a:f2:7a:ba",status="active"} 1.796654141e+09
182 | # HELP vcv_certificate_exporter_last_scrape_success Whether the last scrape succeeded (1) or failed (0)
183 | # TYPE vcv_certificate_exporter_last_scrape_success gauge
184 | vcv_certificate_exporter_last_scrape_success 1
185 | # HELP vcv_certificates_expired_count Number of expired certificates
186 | # TYPE vcv_certificates_expired_count gauge
187 | vcv_certificates_expired_count 1
188 | # HELP vcv_certificates_expires_soon_count Number of certificates expiring soon within threshold window
189 | # TYPE vcv_certificates_expires_soon_count gauge
190 | vcv_certificates_expires_soon_count 4
191 | # HELP vcv_certificates_last_fetch_timestamp_seconds Timestamp of last successful certificates fetch
192 | # TYPE vcv_certificates_last_fetch_timestamp_seconds gauge
193 | vcv_certificates_last_fetch_timestamp_seconds 1.765118171e+09
194 | # HELP vcv_certificates_total Total certificates grouped by status
195 | # TYPE vcv_certificates_total gauge
196 | vcv_certificates_total{status="active"} 6
197 | vcv_certificates_total{status="revoked"} 1
198 | # HELP vcv_vault_connected Vault connection status (1=connected,0=disconnected)
199 | # TYPE vcv_vault_connected gauge
200 | vcv_vault_connected 1
201 | ```
202 |
203 | Si vous utilisez AlertManager, vous pouvez créer des alertes à partir de ces métriques. Par exemple, en ne vous basant que sur le timestamp d’expiration et les compteurs génériques :
204 |
205 | ```yaml
206 | - alert: VCVExpiredCerts
207 | expr: vcv_certificates_expired_count > 0
208 |
209 | - alert: VCVExpiringSoon_14d
210 | expr: (vcv_certificate_expiry_timestamp_seconds - time()) / 86400 < 14
211 |
212 | - alert: VCVStaleData
213 | expr: time() - vcv_certificates_last_fetch_timestamp_seconds > 3600
214 |
215 | - alert: VCVVaultDown
216 | expr: vcv_vault_connected == 0
217 | ```
218 |
219 | Vous pouvez adapter librement la fenêtre « bientôt » (ici 14 jours) directement dans vos requêtes PromQL, sans modifier l’exporter.
220 |
221 | ## 🔎 Pour aller plus loin
222 |
223 | - Documentation technique : [app/README.md](app/README.md)
224 | - Version anglaise : [README.md](README.md)
225 | - Docker Hub : [jhmmt/vcv](https://hub.docker.com/r/jhmmt/vcv)
226 | - Code Source : [github.com/julienhmmt/vcv](https://github.com/julienhmmt/vcv)
227 |
228 | ## 🖼️ Picture of the app
229 |
230 | 
231 |
232 | 
233 |
234 | 
235 |
--------------------------------------------------------------------------------
/vault-dev-init-2.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | set -eu
3 |
4 | # Start Vault dev server and run PKI initialization commands for vault-dev-2.
5 | # This script is meant to be used as the container command in docker-compose.dev.yml.
6 |
7 | VAULT_ADDR_INTERNAL="http://127.0.0.1:8200"
8 | VAULT_DEV_LISTEN="0.0.0.0:8200"
9 | VAULT_ROOT_TOKEN="root"
10 |
11 | export VAULT_ADDR="${VAULT_ADDR_INTERNAL}"
12 | export VAULT_TOKEN="${VAULT_ROOT_TOKEN}"
13 |
14 | # Start Vault dev in background
15 | vault server \
16 | -dev \
17 | -dev-root-token-id="${VAULT_ROOT_TOKEN}" \
18 | -dev-listen-address="${VAULT_DEV_LISTEN}" &
19 | VAULT_PID=$!
20 |
21 | # Wait for Vault to be reachable
22 | printf "[vcv-2] Waiting for Vault dev to be ready"
23 | while ! vault status >/dev/null 2>&1; do
24 | printf "."
25 | sleep 0.5
26 | done
27 | printf " done\n"
28 |
29 | # Enable and configure PKI
30 | # In dev mode the storage is in-memory, so these commands will run on each start.
31 |
32 | # Enable PKI at path pki_vault2/ (idempotent: ignore error if already enabled)
33 | vault secrets enable -path=pki_vault2 pki 2>/dev/null || true
34 |
35 | # Enable additional PKI engines for vault-dev-2
36 | vault secrets enable -path=pki_corporate pki 2>/dev/null || true
37 | vault secrets enable -path=pki_external pki 2>/dev/null || true
38 | vault secrets enable -path=pki_partners pki 2>/dev/null || true
39 |
40 | # Tune max TTL for all engines
41 | vault secrets tune -max-lease-ttl=8760h pki_vault2 2>/dev/null || true
42 | vault secrets tune -max-lease-ttl=8760h pki_corporate 2>/dev/null || true
43 | vault secrets tune -max-lease-ttl=8760h pki_external 2>/dev/null || true
44 | vault secrets tune -max-lease-ttl=8760h pki_partners 2>/dev/null || true
45 |
46 | # Generate root CAs for all engines (force to avoid interactive prompts)
47 | vault write -force pki_vault2/root/generate/internal \
48 | common_name="vcv-vault2.local" \
49 | ttl="8760h" >/dev/null 2>&1 || true
50 |
51 | vault write -force pki_corporate/root/generate/internal \
52 | common_name="vcv-corporate.local" \
53 | ttl="8760h" >/dev/null 2>&1 || true
54 |
55 | vault write -force pki_external/root/generate/internal \
56 | common_name="vcv-external.local" \
57 | ttl="8760h" >/dev/null 2>&1 || true
58 |
59 | vault write -force pki_partners/root/generate/internal \
60 | common_name="vcv-partners.local" \
61 | ttl="8760h" >/dev/null 2>&1 || true
62 |
63 | # Configure CRL URLs for all engines
64 | vault write pki_vault2/config/urls \
65 | issuing_certificates="${VAULT_ADDR_INTERNAL}/v1/pki_vault2/ca" \
66 | crl_distribution_points="${VAULT_ADDR_INTERNAL}/v1/pki_vault2/crl" >/dev/null 2>&1 || true
67 |
68 | vault write pki_corporate/config/urls \
69 | issuing_certificates="${VAULT_ADDR_INTERNAL}/v1/pki_corporate/ca" \
70 | crl_distribution_points="${VAULT_ADDR_INTERNAL}/v1/pki_corporate/crl" >/dev/null 2>&1 || true
71 |
72 | vault write pki_external/config/urls \
73 | issuing_certificates="${VAULT_ADDR_INTERNAL}/v1/pki_external/ca" \
74 | crl_distribution_points="${VAULT_ADDR_INTERNAL}/v1/pki_external/crl" >/dev/null 2>&1 || true
75 |
76 | vault write pki_partners/config/urls \
77 | issuing_certificates="${VAULT_ADDR_INTERNAL}/v1/pki_partners/ca" \
78 | crl_distribution_points="${VAULT_ADDR_INTERNAL}/v1/pki_partners/crl" >/dev/null 2>&1 || true
79 |
80 | # Create roles for issuing test certificates in all engines
81 | vault write pki_vault2/roles/vcv \
82 | allowed_domains="vault2.local" \
83 | allow_bare_domains=true \
84 | allow_subdomains=true \
85 | max_ttl="8760h" \
86 | ttl="8760h" \
87 | not_before_duration="30s" >/dev/null 2>&1 || true
88 |
89 | vault write pki_corporate/roles/vcv \
90 | allowed_domains="corp.local,corporate.local,enterprise.local" \
91 | allow_bare_domains=true \
92 | allow_subdomains=true \
93 | max_ttl="8760h" \
94 | ttl="8760h" \
95 | not_before_duration="30s" >/dev/null 2>&1 || true
96 |
97 | vault write pki_external/roles/vcv \
98 | allowed_domains="external.local,public.local,api-gateway.local" \
99 | allow_bare_domains=true \
100 | allow_subdomains=true \
101 | max_ttl="8760h" \
102 | ttl="8760h" \
103 | not_before_duration="30s" >/dev/null 2>&1 || true
104 |
105 | vault write pki_partners/roles/vcv \
106 | allowed_domains="partners.local,thirdparty.local,integration.local" \
107 | allow_bare_domains=true \
108 | allow_subdomains=true \
109 | max_ttl="8760h" \
110 | ttl="8760h" \
111 | not_before_duration="30s" >/dev/null 2>&1 || true
112 |
113 | # Issue certificates for PKI engine (pki_vault2)
114 | echo "Creating certificates for pki_vault2 engine..."
115 |
116 | # Valid certificates
117 | vault write pki_vault2/issue/vcv common_name="main.vault2.local" alt_names="www.main.vault2.local,api.main.vault2.local" >/dev/null 2>&1 || true
118 | vault write pki_vault2/issue/vcv common_name="services.vault2.local" alt_names="svc1.services.vault2.local,svc2.services.vault2.local" >/dev/null 2>&1 || true
119 | vault write pki_vault2/issue/vcv common_name="admin.vault2.local" alt_names="console.admin.vault2.local,manage.admin.vault2.local" >/dev/null 2>&1 || true
120 | vault write pki_vault2/issue/vcv common_name="data.vault2.local" alt_names="primary.data.vault2.local,backup.data.vault2.local" >/dev/null 2>&1 || true
121 |
122 | # Certificates expiring soon (24h-72h)
123 | vault write pki_vault2/issue/vcv common_name="critical-expiring-1.vault2.local" ttl="48h" >/dev/null 2>&1 || true
124 | vault write pki_vault2/issue/vcv common_name="critical-expiring-2.vault2.local" ttl="72h" >/dev/null 2>&1 || true
125 | vault write pki_vault2/issue/vcv common_name="critical-expiring-3.vault2.local" ttl="24h" >/dev/null 2>&1 || true
126 |
127 | # Certificates expiring in 7-30 days
128 | vault write pki_vault2/issue/vcv common_name="warning-expiring-1.vault2.local" ttl="168h" >/dev/null 2>&1 || true
129 | vault write pki_vault2/issue/vcv common_name="warning-expiring-2.vault2.local" ttl="240h" >/dev/null 2>&1 || true
130 |
131 | # Expired certificates (TTL 2s, then wait)
132 | printf "[vcv-2] Creating expired certificates (waiting 3s)...\n"
133 | vault write pki_vault2/issue/vcv common_name="expired-1.vault2.local" ttl="2s" >/dev/null 2>&1 || true
134 | vault write pki_vault2/issue/vcv common_name="expired-2.vault2.local" ttl="2s" >/dev/null 2>&1 || true
135 | sleep 3
136 |
137 | # Issue certificates for PKI CORPORATE engine
138 | echo "Creating certificates for pki_corporate engine..."
139 |
140 | vault write pki_corporate/issue/vcv common_name="intranet.corp.local" alt_names="portal.intranet.corp.local,hr.intranet.corp.local" >/dev/null 2>&1 || true
141 | vault write pki_corporate/issue/vcv common_name="email.corp.local" alt_names="smtp.email.corp.local,imap.email.corp.local" >/dev/null 2>&1 || true
142 | vault write pki_corporate/issue/vcv common_name="finance.corp.local" alt_names="erp.finance.corp.local,accounting.finance.corp.local" >/dev/null 2>&1 || true
143 | vault write pki_corporate/issue/vcv common_name="hr.corp.local" alt_names="recruitment.hr.corp.local,payroll.hr.corp.local" >/dev/null 2>&1 || true
144 |
145 | # Corporate certificates expiring soon
146 | vault write pki_corporate/issue/vcv common_name="corp-expiring-1.local" ttl="36h" >/dev/null 2>&1 || true
147 | vault write pki_corporate/issue/vcv common_name="corp-expiring-2.local" ttl="60h" >/dev/null 2>&1 || true
148 |
149 | # Issue certificates for PKI EXTERNAL engine
150 | echo "Creating certificates for pki_external engine..."
151 |
152 | vault write pki_external/issue/vcv common_name="customer-api.external.local" alt_names="v1.customer-api.external.local,v2.customer-api.external.local" >/dev/null 2>&1 || true
153 | vault write pki_external/issue/vcv common_name="public-portal.external.local" alt_names="www.public-portal.external.local,mobile.public-portal.external.local" >/dev/null 2>&1 || true
154 | vault write pki_external/issue/vcv common_name="partner-gateway.external.local" alt_names="rest.partner-gateway.external.local,soap.partner-gateway.external.local" >/dev/null 2>&1 || true
155 |
156 | # External certificates expiring soon
157 | vault write pki_external/issue/vcv common_name="external-expiring-1.local" ttl="48h" >/dev/null 2>&1 || true
158 | vault write pki_external/issue/vcv common_name="external-expiring-2.local" ttl="72h" >/dev/null 2>&1 || true
159 |
160 | # Issue certificates for PKI PARTNERS engine
161 | echo "Creating certificates for pki_partners engine..."
162 |
163 | vault write pki_partners/issue/vcv common_name="sso.partners.local" alt_names="auth.sso.partners.local,oauth.sso.partners.local" >/dev/null 2>&1 || true
164 | vault write pki_partners/issue/vcv common_name="integration.partners.local" alt_names="api.integration.partners.local,webhook.integration.partners.local" >/dev/null 2>&1 || true
165 | vault write pki_partners/issue/vcv common_name="thirdparty.partners.local" alt_names="vendor.thirdparty.partners.local,supplier.thirdparty.partners.local" >/dev/null 2>&1 || true
166 |
167 | # Partners certificates expiring soon
168 | vault write pki_partners/issue/vcv common_name="partners-expiring-1.local" ttl="24h" >/dev/null 2>&1 || true
169 | vault write pki_partners/issue/vcv common_name="partners-expiring-2.local" ttl="96h" >/dev/null 2>&1 || true
170 |
171 | # Create certificates to revoke in each engine
172 | echo "Creating certificates to revoke..."
173 |
174 | # Revoke from pki_vault2
175 | REVOKE_OUTPUT=$(vault write -format=json pki_vault2/issue/vcv common_name="revoked.vault2.local" ttl="720h" 2>/dev/null) || true
176 | REVOKE_SERIAL=$(echo "${REVOKE_OUTPUT}" | grep -o '"serial_number"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"serial_number"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null) || true
177 | if [ -n "${REVOKE_SERIAL}" ]; then
178 | printf "[vcv-2] Revoking certificate %s from pki_vault2\n" "${REVOKE_SERIAL}"
179 | vault write pki_vault2/revoke serial_number="${REVOKE_SERIAL}" >/dev/null 2>&1 || true
180 | fi
181 |
182 | # Revoke from pki_corporate
183 | REVOKE_OUTPUT=$(vault write -format=json pki_corporate/issue/vcv common_name="revoked.corporate.local" ttl="720h" 2>/dev/null) || true
184 | REVOKE_SERIAL=$(echo "${REVOKE_OUTPUT}" | grep -o '"serial_number"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"serial_number"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null) || true
185 | if [ -n "${REVOKE_SERIAL}" ]; then
186 | printf "[vcv-2] Revoking certificate %s from pki_corporate\n" "${REVOKE_SERIAL}"
187 | vault write pki_corporate/revoke serial_number="${REVOKE_SERIAL}" >/dev/null 2>&1 || true
188 | fi
189 |
190 | # Revoke from pki_external
191 | REVOKE_OUTPUT=$(vault write -format=json pki_external/issue/vcv common_name="revoked.external.local" ttl="720h" 2>/dev/null) || true
192 | REVOKE_SERIAL=$(echo "${REVOKE_OUTPUT}" | grep -o '"serial_number"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"serial_number"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null) || true
193 | if [ -n "${REVOKE_SERIAL}" ]; then
194 | printf "[vcv-2] Revoking certificate %s from pki_external\n" "${REVOKE_SERIAL}"
195 | vault write pki_external/revoke serial_number="${REVOKE_SERIAL}" >/dev/null 2>&1 || true
196 | fi
197 |
198 | # Revoke from pki_partners
199 | REVOKE_OUTPUT=$(vault write -format=json pki_partners/issue/vcv common_name="revoked.partners.local" ttl="720h" 2>/dev/null) || true
200 | REVOKE_SERIAL=$(echo "${REVOKE_OUTPUT}" | grep -o '"serial_number"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"serial_number"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null) || true
201 | if [ -n "${REVOKE_SERIAL}" ]; then
202 | printf "[vcv-2] Revoking certificate %s from pki_partners\n" "${REVOKE_SERIAL}"
203 | vault write pki_partners/revoke serial_number="${REVOKE_SERIAL}" >/dev/null 2>&1 || true
204 | fi
205 |
206 | # Force CRL rotation to include revoked certs
207 | vault read pki_vault2/crl/rotate >/dev/null 2>&1 || true
208 | vault read pki_corporate/crl/rotate >/dev/null 2>&1 || true
209 | vault read pki_external/crl/rotate >/dev/null 2>&1 || true
210 | vault read pki_partners/crl/rotate >/dev/null 2>&1 || true
211 |
212 | printf "[vcv-2] Vault dev PKI initialized:\n"
213 | printf " - Mounts: pki_vault2/, pki_corporate/, pki_external/, pki_partners/\n"
214 | printf " - Roles: vcv (for all engines)\n"
215 | printf " - Certificates issued:\n"
216 | printf " pki_vault2: vault2.* (valid/expiring/expired), revoked.vault2.local\n"
217 | printf " pki_corporate: corp.* (valid/expiring), revoked.corporate.local\n"
218 | printf " pki_external: external.* (valid/expiring), revoked.external.local\n"
219 | printf " pki_partners: partners.* (valid/expiring), revoked.partners.local\n"
220 |
221 | # Keep the Vault process in foreground
222 | wait "${VAULT_PID}"
223 |
--------------------------------------------------------------------------------
/app/internal/vault/real.go:
--------------------------------------------------------------------------------
1 | package vault
2 |
3 | import (
4 | "context"
5 | "crypto/sha1"
6 | "crypto/sha256"
7 | "crypto/x509"
8 | "encoding/hex"
9 | "encoding/pem"
10 | "fmt"
11 | "sort"
12 | "strings"
13 | "time"
14 |
15 | "vcv/config"
16 | "vcv/internal/cache"
17 | "vcv/internal/certs"
18 |
19 | "github.com/hashicorp/vault/api"
20 | )
21 |
22 | type realClient struct {
23 | client *api.Client
24 | mounts []string
25 | addr string
26 | cache *cache.Cache
27 | stopChan chan struct{}
28 | }
29 |
30 | func NewClientFromConfig(cfg config.VaultConfig) (Client, error) {
31 | if cfg.Addr == "" {
32 | return nil, fmt.Errorf("vault address is empty")
33 | }
34 | if cfg.ReadToken == "" {
35 | return nil, fmt.Errorf("vault read token is empty")
36 | }
37 |
38 | clientConfig := api.DefaultConfig()
39 | if clientConfig == nil {
40 | return nil, fmt.Errorf("failed to create default Vault config")
41 | }
42 |
43 | clientConfig.Address = cfg.Addr
44 | if err := clientConfig.ConfigureTLS(&api.TLSConfig{
45 | Insecure: cfg.TLSInsecure,
46 | }); err != nil {
47 | return nil, fmt.Errorf("failed to configure Vault TLS: %w", err)
48 | }
49 |
50 | apiClient, err := api.NewClient(clientConfig)
51 | if err != nil {
52 | return nil, fmt.Errorf("failed to create Vault client: %w", err)
53 | }
54 |
55 | apiClient.SetToken(cfg.ReadToken)
56 |
57 | c := &realClient{
58 | client: apiClient,
59 | mounts: cfg.PKIMounts,
60 | addr: cfg.Addr,
61 | cache: cache.New(5 * time.Minute),
62 | stopChan: make(chan struct{}),
63 | }
64 |
65 | // Start periodic cache cleanup
66 | go func() {
67 | ticker := time.NewTicker(1 * time.Minute)
68 | defer ticker.Stop()
69 | for {
70 | select {
71 | case <-ticker.C:
72 | c.cache.Cleanup()
73 | case <-c.stopChan:
74 | return
75 | }
76 | }
77 | }()
78 |
79 | return c, nil
80 | }
81 |
82 | // CheckConnection verifies Vault availability and seal status.
83 | func (c *realClient) CheckConnection(ctx context.Context) error {
84 | health, err := c.client.Sys().HealthWithContext(ctx)
85 | if err != nil {
86 | return fmt.Errorf("vault health check failed: %w", err)
87 | }
88 | if health == nil {
89 | return fmt.Errorf("vault health response is nil")
90 | }
91 | if !health.Initialized {
92 | return fmt.Errorf("vault is not initialized")
93 | }
94 | if health.Sealed {
95 | return fmt.Errorf("vault is sealed")
96 | }
97 | return nil
98 | }
99 |
100 | // Shutdown stops background goroutines.
101 | func (c *realClient) Shutdown() {
102 | close(c.stopChan)
103 | }
104 |
105 | func (c *realClient) ListCertificates(ctx context.Context) ([]certs.Certificate, error) {
106 | // Try cache first
107 | if cached, found := c.cache.Get("certificates"); found {
108 | if certificates, ok := cached.([]certs.Certificate); ok {
109 | return certificates, nil
110 | }
111 | }
112 |
113 | var allCertificates []certs.Certificate
114 | revokedSet := make(map[string]bool)
115 |
116 | // Collect certificates from all mounts
117 | for _, mount := range c.mounts {
118 | mountCerts, mountRevoked, err := c.listCertificatesFromMount(ctx, mount)
119 | if err != nil {
120 | // Log error but continue with other mounts
121 | continue
122 | }
123 | allCertificates = append(allCertificates, mountCerts...)
124 | for serial := range mountRevoked {
125 | revokedSet[serial] = true
126 | }
127 | }
128 |
129 | sort.Slice(allCertificates, func(leftIndex, rightIndex int) bool {
130 | return allCertificates[leftIndex].CommonName < allCertificates[rightIndex].CommonName
131 | })
132 |
133 | // Cache the result
134 | c.cache.Set("certificates", allCertificates)
135 |
136 | return allCertificates, nil
137 | }
138 |
139 | func (c *realClient) listCertificatesFromMount(_ context.Context, mount string) ([]certs.Certificate, map[string]bool, error) {
140 | listPath := fmt.Sprintf("%s/certs", mount)
141 | secret, err := c.client.Logical().List(listPath)
142 | if err != nil {
143 | return nil, nil, fmt.Errorf("failed to list certificates from mount %s: %w", mount, err)
144 | }
145 | if secret == nil || secret.Data == nil {
146 | return []certs.Certificate{}, make(map[string]bool), nil
147 | }
148 |
149 | rawKeys, ok := secret.Data["keys"].([]interface{})
150 | if !ok {
151 | return nil, nil, fmt.Errorf("unexpected list response from Vault for mount %s: missing keys array", mount)
152 | }
153 |
154 | revokedSet, err := c.fetchRevokedSerialsFromMount(mount)
155 | if err != nil {
156 | return nil, nil, err
157 | }
158 |
159 | result := make([]certs.Certificate, 0, len(rawKeys))
160 | for _, value := range rawKeys {
161 | serial, ok := value.(string)
162 | if !ok {
163 | continue
164 | }
165 | certificate, err := c.readCertificateFromMount(mount, serial)
166 | if err != nil {
167 | continue
168 | }
169 | if revokedSet[serial] {
170 | certificate.Revoked = true
171 | }
172 | result = append(result, certificate)
173 | }
174 |
175 | return result, revokedSet, nil
176 | }
177 |
178 | func (c *realClient) fetchRevokedSerialsFromMount(mount string) (map[string]bool, error) {
179 | path := fmt.Sprintf("%s/certs/revoked", mount)
180 | secret, err := c.client.Logical().List(path)
181 | if err != nil {
182 | return nil, fmt.Errorf("failed to list revoked certificates from mount %s: %w", mount, err)
183 | }
184 |
185 | serials := make(map[string]bool)
186 | if secret == nil || secret.Data == nil {
187 | return serials, nil
188 | }
189 |
190 | rawKeys, ok := secret.Data["keys"].([]interface{})
191 | if !ok {
192 | return serials, nil
193 | }
194 |
195 | for _, value := range rawKeys {
196 | serial, ok := value.(string)
197 | if ok {
198 | serials[serial] = true
199 | }
200 | }
201 |
202 | return serials, nil
203 | }
204 |
205 | func (c *realClient) readCertificateFromMount(mount, serial string) (certs.Certificate, error) {
206 | path := fmt.Sprintf("%s/cert/%s", mount, serial)
207 | secret, err := c.client.Logical().Read(path)
208 | if err != nil {
209 | return certs.Certificate{}, fmt.Errorf("failed to read certificate %s from mount %s: %w", serial, mount, err)
210 | }
211 | if secret == nil || secret.Data == nil {
212 | return certs.Certificate{}, fmt.Errorf("certificate %s not found in mount %s", serial, mount)
213 | }
214 |
215 | certificatePEM, ok := secret.Data["certificate"].(string)
216 | if !ok || certificatePEM == "" {
217 | return certs.Certificate{}, fmt.Errorf("certificate field missing for %s in mount %s", serial, mount)
218 | }
219 |
220 | block, _ := pem.Decode([]byte(certificatePEM))
221 | if block == nil {
222 | return certs.Certificate{}, fmt.Errorf("failed to decode PEM for certificate %s in mount %s", serial, mount)
223 | }
224 |
225 | x509Certificate, parseError := x509.ParseCertificate(block.Bytes)
226 | if parseError != nil {
227 | return certs.Certificate{}, fmt.Errorf("failed to parse certificate %s in mount %s: %w", serial, mount, parseError)
228 | }
229 |
230 | subjectAlternativeNames := make([]string, 0, len(x509Certificate.DNSNames)+len(x509Certificate.IPAddresses)+len(x509Certificate.EmailAddresses))
231 | subjectAlternativeNames = append(subjectAlternativeNames, x509Certificate.DNSNames...)
232 | for _, address := range x509Certificate.IPAddresses {
233 | subjectAlternativeNames = append(subjectAlternativeNames, address.String())
234 | }
235 | subjectAlternativeNames = append(subjectAlternativeNames, x509Certificate.EmailAddresses...)
236 |
237 | // Prefix ID with mount to avoid collisions across mounts
238 | return certs.Certificate{
239 | ID: fmt.Sprintf("%s:%s", mount, serial),
240 | CommonName: x509Certificate.Subject.CommonName,
241 | Sans: subjectAlternativeNames,
242 | CreatedAt: x509Certificate.NotBefore.UTC(),
243 | ExpiresAt: x509Certificate.NotAfter.UTC(),
244 | Revoked: false,
245 | }, nil
246 | }
247 |
248 | func (c *realClient) GetCertificateDetails(ctx context.Context, serialNumber string) (certs.DetailedCertificate, error) {
249 | // Parse mount and serial from the prefixed ID
250 | mount, serial, err := c.parseMountAndSerial(serialNumber)
251 | if err != nil {
252 | return certs.DetailedCertificate{}, err
253 | }
254 |
255 | // Try cache first
256 | cacheKey := fmt.Sprintf("details_%s", serialNumber)
257 | if cached, found := c.cache.Get(cacheKey); found {
258 | if details, ok := cached.(certs.DetailedCertificate); ok {
259 | return details, nil
260 | }
261 | }
262 |
263 | path := fmt.Sprintf("%s/cert/%s", mount, serial)
264 | secret, err := c.client.Logical().Read(path)
265 | if err != nil {
266 | return certs.DetailedCertificate{}, fmt.Errorf("failed to read certificate %s from mount %s: %w", serial, mount, err)
267 | }
268 | if secret == nil || secret.Data == nil {
269 | return certs.DetailedCertificate{}, fmt.Errorf("certificate %s not found in mount %s", serial, mount)
270 | }
271 |
272 | certificatePEM, ok := secret.Data["certificate"].(string)
273 | if !ok || certificatePEM == "" {
274 | return certs.DetailedCertificate{}, fmt.Errorf("certificate field missing for %s in mount %s", serial, mount)
275 | }
276 |
277 | block, _ := pem.Decode([]byte(certificatePEM))
278 | if block == nil {
279 | return certs.DetailedCertificate{}, fmt.Errorf("failed to decode PEM for certificate %s in mount %s", serial, mount)
280 | }
281 |
282 | x509Certificate, parseError := x509.ParseCertificate(block.Bytes)
283 | if parseError != nil {
284 | return certs.DetailedCertificate{}, fmt.Errorf("failed to parse certificate %s in mount %s: %w", serial, mount, parseError)
285 | }
286 |
287 | // Calculate fingerprints
288 | sha1Fingerprint := sha1.Sum(x509Certificate.Raw)
289 | sha256Fingerprint := sha256.Sum256(x509Certificate.Raw)
290 |
291 | subjectAlternativeNames := make([]string, 0, len(x509Certificate.DNSNames)+len(x509Certificate.IPAddresses)+len(x509Certificate.EmailAddresses))
292 | subjectAlternativeNames = append(subjectAlternativeNames, x509Certificate.DNSNames...)
293 | for _, address := range x509Certificate.IPAddresses {
294 | subjectAlternativeNames = append(subjectAlternativeNames, address.String())
295 | }
296 | subjectAlternativeNames = append(subjectAlternativeNames, x509Certificate.EmailAddresses...)
297 |
298 | // Extract key usage
299 | var usage []string
300 | if len(x509Certificate.ExtKeyUsage) > 0 {
301 | for _, extUsage := range x509Certificate.ExtKeyUsage {
302 | switch extUsage {
303 | case x509.ExtKeyUsageServerAuth:
304 | usage = append(usage, "Server Auth")
305 | case x509.ExtKeyUsageClientAuth:
306 | usage = append(usage, "Client Auth")
307 | case x509.ExtKeyUsageCodeSigning:
308 | usage = append(usage, "Code Signing")
309 | case x509.ExtKeyUsageEmailProtection:
310 | usage = append(usage, "Email Protection")
311 | }
312 | }
313 | }
314 |
315 | // Get revoked status
316 | revokedSet, err := c.fetchRevokedSerialsFromMount(mount)
317 | if err != nil {
318 | return certs.DetailedCertificate{}, err
319 | }
320 |
321 | details := certs.DetailedCertificate{
322 | Certificate: certs.Certificate{
323 | ID: serialNumber, // Keep the prefixed ID
324 | CommonName: x509Certificate.Subject.CommonName,
325 | Sans: subjectAlternativeNames,
326 | CreatedAt: x509Certificate.NotBefore.UTC(),
327 | ExpiresAt: x509Certificate.NotAfter.UTC(),
328 | Revoked: revokedSet[serial],
329 | },
330 | SerialNumber: serial, // Store only the serial part
331 | Issuer: x509Certificate.Issuer.String(),
332 | Subject: x509Certificate.Subject.String(),
333 | KeyAlgorithm: x509Certificate.SignatureAlgorithm.String(),
334 | KeySize: 0, // Would need more complex parsing for RSA/EC key sizes
335 | FingerprintSHA1: hex.EncodeToString(sha1Fingerprint[:]),
336 | FingerprintSHA256: hex.EncodeToString(sha256Fingerprint[:]),
337 | Usage: usage,
338 | PEM: certificatePEM,
339 | }
340 |
341 | // Cache the full detailed certificate
342 | c.cache.Set(cacheKey, details)
343 |
344 | return details, nil
345 | }
346 |
347 | func (c *realClient) parseMountAndSerial(serialNumber string) (string, string, error) {
348 | // Parse mount and serial from the prefixed ID
349 | // Format: "mount:serial" (e.g., "pki:1234-5678", "pki_dev:abcd-efgh")
350 | // This prevents ID collisions across multiple PKI mounts
351 | parts := strings.SplitN(serialNumber, ":", 2)
352 | if len(parts) == 2 {
353 | mount := parts[0]
354 | serial := parts[1]
355 |
356 | // Validate that the mount is configured
357 | for _, configuredMount := range c.mounts {
358 | if configuredMount == mount {
359 | return mount, serial, nil
360 | }
361 | }
362 | return "", "", fmt.Errorf("mount %s is not configured", mount)
363 | }
364 |
365 | // Legacy behavior: if no prefix, use the first configured mount
366 | if len(c.mounts) == 0 {
367 | return "", "", fmt.Errorf("no mounts configured")
368 | }
369 | return c.mounts[0], serialNumber, nil
370 | }
371 |
372 | func (c *realClient) GetCertificatePEM(ctx context.Context, serialNumber string) (certs.PEMResponse, error) {
373 | details, err := c.GetCertificateDetails(ctx, serialNumber)
374 | if err != nil {
375 | return certs.PEMResponse{}, err
376 | }
377 |
378 | return certs.PEMResponse{
379 | SerialNumber: details.SerialNumber, // Return only the serial part
380 | PEM: details.PEM,
381 | }, nil
382 | }
383 |
384 | func (c *realClient) InvalidateCache() {
385 | c.cache.Clear()
386 | }
387 |
--------------------------------------------------------------------------------