From db87e3be3d21efafa45dbae6c2b520b5950fba0c Mon Sep 17 00:00:00 2001 From: stef Date: Wed, 10 Dec 2025 11:03:29 +0100 Subject: [PATCH] First commit --- Dockerfile | 21 + cmd/server/main.go | 63 ++ config/config.go | 32 + docker-compose.yml | 35 + go.mod | 43 ++ go.sum | 141 ++++ internal/api/handlers.go | 380 ++++++++++ internal/api/routes.go | 67 ++ internal/models/ca.go | 43 ++ internal/models/certificate.go | 38 + internal/models/subca.go | 43 ++ internal/repository/mongo_repository.go | 229 ++++++ internal/repository/repository.go | 32 + internal/services/crypto_service.go | 327 +++++++++ internal/web/static/css/style.css | 502 ++++++++++++++ internal/web/static/js/app.js | 887 ++++++++++++++++++++++++ internal/web/templates/index.html | 307 ++++++++ scripts/init-mongo.js | 21 + 18 files changed, 3211 insertions(+) create mode 100644 Dockerfile create mode 100644 cmd/server/main.go create mode 100644 config/config.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/handlers.go create mode 100644 internal/api/routes.go create mode 100644 internal/models/ca.go create mode 100644 internal/models/certificate.go create mode 100644 internal/models/subca.go create mode 100644 internal/repository/mongo_repository.go create mode 100644 internal/repository/repository.go create mode 100644 internal/services/crypto_service.go create mode 100644 internal/web/static/css/style.css create mode 100644 internal/web/static/js/app.js create mode 100644 internal/web/templates/index.html create mode 100644 scripts/init-mongo.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..588fcc9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server + +FROM alpine:latest +RUN apk --no-cache add ca-certificates + +WORKDIR /app +COPY --from=builder /app/main . +COPY --from=builder /app/internal/web ./internal/web +COPY --from=builder /app/certs ./certs + +EXPOSE 8080 + +CMD ["./main"] \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..1029acc --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "log" + "os" + "time" + + "pki-manager/config" + "pki-manager/internal/api" + "pki-manager/internal/repository" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func main() { + // Configuration + cfg := config.LoadConfig() + + // MongoDB connection + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + clientOptions := options.Client().ApplyURI(cfg.MongoDBURI) + client, err := mongo.Connect(ctx, clientOptions) + if err != nil { + log.Fatal(err) + } + defer client.Disconnect(ctx) + + // Verify connection + err = client.Ping(ctx, nil) + if err != nil { + log.Fatal(err) + } + log.Println("Connected to MongoDB!") + + // Initialize repository + repo := repository.NewMongoRepository(client.Database(cfg.DBName)) + + // Create certs directory if not exists + if err := os.MkdirAll("certs", 0755); err != nil { + log.Fatal(err) + } + + // Setup Gin + if cfg.Environment == "production" { + gin.SetMode(gin.ReleaseMode) + } + + router := gin.Default() + + // Setup routes + api.SetupRoutes(router, repo, cfg.JWTSecret) + + // Start server + log.Printf("Server starting on port %s", cfg.Port) + if err := router.Run(":" + cfg.Port); err != nil { + log.Fatal(err) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..cbaf715 --- /dev/null +++ b/config/config.go @@ -0,0 +1,32 @@ +package config + +import ( + "os" +) + +type Config struct { + Port string + MongoDBURI string + DBName string + JWTSecret string + Environment string + CertsPath string +} + +func LoadConfig() *Config { + return &Config{ + Port: getEnv("PORT", "8080"), + MongoDBURI: getEnv("MONGODB_URI", "mongodb://localhost:27017"), + DBName: getEnv("DB_NAME", "pki_db"), + JWTSecret: getEnv("JWT_SECRET", "your-secret-key"), + Environment: getEnv("ENVIRONMENT", "development"), + CertsPath: getEnv("CERTS_PATH", "./certs"), + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0a21045 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' + +services: + mongodb: + image: mongo:latest + container_name: pki-mongodb + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: admin123 + MONGO_INITDB_DATABASE: pki_db + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + - ./scripts/init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro + + pki-api: + build: . + container_name: pki-api + restart: always + depends_on: + - mongodb + ports: + - "8080:8080" + environment: + MONGODB_URI: mongodb://admin:admin123@mongodb:27017/pki_db?authSource=admin + JWT_SECRET: your-super-secret-jwt-key-change-this + volumes: + - ./certs:/app/certs + - ./internal/web/static:/app/internal/web/static + - ./internal/web/templates:/app/internal/web/templates + +volumes: + mongodb_data: \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..85a3f88 --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module pki-manager + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + go.mongodb.org/mongo-driver v1.12.1 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4950248 --- /dev/null +++ b/go.sum @@ -0,0 +1,141 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk= +github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.12.1 h1:nLkghSU8fQNaK7oUmDhQFsnrtcoNy7Z6LVFKsEecqgE= +go.mongodb.org/mongo-driver v1.12.1/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..1dd2d1b --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,380 @@ +package api + +import ( + "net/http" + + "pki-manager/internal/models" + "pki-manager/internal/repository" + "pki-manager/internal/services" + + "github.com/gin-gonic/gin" +) + +type Handlers struct { + repo repository.Repository + cryptoService *services.CryptoService +} + +func NewHandlers(repo repository.Repository, cryptoService *services.CryptoService) *Handlers { + return &Handlers{ + repo: repo, + cryptoService: cryptoService, + } +} + +// CA Handlers +func (h *Handlers) CreateCA(c *gin.Context) { + var req models.CreateCARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ca, err := h.cryptoService.GenerateRootCA(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if err := h.repo.CreateCA(c.Request.Context(), ca); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, ca) +} + +func (h *Handlers) GetCA(c *gin.Context) { + id := c.Param("id") + ca, err := h.repo.GetCA(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "CA not found"}) + return + } + + // Don't expose private key in GET requests + ca.PrivateKey = "" + c.JSON(http.StatusOK, ca) +} + +// GetAllCAs - Retourne toujours un tableau, même vide +func (h *Handlers) GetAllCAs(c *gin.Context) { + cas, err := h.repo.GetAllCAs(c.Request.Context()) + if err != nil { + // IMPORTANT: Retourner un tableau vide, pas une erreur + c.JSON(http.StatusOK, []interface{}{}) + return + } + + // Remove private keys from response + for _, ca := range cas { + ca.PrivateKey = "" + } + + // S'assurer qu'on retourne toujours un tableau + if cas == nil { + c.JSON(http.StatusOK, []interface{}{}) + } else { + c.JSON(http.StatusOK, cas) + } +} + +func (h *Handlers) UpdateCA(c *gin.Context) { + id := c.Param("id") + var req models.UpdateCARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := make(map[string]interface{}) + if req.Name != "" { + updates["name"] = req.Name + } + if req.Organization != "" { + updates["organization"] = req.Organization + } + if req.Email != "" { + updates["email"] = req.Email + } + + if err := h.repo.UpdateCA(c.Request.Context(), id, updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "CA updated successfully"}) +} + +func (h *Handlers) DeleteCA(c *gin.Context) { + id := c.Param("id") + if err := h.repo.DeleteCA(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "CA deleted successfully"}) +} + +// SubCA Handlers +func (h *Handlers) CreateSubCA(c *gin.Context) { + var req models.CreateSubCARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get parent CA + parentCA, err := h.repo.GetCA(c.Request.Context(), req.ParentCAID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Parent CA not found"}) + return + } + + subca, err := h.cryptoService.GenerateSubCA(req, parentCA) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if err := h.repo.CreateSubCA(c.Request.Context(), subca); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, subca) +} + +func (h *Handlers) GetSubCA(c *gin.Context) { + id := c.Param("id") + subca, err := h.repo.GetSubCA(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "SubCA not found"}) + return + } + + subca.PrivateKey = "" + c.JSON(http.StatusOK, subca) +} + +// GetAllSubCAs - Retourne toujours un tableau, même vide +func (h *Handlers) GetAllSubCAs(c *gin.Context) { + subcas, err := h.repo.GetAllSubCAs(c.Request.Context()) + if err != nil { + // IMPORTANT: Retourner un tableau vide, pas une erreur + c.JSON(http.StatusOK, []interface{}{}) + return + } + + // Remove private keys from response + for _, subca := range subcas { + subca.PrivateKey = "" + } + + // S'assurer qu'on retourne toujours un tableau + if subcas == nil { + c.JSON(http.StatusOK, []interface{}{}) + } else { + c.JSON(http.StatusOK, subcas) + } +} + +func (h *Handlers) UpdateSubCA(c *gin.Context) { + id := c.Param("id") + var req models.UpdateSubCARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := make(map[string]interface{}) + if req.Name != "" { + updates["name"] = req.Name + } + if req.Organization != "" { + updates["organization"] = req.Organization + } + if req.Email != "" { + updates["email"] = req.Email + } + + if err := h.repo.UpdateSubCA(c.Request.Context(), id, updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "SubCA updated successfully"}) +} + +func (h *Handlers) DeleteSubCA(c *gin.Context) { + id := c.Param("id") + if err := h.repo.DeleteSubCA(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "SubCA deleted successfully"}) +} + +// Certificate Handlers +func (h *Handlers) CreateCertificate(c *gin.Context) { + var req models.CreateCertificateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Try to get issuer as CA first + issuer, err := h.repo.GetCA(c.Request.Context(), req.IssuerCAID) + if err != nil { + // If not found as CA, try as SubCA + issuer, err := h.repo.GetSubCA(c.Request.Context(), req.IssuerCAID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Issuer not found"}) + return + } + cert, err := h.cryptoService.GenerateCertificate(req, issuer) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := h.repo.CreateCertificate(c.Request.Context(), cert); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, cert) + return + } + + cert, err := h.cryptoService.GenerateCertificate(req, issuer) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if err := h.repo.CreateCertificate(c.Request.Context(), cert); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, cert) +} + +func (h *Handlers) GetCertificate(c *gin.Context) { + id := c.Param("id") + cert, err := h.repo.GetCertificate(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Certificate not found"}) + return + } + + cert.PrivateKey = "" + c.JSON(http.StatusOK, cert) +} + +// GetAllCertificates - Retourne toujours un tableau, même vide +func (h *Handlers) GetAllCertificates(c *gin.Context) { + certs, err := h.repo.GetAllCertificates(c.Request.Context()) + if err != nil { + // IMPORTANT: Retourner un tableau vide, pas une erreur + c.JSON(http.StatusOK, []interface{}{}) + return + } + + // Remove private keys from response + for _, cert := range certs { + cert.PrivateKey = "" + } + + // S'assurer qu'on retourne toujours un tableau + if certs == nil { + c.JSON(http.StatusOK, []interface{}{}) + } else { + c.JSON(http.StatusOK, certs) + } +} + +func (h *Handlers) DeleteCertificate(c *gin.Context) { + id := c.Param("id") + if err := h.repo.DeleteCertificate(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Certificate deleted successfully"}) +} + +func (h *Handlers) RevokeCertificate(c *gin.Context) { + id := c.Param("id") + var req models.RevokeCertificateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.repo.RevokeCertificate(c.Request.Context(), id, req.Reason); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Certificate revoked successfully"}) +} + +// Download handlers for CA and SubCA +func (h *Handlers) DownloadCACertificate(c *gin.Context) { + id := c.Param("id") + ca, err := h.repo.GetCA(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "CA not found"}) + return + } + + c.Header("Content-Type", "application/x-pem-file") + c.Header("Content-Disposition", "attachment; filename="+ca.CommonName+".crt") + c.String(http.StatusOK, ca.Certificate) +} + +func (h *Handlers) DownloadSubCACertificate(c *gin.Context) { + id := c.Param("id") + subca, err := h.repo.GetSubCA(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "SubCA not found"}) + return + } + + c.Header("Content-Type", "application/x-pem-file") + c.Header("Content-Disposition", "attachment; filename="+subca.CommonName+".crt") + c.String(http.StatusOK, subca.Certificate) +} + +// Download handlers +func (h *Handlers) DownloadCertificate(c *gin.Context) { + id := c.Param("id") + cert, err := h.repo.GetCertificate(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Certificate not found"}) + return + } + + c.Header("Content-Type", "application/x-pem-file") + c.Header("Content-Disposition", "attachment; filename="+cert.CommonName+".crt") + c.String(http.StatusOK, cert.Certificate) +} + +func (h *Handlers) DownloadPrivateKey(c *gin.Context) { + id := c.Param("id") + cert, err := h.repo.GetCertificate(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Certificate not found"}) + return + } + + c.Header("Content-Type", "application/x-pem-file") + c.Header("Content-Disposition", "attachment; filename="+cert.CommonName+".key") + c.String(http.StatusOK, cert.PrivateKey) +} + +// Web Interface +func (h *Handlers) ServeWebInterface(c *gin.Context) { + c.HTML(http.StatusOK, "index.html", nil) +} diff --git a/internal/api/routes.go b/internal/api/routes.go new file mode 100644 index 0000000..41bbd95 --- /dev/null +++ b/internal/api/routes.go @@ -0,0 +1,67 @@ +package api + +import ( + "log" + "pki-manager/config" + "pki-manager/internal/repository" + "pki-manager/internal/services" + + "github.com/gin-gonic/gin" +) + +func SetupRoutes(router *gin.Engine, repo repository.Repository, jwtSecret string) { + cfg := config.LoadConfig() + cryptoService := services.NewCryptoService(cfg.CertsPath) + handlers := NewHandlers(repo, cryptoService) + + // Add logging middleware + router.Use(func(c *gin.Context) { + log.Printf("[API] %s %s", c.Request.Method, c.Request.URL.Path) + c.Next() + }) + + // Load HTML templates + router.LoadHTMLGlob("internal/web/templates/*") + router.Static("/static", "internal/web/static") + + // Web interface + router.GET("/", handlers.ServeWebInterface) + + // API routes + api := router.Group("/api/v1") + { + // CA routes + ca := api.Group("/cas") + { + ca.POST("/", handlers.CreateCA) + ca.GET("/", handlers.GetAllCAs) + ca.GET("/:id", handlers.GetCA) + ca.PUT("/:id", handlers.UpdateCA) + ca.DELETE("/:id", handlers.DeleteCA) + ca.GET("/:id/download/cert", handlers.DownloadCACertificate) + } + + // SubCA routes + subca := api.Group("/subcas") + { + subca.POST("/", handlers.CreateSubCA) + subca.GET("/", handlers.GetAllSubCAs) + subca.GET("/:id", handlers.GetSubCA) + subca.PUT("/:id", handlers.UpdateSubCA) + subca.DELETE("/:id", handlers.DeleteSubCA) + subca.GET("/:id/download/cert", handlers.DownloadSubCACertificate) + } + + // Certificate routes + cert := api.Group("/certificates") + { + cert.POST("/", handlers.CreateCertificate) + cert.GET("/", handlers.GetAllCertificates) + cert.GET("/:id", handlers.GetCertificate) + cert.DELETE("/:id", handlers.DeleteCertificate) + cert.POST("/:id/revoke", handlers.RevokeCertificate) + cert.GET("/:id/download/cert", handlers.DownloadCertificate) + cert.GET("/:id/download/key", handlers.DownloadPrivateKey) + } + } +} diff --git a/internal/models/ca.go b/internal/models/ca.go new file mode 100644 index 0000000..c2ed7c3 --- /dev/null +++ b/internal/models/ca.go @@ -0,0 +1,43 @@ +package models + +import ( + "time" +) + +type CA struct { + ID string `json:"id" bson:"_id"` + Name string `json:"name" bson:"name"` + CommonName string `json:"common_name" bson:"common_name"` + Organization string `json:"organization" bson:"organization"` + Country string `json:"country" bson:"country"` + Province string `json:"province" bson:"province"` + Locality string `json:"locality" bson:"locality"` + Email string `json:"email" bson:"email"` + PrivateKey string `json:"private_key,omitempty" bson:"private_key"` + Certificate string `json:"certificate" bson:"certificate"` + SerialNumber string `json:"serial_number" bson:"serial_number"` + ValidFrom time.Time `json:"valid_from" bson:"valid_from"` + ValidTo time.Time `json:"valid_to" bson:"valid_to"` + IsRoot bool `json:"is_root" bson:"is_root"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` + UpdatedAt time.Time `json:"updated_at" bson:"updated_at"` +} + +type CreateCARequest struct { + Name string `json:"name" binding:"required"` + CommonName string `json:"common_name" binding:"required"` + Organization string `json:"organization" binding:"required"` + Country string `json:"country" binding:"required"` + Province string `json:"province"` + Locality string `json:"locality"` + Email string `json:"email" binding:"omitempty,email"` // omitempty permet les chaînes vides + KeySize int `json:"key_size" binding:"required,min=2048"` + ValidYears int `json:"valid_years" binding:"required,min=1,max=20"` + IsRoot bool `json:"is_root"` +} + +type UpdateCARequest struct { + Name string `json:"name"` + Organization string `json:"organization"` + Email string `json:"email"` +} diff --git a/internal/models/certificate.go b/internal/models/certificate.go new file mode 100644 index 0000000..9d68865 --- /dev/null +++ b/internal/models/certificate.go @@ -0,0 +1,38 @@ +package models + +import ( + "time" +) + +type Certificate struct { + ID string `json:"id" bson:"_id"` + CommonName string `json:"common_name" bson:"common_name"` + Subject string `json:"subject" bson:"subject"` + DNSNames []string `json:"dns_names" bson:"dns_names"` + IPAddresses []string `json:"ip_addresses" bson:"ip_addresses"` + Type string `json:"type" bson:"type"` // "server" or "client" + PrivateKey string `json:"private_key,omitempty" bson:"private_key"` + Certificate string `json:"certificate" bson:"certificate"` + SerialNumber string `json:"serial_number" bson:"serial_number"` + ValidFrom time.Time `json:"valid_from" bson:"valid_from"` + ValidTo time.Time `json:"valid_to" bson:"valid_to"` + IssuerCAID string `json:"issuer_ca_id" bson:"issuer_ca_id"` + Revoked bool `json:"revoked" bson:"revoked"` + RevokedAt time.Time `json:"revoked_at,omitempty" bson:"revoked_at"` + RevokedReason string `json:"revoked_reason,omitempty" bson:"revoked_reason"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` +} + +type CreateCertificateRequest struct { + CommonName string `json:"common_name" binding:"required"` + DNSNames []string `json:"dns_names"` + IPAddresses []string `json:"ip_addresses"` + Type string `json:"type" binding:"required,oneof=server client"` + KeySize int `json:"key_size" binding:"required,min=2048"` + ValidDays int `json:"valid_days" binding:"required,min=1,max=365"` + IssuerCAID string `json:"issuer_ca_id" binding:"required"` +} + +type RevokeCertificateRequest struct { + Reason string `json:"reason" binding:"required"` +} diff --git a/internal/models/subca.go b/internal/models/subca.go new file mode 100644 index 0000000..498d548 --- /dev/null +++ b/internal/models/subca.go @@ -0,0 +1,43 @@ +package models + +import ( + "time" +) + +type SubCA struct { + ID string `json:"id" bson:"_id"` + Name string `json:"name" bson:"name"` + CommonName string `json:"common_name" bson:"common_name"` + Organization string `json:"organization" bson:"organization"` + Country string `json:"country" bson:"country"` + Province string `json:"province" bson:"province"` + Locality string `json:"locality" bson:"locality"` + Email string `json:"email" bson:"email"` + PrivateKey string `json:"private_key,omitempty" bson:"private_key"` + Certificate string `json:"certificate" bson:"certificate"` + SerialNumber string `json:"serial_number" bson:"serial_number"` + ValidFrom time.Time `json:"valid_from" bson:"valid_from"` + ValidTo time.Time `json:"valid_to" bson:"valid_to"` + ParentCAID string `json:"parent_ca_id" bson:"parent_ca_id"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` + UpdatedAt time.Time `json:"updated_at" bson:"updated_at"` +} + +type CreateSubCARequest struct { + Name string `json:"name" binding:"required"` + CommonName string `json:"common_name" binding:"required"` + Organization string `json:"organization" binding:"required"` + Country string `json:"country" binding:"required"` + Province string `json:"province"` + Locality string `json:"locality"` + Email string `json:"email" binding:"omitempty,email"` // omitempty permet les chaînes vides + KeySize int `json:"key_size" binding:"required,min=2048"` + ValidYears int `json:"valid_years" binding:"required,min=1,max=10"` + ParentCAID string `json:"parent_ca_id" binding:"required"` +} + +type UpdateSubCARequest struct { + Name string `json:"name"` + Organization string `json:"organization"` + Email string `json:"email"` +} diff --git a/internal/repository/mongo_repository.go b/internal/repository/mongo_repository.go new file mode 100644 index 0000000..8ed8cf6 --- /dev/null +++ b/internal/repository/mongo_repository.go @@ -0,0 +1,229 @@ +package repository + +import ( + "context" + "log" + "time" + + "pki-manager/internal/models" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +type MongoRepository struct { + db *mongo.Database +} + +func NewMongoRepository(db *mongo.Database) *MongoRepository { + return &MongoRepository{db: db} +} + +func (r *MongoRepository) CreateCA(ctx context.Context, ca *models.CA) error { + ca.CreatedAt = time.Now() + ca.UpdatedAt = time.Now() + + _, err := r.db.Collection("cas").InsertOne(ctx, ca) + return err +} + +func (r *MongoRepository) GetCA(ctx context.Context, id string) (*models.CA, error) { + var ca models.CA + err := r.db.Collection("cas").FindOne(ctx, bson.M{"_id": id}).Decode(&ca) + if err != nil { + return nil, err + } + return &ca, nil +} + +func (r *MongoRepository) GetAllCAs(ctx context.Context) ([]*models.CA, error) { + var cas []*models.CA + cursor, err := r.db.Collection("cas").Find(ctx, bson.M{}) + if err != nil { + // Retourner un tableau vide au lieu d'une erreur + return []*models.CA{}, nil + } + defer cursor.Close(ctx) + + for cursor.Next(ctx) { + var ca models.CA + if err := cursor.Decode(&ca); err != nil { + continue // Ignorer les erreurs de décodage + } + cas = append(cas, &ca) + } + + return cas, nil +} + +func (r *MongoRepository) UpdateCA(ctx context.Context, id string, updates map[string]interface{}) error { + updates["updated_at"] = time.Now() + _, err := r.db.Collection("cas").UpdateOne( + ctx, + bson.M{"_id": id}, + bson.M{"$set": updates}, + ) + return err +} + +func (r *MongoRepository) DeleteCA(ctx context.Context, id string) error { + _, err := r.db.Collection("cas").DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +// SubCA methods +func (r *MongoRepository) CreateSubCA(ctx context.Context, subca *models.SubCA) error { + subca.CreatedAt = time.Now() + subca.UpdatedAt = time.Now() + + _, err := r.db.Collection("subcas").InsertOne(ctx, subca) + return err +} + +func (r *MongoRepository) GetSubCA(ctx context.Context, id string) (*models.SubCA, error) { + var subca models.SubCA + err := r.db.Collection("subcas").FindOne(ctx, bson.M{"_id": id}).Decode(&subca) + if err != nil { + return nil, err + } + return &subca, nil +} + +func (r *MongoRepository) GetSubCAsByParent(ctx context.Context, parentCAID string) ([]*models.SubCA, error) { + var subcas []*models.SubCA + cursor, err := r.db.Collection("subcas").Find(ctx, bson.M{"parent_ca_id": parentCAID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + for cursor.Next(ctx) { + var subca models.SubCA + if err := cursor.Decode(&subca); err != nil { + log.Println("Error decoding SubCA:", err) + continue + } + subcas = append(subcas, &subca) + } + return subcas, nil +} + +func (r *MongoRepository) GetAllSubCAs(ctx context.Context) ([]*models.SubCA, error) { + var subcas []*models.SubCA + cursor, err := r.db.Collection("subcas").Find(ctx, bson.M{}) + if err != nil { + // Retourner un tableau vide au lieu d'une erreur + return []*models.SubCA{}, nil + } + defer cursor.Close(ctx) + + for cursor.Next(ctx) { + var subca models.SubCA + if err := cursor.Decode(&subca); err != nil { + continue // Ignorer les erreurs de décodage + } + subcas = append(subcas, &subca) + } + + return subcas, nil +} + +func (r *MongoRepository) UpdateSubCA(ctx context.Context, id string, updates map[string]interface{}) error { + updates["updated_at"] = time.Now() + _, err := r.db.Collection("subcas").UpdateOne( + ctx, + bson.M{"_id": id}, + bson.M{"$set": updates}, + ) + return err +} + +func (r *MongoRepository) DeleteSubCA(ctx context.Context, id string) error { + _, err := r.db.Collection("subcas").DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +// Certificate methods +func (r *MongoRepository) CreateCertificate(ctx context.Context, cert *models.Certificate) error { + cert.CreatedAt = time.Now() + + _, err := r.db.Collection("certificates").InsertOne(ctx, cert) + return err +} + +func (r *MongoRepository) GetCertificate(ctx context.Context, id string) (*models.Certificate, error) { + var cert models.Certificate + err := r.db.Collection("certificates").FindOne(ctx, bson.M{"_id": id}).Decode(&cert) + if err != nil { + return nil, err + } + return &cert, nil +} + +func (r *MongoRepository) GetCertificatesByIssuer(ctx context.Context, issuerCAID string) ([]*models.Certificate, error) { + var certs []*models.Certificate + cursor, err := r.db.Collection("certificates").Find(ctx, bson.M{"issuer_ca_id": issuerCAID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + for cursor.Next(ctx) { + var cert models.Certificate + if err := cursor.Decode(&cert); err != nil { + log.Println("Error decoding certificate:", err) + continue + } + certs = append(certs, &cert) + } + return certs, nil +} + +func (r *MongoRepository) GetAllCertificates(ctx context.Context) ([]*models.Certificate, error) { + var certs []*models.Certificate + cursor, err := r.db.Collection("certificates").Find(ctx, bson.M{}) + if err != nil { + // Retourner un tableau vide au lieu d'une erreur + return []*models.Certificate{}, nil + } + defer cursor.Close(ctx) + + for cursor.Next(ctx) { + var cert models.Certificate + if err := cursor.Decode(&cert); err != nil { + continue // Ignorer les erreurs de décodage + } + certs = append(certs, &cert) + } + + return certs, nil +} + +func (r *MongoRepository) UpdateCertificate(ctx context.Context, id string, updates map[string]interface{}) error { + _, err := r.db.Collection("certificates").UpdateOne( + ctx, + bson.M{"_id": id}, + bson.M{"$set": updates}, + ) + return err +} + +func (r *MongoRepository) DeleteCertificate(ctx context.Context, id string) error { + _, err := r.db.Collection("certificates").DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +func (r *MongoRepository) RevokeCertificate(ctx context.Context, id string, reason string) error { + updates := bson.M{ + "revoked": true, + "revoked_at": time.Now(), + "revoked_reason": reason, + } + + _, err := r.db.Collection("certificates").UpdateOne( + ctx, + bson.M{"_id": id}, + bson.M{"$set": updates}, + ) + return err +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go new file mode 100644 index 0000000..eedbfb1 --- /dev/null +++ b/internal/repository/repository.go @@ -0,0 +1,32 @@ +package repository + +import ( + "context" + "pki-manager/internal/models" +) + +type Repository interface { + // CA operations + CreateCA(ctx context.Context, ca *models.CA) error + GetCA(ctx context.Context, id string) (*models.CA, error) + GetAllCAs(ctx context.Context) ([]*models.CA, error) + UpdateCA(ctx context.Context, id string, updates map[string]interface{}) error + DeleteCA(ctx context.Context, id string) error + + // SubCA operations + CreateSubCA(ctx context.Context, subca *models.SubCA) error + GetSubCA(ctx context.Context, id string) (*models.SubCA, error) + GetSubCAsByParent(ctx context.Context, parentCAID string) ([]*models.SubCA, error) + GetAllSubCAs(ctx context.Context) ([]*models.SubCA, error) + UpdateSubCA(ctx context.Context, id string, updates map[string]interface{}) error + DeleteSubCA(ctx context.Context, id string) error + + // Certificate operations + CreateCertificate(ctx context.Context, cert *models.Certificate) error + GetCertificate(ctx context.Context, id string) (*models.Certificate, error) + GetCertificatesByIssuer(ctx context.Context, issuerCAID string) ([]*models.Certificate, error) + GetAllCertificates(ctx context.Context) ([]*models.Certificate, error) + UpdateCertificate(ctx context.Context, id string, updates map[string]interface{}) error + DeleteCertificate(ctx context.Context, id string) error + RevokeCertificate(ctx context.Context, id string, reason string) error +} diff --git a/internal/services/crypto_service.go b/internal/services/crypto_service.go new file mode 100644 index 0000000..8c79324 --- /dev/null +++ b/internal/services/crypto_service.go @@ -0,0 +1,327 @@ +package services + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "time" + + "pki-manager/internal/models" +) + +type CryptoService struct { + certsPath string +} + +func NewCryptoService(certsPath string) *CryptoService { + return &CryptoService{certsPath: certsPath} +} + +func (s *CryptoService) GenerateRootCA(req models.CreateCARequest) (*models.CA, error) { + // Generate private key + priv, err := rsa.GenerateKey(rand.Reader, req.KeySize) + if err != nil { + return nil, fmt.Errorf("failed to generate private key: %v", err) + } + + // Create certificate template + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("failed to generate serial number: %v", err) + } + // Préparer le sujet avec email optionnel + subject := pkix.Name{ + CommonName: req.CommonName, + Organization: []string{req.Organization}, + Country: []string{req.Country}, + Province: []string{req.Province}, + Locality: []string{req.Locality}, + } + + // Ajouter l'email seulement s'il est fourni + if req.Email != "" { + // L'email n'est pas un champ standard dans pkix.Name + // On le stockera dans les champs personnalisés + } + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: subject, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(req.ValidYears, 0, 0), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 0, + MaxPathLenZero: true, + } + // Ajouter l'email seulement s'il est fourni + if req.Email != "" { + // L'email n'est pas un champ standard dans pkix.Name + // On le stockera dans les champs personnalisés + } + // Self-sign the certificate + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %v", err) + } + + // Encode private key + privBytes := x509.MarshalPKCS1PrivateKey(priv) + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privBytes, + }) + + // Encode certificate + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + + ca := &models.CA{ + ID: fmt.Sprintf("ca_%d", time.Now().UnixNano()), + Name: req.Name, + CommonName: req.CommonName, + Organization: req.Organization, + Country: req.Country, + Province: req.Province, + Locality: req.Locality, + Email: req.Email, + PrivateKey: string(privPEM), + Certificate: string(certPEM), + SerialNumber: serialNumber.String(), + ValidFrom: template.NotBefore, + ValidTo: template.NotAfter, + IsRoot: req.IsRoot, + } + + return ca, nil +} + +func (s *CryptoService) GenerateSubCA(req models.CreateSubCARequest, parentCA *models.CA) (*models.SubCA, error) { + // Parse parent CA certificate and private key + parentCertBlock, _ := pem.Decode([]byte(parentCA.Certificate)) + if parentCertBlock == nil { + return nil, fmt.Errorf("failed to parse parent CA certificate") + } + parentCert, err := x509.ParseCertificate(parentCertBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse parent CA certificate: %v", err) + } + + parentKeyBlock, _ := pem.Decode([]byte(parentCA.PrivateKey)) + if parentKeyBlock == nil { + return nil, fmt.Errorf("failed to parse parent CA private key") + } + parentKey, err := x509.ParsePKCS1PrivateKey(parentKeyBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse parent CA private key: %v", err) + } + + // Generate subCA private key + priv, err := rsa.GenerateKey(rand.Reader, req.KeySize) + if err != nil { + return nil, fmt.Errorf("failed to generate private key: %v", err) + } + + // Create certificate template + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("failed to generate serial number: %v", err) + } + subject := pkix.Name{ + CommonName: req.CommonName, + Organization: []string{req.Organization}, + Country: []string{req.Country}, + Province: []string{req.Province}, + Locality: []string{req.Locality}, + } + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: subject, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(req.ValidYears, 0, 0), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 0, + MaxPathLenZero: false, + } + + // Sign the certificate with parent CA + certBytes, err := x509.CreateCertificate(rand.Reader, &template, parentCert, &priv.PublicKey, parentKey) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %v", err) + } + + // Encode private key + privBytes := x509.MarshalPKCS1PrivateKey(priv) + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privBytes, + }) + + // Encode certificate + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + + subca := &models.SubCA{ + ID: fmt.Sprintf("subca_%d", time.Now().UnixNano()), + Name: req.Name, + CommonName: req.CommonName, + Organization: req.Organization, + Country: req.Country, + Province: req.Province, + Locality: req.Locality, + Email: req.Email, + PrivateKey: string(privPEM), + Certificate: string(certPEM), + SerialNumber: serialNumber.String(), + ValidFrom: template.NotBefore, + ValidTo: template.NotAfter, + ParentCAID: req.ParentCAID, + } + + return subca, nil +} + +func (s *CryptoService) GenerateCertificate(req models.CreateCertificateRequest, issuer interface{}) (*models.Certificate, error) { + var issuerCert *x509.Certificate + var issuerKey *rsa.PrivateKey + + // Parse issuer based on type + switch iss := issuer.(type) { + case *models.CA: + certBlock, _ := pem.Decode([]byte(iss.Certificate)) + if certBlock == nil { + return nil, fmt.Errorf("failed to parse issuer certificate") + } + var err error + issuerCert, err = x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse issuer certificate: %v", err) + } + + keyBlock, _ := pem.Decode([]byte(iss.PrivateKey)) + if keyBlock == nil { + return nil, fmt.Errorf("failed to parse issuer private key") + } + issuerKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse issuer private key: %v", err) + } + + case *models.SubCA: + certBlock, _ := pem.Decode([]byte(iss.Certificate)) + if certBlock == nil { + return nil, fmt.Errorf("failed to parse issuer certificate") + } + var err error + issuerCert, err = x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse issuer certificate: %v", err) + } + + keyBlock, _ := pem.Decode([]byte(iss.PrivateKey)) + if keyBlock == nil { + return nil, fmt.Errorf("failed to parse issuer private key") + } + issuerKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse issuer private key: %v", err) + } + + default: + return nil, fmt.Errorf("unsupported issuer type") + } + + // Generate certificate private key + priv, err := rsa.GenerateKey(rand.Reader, req.KeySize) + if err != nil { + return nil, fmt.Errorf("failed to generate private key: %v", err) + } + + // Create certificate template + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("failed to generate serial number: %v", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: req.CommonName, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, req.ValidDays), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: s.getExtKeyUsage(req.Type), + DNSNames: req.DNSNames, + IPAddresses: s.parseIPs(req.IPAddresses), + } + + // Sign the certificate + certBytes, err := x509.CreateCertificate(rand.Reader, &template, issuerCert, &priv.PublicKey, issuerKey) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %v", err) + } + + // Encode private key + privBytes := x509.MarshalPKCS1PrivateKey(priv) + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privBytes, + }) + + // Encode certificate + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + + cert := &models.Certificate{ + ID: fmt.Sprintf("cert_%d", time.Now().UnixNano()), + CommonName: req.CommonName, + Subject: template.Subject.String(), + DNSNames: req.DNSNames, + IPAddresses: req.IPAddresses, + Type: req.Type, + PrivateKey: string(privPEM), + Certificate: string(certPEM), + SerialNumber: serialNumber.String(), + ValidFrom: template.NotBefore, + ValidTo: template.NotAfter, + IssuerCAID: req.IssuerCAID, + Revoked: false, + } + + return cert, nil +} + +func (s *CryptoService) getExtKeyUsage(certType string) []x509.ExtKeyUsage { + switch certType { + case "server": + return []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} + case "client": + return []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} + default: + return []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth} + } +} + +func (s *CryptoService) parseIPs(ips []string) []net.IP { + var parsedIPs []net.IP + for _, ipStr := range ips { + if ip := net.ParseIP(ipStr); ip != nil { + parsedIPs = append(parsedIPs, ip) + } + } + return parsedIPs +} diff --git a/internal/web/static/css/style.css b/internal/web/static/css/style.css new file mode 100644 index 0000000..fb88ab0 --- /dev/null +++ b/internal/web/static/css/style.css @@ -0,0 +1,502 @@ +/* Ajoutez ces styles à la fin du fichier CSS existant */ + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 30px; +} + +.stat-card { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px; + border-radius: 10px; + text-align: center; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.stat-card h3 { + font-size: 16px; + margin-bottom: 10px; + opacity: 0.9; +} + +.stat-number { + font-size: 32px; + font-weight: bold; +} + +.recent-activity { + background: rgba(255, 255, 255, 0.95); + padding: 20px; + border-radius: 10px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.recent-activity h3 { + margin-bottom: 20px; + color: #333; +} + +.table-container { + overflow-x: auto; +} + +.table-container .btn { + margin-bottom: 20px; +} + +/* Spinner styles */ +.fa-spinner { + color: #667eea; + margin-bottom: 15px; +} + +/* Modal improvements */ +.modal-content { + background: white; + padding: 30px; + border-radius: 10px; + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +.modal-content h2 { + margin-bottom: 20px; + color: #333; +} + +.modal-body { + padding: 10px 0; +} + +/* Form improvements */ +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + color: #4a5568; + font-weight: 600; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid #cbd5e0; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.form-group input[type="checkbox"] { + width: auto; + margin-right: 8px; +} + +/* Button improvements */ +.btn { + padding: 10px 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; + margin-right: 10px; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.btn-danger { + background: linear-gradient(135deg, #e53e3e 0%, #c53030 100%); +} + +.btn-danger:hover { + background: linear-gradient(135deg, #c53030 0%, #9b2c2c 100%); +} + +/* Close button */ +.close-btn { + float: right; + font-size: 28px; + font-weight: bold; + color: #a0aec0; + cursor: pointer; + line-height: 20px; + transition: color 0.2s; +} + +.close-btn:hover { + color: #4a5568; +} + +/* Responsive design */ +@media (max-width: 768px) { + .container { + padding: 10px; + } + + header { + padding: 15px; + } + + nav { + flex-direction: column; + gap: 10px; + } + + .nav-btn { + width: 100%; + text-align: left; + } + + .main-content { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .modal-content { + width: 95%; + padding: 20px; + } +} + +/* Badge styles */ +.badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.badge-server { + background-color: #4299e1; + color: white; +} + +.badge-client { + background-color: #48bb78; + color: white; +} + +.badge-warning { + background-color: #ed8936; + color: white; +} + +/* Detail view styles */ +.detail-group { + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 1px solid #e2e8f0; +} + +.detail-group:last-child { + border-bottom: none; +} + +.detail-group label { + display: block; + font-weight: 600; + color: #4a5568; + margin-bottom: 5px; +} + +.detail-group span { + display: block; + color: #2d3748; + word-break: break-all; +} + +.monospace { + font-family: 'Courier New', monospace; + background-color: #f7fafc; + padding: 4px 8px; + border-radius: 4px; + font-size: 14px; +} + +.certificate-preview { + margin-top: 20px; +} + +.certificate-preview label { + display: block; + font-weight: 600; + margin-bottom: 10px; + color: #4a5568; +} + +.cert-pem { + width: 100%; + height: 200px; + font-family: 'Courier New', monospace; + font-size: 12px; + padding: 10px; + border: 1px solid #cbd5e0; + border-radius: 6px; + background-color: #f7fafc; + resize: vertical; + white-space: pre; + overflow-x: auto; +} + +.actions { + margin-top: 20px; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +/* Loading spinner */ +.loading-spinner { + text-align: center; + padding: 40px; +} + +.loading-spinner i { + font-size: 48px; + color: #667eea; + margin-bottom: 20px; +} + +.loading-spinner p { + color: #718096; + font-size: 16px; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: #718096; +} + +.empty-state i { + font-size: 48px; + margin-bottom: 20px; + color: #cbd5e0; +} + +.empty-state h3 { + margin-bottom: 10px; + color: #4a5568; +} + +/* Tooltip for buttons */ +button[title] { + position: relative; +} + +button[title]:hover::after { + content: attr(title); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background-color: #2d3748; + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; + z-index: 1000; + margin-bottom: 5px; +} + +/* Loading states */ +.loading { + text-align: center; + padding: 40px; + color: #666; +} + +.error { + text-align: center; + padding: 40px; + color: #e53e3e; + background: #fed7d7; + border-radius: 8px; +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: #718096; +} + +.empty-state p { + margin-bottom: 20px; +} + +/* Section headers */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.section-header h3 { + margin: 0; +} + +/* Table improvements */ +.table { + width: 100%; + border-collapse: collapse; + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.table th { + background: #f7fafc; + padding: 12px 15px; + text-align: left; + font-weight: 600; + color: #4a5568; + border-bottom: 2px solid #e2e8f0; +} + +.table td { + padding: 12px 15px; + border-bottom: 1px solid #e2e8f0; +} + +.table tr:last-child td { + border-bottom: none; +} + +.table tr:hover { + background: #f7fafc; +} + +/* Status badges */ +.status { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.status.valid { + background: #c6f6d5; + color: #22543d; +} + +.status.revoked { + background: #fed7d7; + color: #742a2a; +} + +.status.expired { + background: #feebc8; + color: #744210; +} + +/* Button styles */ +.btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + transition: all 0.2s; +} + +.btn i { + font-size: 14px; +} + +.btn.primary { + background: #667eea; + color: white; +} + +.btn.primary:hover { + background: #5a67d8; +} + +.btn.small { + padding: 4px 8px; + font-size: 13px; +} + +.btn.danger { + background: #fc8181; + color: #742a2a; +} + +.btn.danger:hover { + background: #f56565; +} + +.btn.warning { + background: #ed8936; + color: #744210; +} + +.btn.warning:hover { + background: #dd6b20; +} + +.actions { + display: flex; + gap: 5px; +} + +/* Modal animations */ +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} \ No newline at end of file diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js new file mode 100644 index 0000000..2891135 --- /dev/null +++ b/internal/web/static/js/app.js @@ -0,0 +1,887 @@ +class PKIManager { + constructor() { + this.apiBase = '/api/v1'; + this.currentTab = 'dashboard'; + this.cas = []; + this.subcas = []; + this.certificates = []; + this.init(); + } + + init() { + this.bindEvents(); + this.showTab('dashboard'); + } + + bindEvents() { + // Tab navigation + document.querySelectorAll('.nav-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const tab = e.target.dataset.tab || e.target.closest('.nav-btn').dataset.tab; + this.showTab(tab); + }); + }); + + // Form submissions + const caForm = document.getElementById('createCAForm'); + const subcaForm = document.getElementById('createSubCAForm'); + const certForm = document.getElementById('createCertForm'); + + if (caForm) caForm.addEventListener('submit', (e) => this.handleCreateCA(e)); + if (subcaForm) subcaForm.addEventListener('submit', (e) => this.handleCreateSubCA(e)); + if (certForm) certForm.addEventListener('submit', (e) => this.handleCreateCertificate(e)); + + // Close modal buttons + document.addEventListener('click', (e) => { + if (e.target.classList.contains('close-btn') || e.target.closest('.close-btn')) { + this.hideModal(); + } + }); + + // Click outside modal + window.addEventListener('click', (e) => { + if (e.target.classList.contains('modal')) { + this.hideModal(); + } + }); + } + + showTab(tabName) { + console.log(`Showing tab: ${tabName}`); + + // Hide all tabs + document.querySelectorAll('.tab-content').forEach(tab => { + tab.classList.add('hidden'); + }); + + // Update active button + document.querySelectorAll('.nav-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + const activeBtn = document.querySelector(`[data-tab="${tabName}"]`); + if (activeBtn) activeBtn.classList.add('active'); + + // Show selected tab + const tabElement = document.getElementById(tabName + 'Tab'); + if (tabElement) { + tabElement.classList.remove('hidden'); + } + + this.currentTab = tabName; + + // Load data for tab + setTimeout(() => this.loadTabData(tabName), 50); + } + + async loadTabData(tabName) { + switch(tabName) { + case 'dashboard': + await this.loadDashboard(); + break; + case 'cas': + await this.loadCAs(); + break; + case 'subcas': + await this.loadSubCAs(); + break; + case 'certificates': + await this.loadCertificates(); + break; + } + } + + async loadDashboard() { + const content = document.getElementById('dashboardContent'); + if (!content) return; + + content.innerHTML = '
Loading dashboard...
'; + + try { + // Charger les données + await this.fetchAllData(); + + content.innerHTML = ` +
+
+

Root CAs

+

${this.cas.filter(c => c.is_root).length}

+
+
+

Sub CAs

+

${this.subcas.length}

+
+
+

Certificates

+

${this.certificates.length}

+
+
+

Active

+

${this.certificates.filter(c => !c.revoked && new Date(c.valid_to) > new Date()).length}

+
+
+
+

Recent Certificates

+ ${this.certificates.length > 0 ? ` + + + + + + ${this.certificates.slice(0, 5).map(cert => ` + + + + + + + + `).join('')} + +
NameTypeIssuedExpiresStatus
${cert.common_name}${cert.type}${new Date(cert.created_at).toLocaleDateString()}${new Date(cert.valid_to).toLocaleDateString()} + ${cert.revoked ? 'Revoked' : new Date(cert.valid_to) > new Date() ? 'Valid' : 'Expired'} +
+ ` : '

No certificates yet

'} +
+ `; + } catch (error) { + console.error('Dashboard error:', error); + content.innerHTML = '
Failed to load dashboard
'; + } + } + + async loadCAs() { + const content = document.getElementById('casContent'); + if (!content) return; + + content.innerHTML = '
Loading CAs...
'; + + try { + await this.fetchCAs(); + + content.innerHTML = ` +
+

Certificate Authorities

+ +
+ ${this.cas.length > 0 ? ` + + + + + + + + + + + + + ${this.cas.map(ca => ` + + + + + + + + + `).join('')} + +
NameCommon NameOrganizationValid FromValid ToActions
${ca.name}${ca.common_name}${ca.organization}${new Date(ca.valid_from).toLocaleDateString()}${new Date(ca.valid_to).toLocaleDateString()} + + +
+ ` : ` +
+

No Certificate Authorities found

+ +
+ `} + `; + } catch (error) { + console.error('CAs error:', error); + content.innerHTML = '
Failed to load CAs
'; + } + } + + async loadSubCAs() { + const content = document.getElementById('subcasContent'); + if (!content) return; + + content.innerHTML = '
Loading Sub CAs...
'; + + try { + await this.fetchSubCAs(); + await this.fetchCAs(); // Need CAs for parent info + + content.innerHTML = ` +
+

Sub Certificate Authorities

+ +
+ ${this.subcas.length > 0 ? ` + + + + + + + + + + + + + ${this.subcas.map(subca => { + const parent = this.cas.find(ca => ca.id === subca.parent_ca_id); + return ` + + + + + + + + + `; + }).join('')} + +
NameCommon NameParent CAValid FromValid ToActions
${subca.name}${subca.common_name}${parent ? parent.name : 'Unknown'}${new Date(subca.valid_from).toLocaleDateString()}${new Date(subca.valid_to).toLocaleDateString()} + + +
+ ` : ` +
+

No Sub Certificate Authorities found

+ ${this.cas.length > 0 ? ` + + ` : '

Create a CA first to create Sub CAs

'} +
+ `} + `; + } catch (error) { + console.error('SubCAs error:', error); + content.innerHTML = '
Failed to load Sub CAs
'; + } + } + + async loadCertificates() { + const content = document.getElementById('certsContent'); + if (!content) return; + + content.innerHTML = '
Loading Certificates...
'; + + try { + await this.fetchCertificates(); + await this.fetchCAs(); + await this.fetchSubCAs(); + + content.innerHTML = ` +
+

Certificates

+ +
+ ${this.certificates.length > 0 ? ` + + + + + + + + + + + + + + ${this.certificates.map(cert => { + const issuer = [...this.cas, ...this.subcas].find(i => i.id === cert.issuer_ca_id); + const status = cert.revoked ? 'revoked' : new Date(cert.valid_to) > new Date() ? 'valid' : 'expired'; + return ` + + + + + + + + + + `; + }).join('')} + +
Common NameTypeIssuerIssuedExpiresStatusActions
${cert.common_name}${cert.type}${issuer ? issuer.name : 'Unknown'}${new Date(cert.created_at).toLocaleDateString()}${new Date(cert.valid_to).toLocaleDateString()}${status.charAt(0).toUpperCase() + status.slice(1)} + + + ${!cert.revoked ? `` : ''} + +
+ ` : ` +
+

No certificates found

+ ${this.cas.length > 0 || this.subcas.length > 0 ? ` + + ` : '

Create a CA or Sub CA first

'} +
+ `} + `; + } catch (error) { + console.error('Certificates error:', error); + content.innerHTML = '
Failed to load certificates
'; + } + } + + async fetchAllData() { + try { + const [cas, subcas, certs] = await Promise.all([ + this.fetchCAs(), + this.fetchSubCAs(), + this.fetchCertificates() + ]); + return { cas, subcas, certs }; + } catch (error) { + console.error('Error fetching all data:', error); + throw error; + } + } + +async fetchCAs() { + try { + console.log('Fetching CAs from:', `${this.apiBase}/cas`); + const response = await fetch(`${this.apiBase}/cas`); + + console.log('CAs response status:', response.status); + + if (!response.ok) { + console.warn(`CAs fetch failed: ${response.status} ${response.statusText}`); + this.cas = []; + return this.cas; + } + + const data = await response.json(); + console.log('CAs raw data:', data); + + // S'assurer que c'est un tableau + this.cas = Array.isArray(data) ? data : []; + console.log(`Loaded ${this.cas.length} CAs`); + + return this.cas; + } catch (error) { + console.error('CAs fetch error:', error); + this.cas = []; + return this.cas; + } +} + +async fetchSubCAs() { + try { + console.log('Fetching SubCAs from:', `${this.apiBase}/subcas`); + const response = await fetch(`${this.apiBase}/subcas`); + + console.log('SubCAs response status:', response.status); + + if (!response.ok) { + console.warn(`SubCAs fetch failed: ${response.status} ${response.statusText}`); + this.subcas = []; + return this.subcas; + } + + const data = await response.json(); + console.log('SubCAs raw data:', data); + + // S'assurer que c'est un tableau + this.subcas = Array.isArray(data) ? data : []; + console.log(`Loaded ${this.subcas.length} SubCAs`); + + return this.subcas; + } catch (error) { + console.error('SubCAs fetch error:', error); + this.subcas = []; + return this.subcas; + } +} + + async fetchCertificates() { + try { + console.log('Fetching certificates...'); + const response = await fetch(`${this.apiBase}/certificates`); + + if (!response.ok) { + console.warn(`Certificates fetch failed: ${response.status}`); + this.certificates = []; + return this.certificates; + } + + const data = await response.json(); + this.certificates = Array.isArray(data) ? data : []; + console.log(`Loaded ${this.certificates.length} certificates`); + return this.certificates; + } catch (error) { + console.warn('Certificates fetch error:', error); + this.certificates = []; + return this.certificates; + } + } + + async showCreateCAModal() { + const modal = document.getElementById('createCAModal'); + if (modal) modal.classList.remove('hidden'); + } + + async showCreateSubCAModal() { + try { + // Charger les CAs pour le dropdown + await this.fetchCAs(); + + if (this.cas.length === 0) { + this.showError('Create a CA first before creating a Sub CA'); + return; + } + + // Mettre à jour le dropdown + const dropdown = document.getElementById('parentCA'); + if (dropdown) { + dropdown.innerHTML = this.cas.map(ca => + `` + ).join(''); + } + + const modal = document.getElementById('createSubCAModal'); + if (modal) modal.classList.remove('hidden'); + } catch (error) { + console.error('Error showing SubCA modal:', error); + this.showError('Failed to load CAs'); + } + } + + async showCreateCertModal() { + console.log('=== showCreateCertModal START ==='); + + try { + // 1. Charger les données + console.log('Loading issuers...'); + await this.fetchCAs(); + await this.fetchSubCAs(); + + // 2. Vérifier les données + console.log(`CAs: ${this.cas.length} items`, this.cas); + console.log(`SubCAs: ${this.subcas.length} items`, this.subcas); + + // 3. Combiner tous les émetteurs + const allIssuers = []; + + // Ajouter les CAs + if (this.cas && Array.isArray(this.cas)) { + this.cas.forEach(ca => { + if (ca && ca.id) { + allIssuers.push({ + id: ca.id, + name: ca.name || ca.common_name || 'Unnamed CA', + common_name: ca.common_name || 'No CN', + type: 'CA' + }); + } + }); + } + + // Ajouter les SubCAs + if (this.subcas && Array.isArray(this.subcas)) { + this.subcas.forEach(subca => { + if (subca && subca.id) { + allIssuers.push({ + id: subca.id, + name: subca.name || subca.common_name || 'Unnamed SubCA', + common_name: subca.common_name || 'No CN', + type: 'SubCA' + }); + } + }); + } + + console.log('All issuers combined:', allIssuers); + + if (allIssuers.length === 0) { + this.showError('Please create a CA or Sub CA first.'); + return; + } + + // 4. Remplir le dropdown + const dropdown = document.getElementById('issuerCA'); + if (!dropdown) { + console.error('ERROR: Dropdown #issuerCA not found in DOM!'); + // Vérifier si l'élément existe avec un autre ID + console.log('Available select elements:', document.querySelectorAll('select')); + return; + } + + console.log('Dropdown found, populating...'); + + // Sauvegarder la sélection actuelle + const currentValue = dropdown.value; + + // Vider et remplir + dropdown.innerHTML = ''; + + allIssuers.forEach(issuer => { + const option = document.createElement('option'); + option.value = issuer.id; + option.textContent = `${issuer.name} (${issuer.type})`; + dropdown.appendChild(option); + }); + + // Restaurer la sélection si possible + if (currentValue && allIssuers.some(i => i.id === currentValue)) { + dropdown.value = currentValue; + } + + console.log(`Dropdown populated with ${allIssuers.length} options`); + console.log('Dropdown HTML:', dropdown.innerHTML); + + // 5. Afficher la modal + const modal = document.getElementById('createCertModal'); + if (modal) { + modal.classList.remove('hidden'); + console.log('Modal shown'); + + // Focus sur le premier champ + setTimeout(() => { + const firstInput = modal.querySelector('input, select'); + if (firstInput) firstInput.focus(); + }, 100); + } else { + console.error('ERROR: Modal #createCertModal not found!'); + } + + } catch (error) { + console.error('Error in showCreateCertModal:', error); + this.showError('Failed to open certificate form: ' + error.message); + } + + console.log('=== showCreateCertModal END ==='); + } + + hideModal() { + document.querySelectorAll('.modal').forEach(modal => { + modal.classList.add('hidden'); + }); + } + + async handleCreateCA(e) { + e.preventDefault(); + const formData = new FormData(e.target); + const data = Object.fromEntries(formData.entries()); + + try { + const response = await fetch(`${this.apiBase}/cas`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: data.name, + common_name: data.common_name, + organization: data.organization, + country: data.country, + province: data.province || '', + locality: data.locality || '', + email: data.email || '', + key_size: parseInt(data.key_size) || 4096, + valid_years: parseInt(data.valid_years) || 10, + is_root: data.is_root === 'true' + }) + }); + + if (response.ok) { + this.showSuccess('CA created successfully'); + this.hideModal(); + e.target.reset(); + await this.fetchAllData(); + this.showTab(this.currentTab); + } else { + const error = await response.json(); + throw new Error(error.error || 'Failed to create CA'); + } + } catch (error) { + console.error('CA creation error:', error); + this.showError('Failed to create CA: ' + error.message); + } + } + + async handleCreateSubCA(e) { + e.preventDefault(); + const formData = new FormData(e.target); + const data = Object.fromEntries(formData.entries()); + + try { + const response = await fetch(`${this.apiBase}/subcas`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: data.name, + common_name: data.common_name, + organization: data.organization, + email: data.email || '', + country: data.country, + province: data.province || '', + locality: data.locality || '', + parent_ca_id: data.parent_ca_id, + key_size: parseInt(data.key_size) || 4096, + valid_years: parseInt(data.valid_years) || 5 + }) + }); + + if (response.ok) { + this.showSuccess('Sub CA created successfully'); + this.hideModal(); + e.target.reset(); + await this.fetchAllData(); + this.showTab(this.currentTab); + } else { + const error = await response.json(); + throw new Error(error.error || 'Failed to create Sub CA'); + } + } catch (error) { + console.error('SubCA creation error:', error); + this.showError('Failed to create Sub CA: ' + error.message); + } + } + + async handleCreateCertificate(e) { + e.preventDefault(); + const formData = new FormData(e.target); + const data = Object.fromEntries(formData.entries()); + + try { + const dnsNames = data.dns_names ? data.dns_names.split(',').map(s => s.trim()).filter(s => s) : []; + const ipAddresses = data.ip_addresses ? data.ip_addresses.split(',').map(s => s.trim()).filter(s => s) : []; + + const response = await fetch(`${this.apiBase}/certificates`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + common_name: data.common_name, + type: data.type, + dns_names: dnsNames, + ip_addresses: ipAddresses, + issuer_ca_id: data.issuer_ca_id, + key_size: parseInt(data.key_size) || 2048, + valid_days: parseInt(data.valid_days) || 365 + }) + }); + + if (response.ok) { + this.showSuccess('Certificate created successfully'); + this.hideModal(); + e.target.reset(); + await this.fetchAllData(); + this.showTab(this.currentTab); + } else { + const error = await response.json(); + throw new Error(error.error || 'Failed to create certificate'); + } + } catch (error) { + console.error('Certificate creation error:', error); + this.showError('Failed to create certificate: ' + error.message); + } + } + + async deleteCA(id) { + if (!confirm('Delete this CA?')) return; + + try { + const response = await fetch(`${this.apiBase}/cas/${id}`, { method: 'DELETE' }); + if (response.ok) { + this.showSuccess('CA deleted'); + await this.fetchAllData(); + this.showTab(this.currentTab); + } else { + throw new Error('Delete failed'); + } + } catch (error) { + this.showError('Failed to delete CA'); + } + } + + async deleteSubCA(id) { + if (!confirm('Delete this Sub CA?')) return; + + try { + const response = await fetch(`${this.apiBase}/subcas/${id}`, { method: 'DELETE' }); + if (response.ok) { + this.showSuccess('Sub CA deleted'); + await this.fetchAllData(); + this.showTab(this.currentTab); + } else { + throw new Error('Delete failed'); + } + } catch (error) { + this.showError('Failed to delete Sub CA'); + } + } + + async deleteCertificate(id) { + if (!confirm('Delete this certificate?')) return; + + try { + const response = await fetch(`${this.apiBase}/certificates/${id}`, { method: 'DELETE' }); + if (response.ok) { + this.showSuccess('Certificate deleted'); + await this.fetchAllData(); + this.showTab(this.currentTab); + } else { + throw new Error('Delete failed'); + } + } catch (error) { + this.showError('Failed to delete certificate'); + } + } + + async revokeCertificate(id) { + const reason = prompt('Revocation reason:') || 'Administrative revocation'; + if (!reason) return; + + try { + const response = await fetch(`${this.apiBase}/certificates/${id}/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason }) + }); + + if (response.ok) { + this.showSuccess('Certificate revoked'); + await this.fetchAllData(); + this.showTab(this.currentTab); + } else { + throw new Error('Revocation failed'); + } + } catch (error) { + this.showError('Failed to revoke certificate'); + } + } + + downloadCertificate(id) { + window.open(`${this.apiBase}/certificates/${id}/download/cert`, '_blank'); + } + + async viewCA(id) { + try { + const response = await fetch(`${this.apiBase}/cas/${id}`); + if (!response.ok) throw new Error('Not found'); + + const ca = await response.json(); + this.showModal('CA Details', ` +

${ca.name}

+

Common Name: ${ca.common_name}

+

Organization: ${ca.organization}

+

Valid From: ${new Date(ca.valid_from).toLocaleString()}

+

Valid To: ${new Date(ca.valid_to).toLocaleString()}

+

Serial: ${ca.serial_number}

+ + `); + } catch (error) { + this.showError('Failed to load CA details'); + } + } + + async viewSubCA(id) { + try { + const response = await fetch(`${this.apiBase}/subcas/${id}`); + if (!response.ok) throw new Error('Not found'); + + const subca = await response.json(); + this.showModal('Sub CA Details', ` +

${subca.name}

+

Common Name: ${subca.common_name}

+

Organization: ${subca.organization}

+

Valid From: ${new Date(subca.valid_from).toLocaleString()}

+

Valid To: ${new Date(subca.valid_to).toLocaleString()}

+

Serial: ${subca.serial_number}

+ + `); + } catch (error) { + this.showError('Failed to load Sub CA details'); + } + } + + async viewCertificate(id) { + try { + const response = await fetch(`${this.apiBase}/certificates/${id}`); + if (!response.ok) throw new Error('Not found'); + + const cert = await response.json(); + this.showModal('Certificate Details', ` +

${cert.common_name}

+

Type: ${cert.type}

+

Valid From: ${new Date(cert.valid_from).toLocaleString()}

+

Valid To: ${new Date(cert.valid_to).toLocaleString()}

+

Status: ${cert.revoked ? 'Revoked' : new Date(cert.valid_to) > new Date() ? 'Valid' : 'Expired'}

+

Serial: ${cert.serial_number}

+
+ + +
+ `); + } catch (error) { + this.showError('Failed to load certificate details'); + } + } + + showModal(title, content) { + const modal = document.getElementById('viewModal'); + if (!modal) return; + + modal.querySelector('h2').textContent = title; + modal.querySelector('.modal-body').innerHTML = content; + modal.classList.remove('hidden'); + } + + showSuccess(message) { + this.showAlert(message, 'success'); + } + + showError(message) { + this.showAlert(message, 'error'); + } + + showAlert(message, type) { + // Remove existing alerts + document.querySelectorAll('.alert').forEach(a => a.remove()); + + const alert = document.createElement('div'); + alert.className = `alert alert-${type}`; + alert.textContent = message; + alert.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 15px 20px; + border-radius: 5px; + z-index: 10000; + animation: slideIn 0.3s ease; + `; + + document.body.appendChild(alert); + + setTimeout(() => { + alert.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => alert.remove(), 300); + }, 5000); + } +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + window.pki = new PKIManager(); +}); \ No newline at end of file diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html new file mode 100644 index 0000000..af579d1 --- /dev/null +++ b/internal/web/templates/index.html @@ -0,0 +1,307 @@ + + + + + + PKI Manager + + + + +
+
+

PKI Manager

+

Manage your Certificate Authority infrastructure

+
+ + + + +
+ +
+ +
+ +
+
+ +
+ +

Loading dashboard...

+
+
+
+ + + + + + + + + +
+
+ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/init-mongo.js b/scripts/init-mongo.js new file mode 100644 index 0000000..5c97802 --- /dev/null +++ b/scripts/init-mongo.js @@ -0,0 +1,21 @@ +db = db.getSiblingDB('pki_db'); + +// Create collections +db.createCollection('cas'); +db.createCollection('subcas'); +db.createCollection('certificates'); + +// Create indexes +db.cas.createIndex({ name: 1 }, { unique: true }); +db.cas.createIndex({ common_name: 1 }, { unique: true }); + +db.subcas.createIndex({ name: 1 }, { unique: true }); +db.subcas.createIndex({ common_name: 1 }, { unique: true }); +db.subcas.createIndex({ parent_ca_id: 1 }); + +db.certificates.createIndex({ common_name: 1 }); +db.certificates.createIndex({ issuer_ca_id: 1 }); +db.certificates.createIndex({ serial_number: 1 }, { unique: true }); +db.certificates.createIndex({ revoked: 1 }); + +print('PKI database initialized successfully'); \ No newline at end of file