├── 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 | {{.VersionText}} 2 | {{.VaultText}} 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 |
{{.Messages.CertificateInformationTitle}}
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 |
{{.Messages.TechnicalDetailsTitle}}
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 |
46 |
{{.Messages.LabelPEM}}
47 |
48 |
49 |
{{.Certificate.PEM}}
50 |
51 |
52 |
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 | 13 | 14 | {{if .DonutHasValid}} 15 | 16 | {{end}} 17 | {{if .DonutHasExpired}} 18 | 19 | {{end}} 20 | {{if .DonutHasRevoked}} 21 | 22 | {{end}} 23 | 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 | ![VaultCertsViewer v1.3](img/VaultCertsViewer-v1.3.png) 229 | 230 | ![VaultCertsViewer v1.3 - Light Mode](img/VaultCertsViewer-v1.3-light.png) 231 | 232 | ![VaultCertsViewer v1.3 - Dark Mode](img/VaultCertsViewer-v1.3-dark.png) 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 | ![VaultCertsViewer v1.3](img/VaultCertsViewer-v1.3.png) 231 | 232 | ![VaultCertsViewer v1.3 - Light Mode](img/VaultCertsViewer-v1.3-light.png) 233 | 234 | ![VaultCertsViewer v1.3 - Dark Mode](img/VaultCertsViewer-v1.3-dark.png) 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 | --------------------------------------------------------------------------------