From 6ee80f1c8fb552e3af28fc72040840527e86d07f Mon Sep 17 00:00:00 2001 From: Aleksei Sokol Date: Sun, 16 Aug 2026 16:23:14 +0300 Subject: [PATCH] Codevault initial commit --- .dockerignore | 7 + .gitignore | 6 + Dockerfile | 23 ++ Makefile | 28 ++ README.md | 111 ++++++ cmd/add_user.go | 83 ++++ cmd/config_example.go | 31 ++ cmd/root.go | 23 ++ cmd/run.go | 123 ++++++ docker-compose.yaml.example | 30 ++ go.mod | 102 +++++ go.sum | 324 ++++++++++++++++ internal/api/doc.go | 4 + internal/api/generated.go | 363 ++++++++++++++++++ internal/api/handlers.go | 55 +++ internal/api/middleware.go | 81 ++++ internal/api/oapi-codegen.yaml | 7 + internal/api/openapi.yaml | 73 ++++ internal/api/swagger.go | 35 ++ internal/auth/password.go | 37 ++ internal/config/config.go | 74 ++++ internal/config/database.go | 14 + internal/config/observability.go | 131 +++++++ internal/config/ratelimit.go | 21 + internal/config/server.go | 37 ++ internal/db/db.go | 30 ++ internal/db/schema.sql | 12 + internal/db/secrets.go | 42 ++ internal/db/users.go | 51 +++ internal/httpserver/server.go | 116 ++++++ internal/observability/logging/configure.go | 139 +++++++ internal/observability/logging/ctx.go | 41 ++ internal/observability/metrics/configure.go | 83 ++++ internal/observability/metrics/metrics.go | 110 ++++++ internal/observability/otel.go | 18 + internal/ratelimit/limiter.go | 145 +++++++ internal/ratelimit/limiter_test.go | 101 +++++ internal/runcmd/add_user.go | 32 ++ internal/runcmd/config_example.go | 59 +++ internal/runcmd/run.go | 54 +++ internal/utils/env.go | 17 + internal/utils/gshutdown/graceful_shutdown.go | 174 +++++++++ internal/version.go | 4 + internal/webui/embed.go | 12 + internal/webui/static/get.html | 48 +++ internal/webui/static/set.html | 59 +++ main.go | 7 + 47 files changed, 3177 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/add_user.go create mode 100644 cmd/config_example.go create mode 100644 cmd/root.go create mode 100644 cmd/run.go create mode 100644 docker-compose.yaml.example create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/doc.go create mode 100644 internal/api/generated.go create mode 100644 internal/api/handlers.go create mode 100644 internal/api/middleware.go create mode 100644 internal/api/oapi-codegen.yaml create mode 100644 internal/api/openapi.yaml create mode 100644 internal/api/swagger.go create mode 100644 internal/auth/password.go create mode 100644 internal/config/config.go create mode 100644 internal/config/database.go create mode 100644 internal/config/observability.go create mode 100644 internal/config/ratelimit.go create mode 100644 internal/config/server.go create mode 100644 internal/db/db.go create mode 100644 internal/db/schema.sql create mode 100644 internal/db/secrets.go create mode 100644 internal/db/users.go create mode 100644 internal/httpserver/server.go create mode 100644 internal/observability/logging/configure.go create mode 100644 internal/observability/logging/ctx.go create mode 100644 internal/observability/metrics/configure.go create mode 100644 internal/observability/metrics/metrics.go create mode 100644 internal/observability/otel.go create mode 100644 internal/ratelimit/limiter.go create mode 100644 internal/ratelimit/limiter_test.go create mode 100644 internal/runcmd/add_user.go create mode 100644 internal/runcmd/config_example.go create mode 100644 internal/runcmd/run.go create mode 100644 internal/utils/env.go create mode 100644 internal/utils/gshutdown/graceful_shutdown.go create mode 100644 internal/version.go create mode 100644 internal/webui/embed.go create mode 100644 internal/webui/static/get.html create mode 100644 internal/webui/static/set.html create mode 100644 main.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b064cec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +codevault +codevault.exe +*.db +*.log +config*.yaml* +docker-compose.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8bcaa9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/codevault +/codevault.exe +/*.db* +/*.log +config*.yaml* +docker-compose.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f327347 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.26-alpine AS build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /out/codevault . + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=build /out/codevault ./codevault + +# /app/data's ownership is picked up by Docker as the initial content of any +# named volume mounted there, so the "nobody" user can still write the DB file. +RUN mkdir -p /app/data && chown -R nobody:nobody /app/data +USER nobody + +EXPOSE 8080 + +ENTRYPOINT ["./codevault"] +CMD ["run", "--config", "/app/config.yaml"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1694ee0 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +build: + go build + +build-linux: + GOOS=linux go build + +docker-build: + docker build -t codevault . + +# go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0 +lint: + golangci-lint run + +# go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.5.1 +generate: + go generate ./... + +test: + go test ./... + +config-example: + go run . config-example ./config.yaml + +add-user: + go run . add-user --login admin + +run: + go run . run --config ./config.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..63f7b56 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# CodeVault + +A small HTTP service that stores string values under access codes: write a +value with `setValue` under a code, later retrieve it with `getValue` using +that code. Basic Auth protects writes; reads are open but rate-limited by +failed-lookup count per client IP to slow down code guessing. + +## Features + +- `POST /api/v1/setValue` — store a value under a code (Basic Auth required). +- `POST /api/v1/getValue` — retrieve a value by code (public, rate-limited). +- Interactive API docs at `/api` (Swagger UI, embedded assets — no CDN). +- Prometheus metrics for HTTP requests and business events (successful/failed + gets and sets). +- OpenTelemetry logging, with optional OTLP export. +- SQLite storage (pure Go driver, no CGO). +- Configurable trusted proxies so client IPs are resolved correctly behind a + reverse proxy (e.g. nginx). + +## Running + +### Locally + +```sh +go build +./codevault config-example ./config.yaml # write a starter config +./codevault add-user --db-path ./codevault.db --login admin # prompts for a password +./codevault run --config ./config.yaml +``` + +Or via the Makefile: `make build`, `make config-example`, `make add-user`, +`make run`. + +### Docker + +```sh +cp docker-compose.yaml.example docker-compose.yaml +go run . config-example ./config.yaml # or hand-write one, see Configuration below +docker compose up -d --build +docker compose exec codevault ./codevault add-user --db-path /app/data/codevault.db --login admin +``` + +The compose file mounts `config.yaml` read-only and keeps the SQLite database +in a named volume (`codevault-data`). Edit `server.trusted_proxies` in your +`config.yaml` to match the proxy/network actually in front of the container +(gin needs IPs/CIDRs, not hostnames). + +## Configuration + +`config-example` writes a documented starting point. Key sections: + +```yaml +environment: development # development | production +server: + address: :8080 + trusted_proxies: # IPs/CIDRs allowed to set X-Forwarded-For / X-Real-IP + - 172.16.0.0/12 # empty/omitted = trust nothing, use the raw remote address +database: + path: ./codevault.db +rate_limit: + max_failures: 10 # failed getValue lookups per IP before 404-ing everything + window: 1m0s # sliding window for max_failures +observability: + logging: + stderr: INFO # DEBUG|INFO|WARNING|ERROR|PANIC|FATAL, empty disables + files: + INFO: ./info.log # per-level log files, optional + exporter: # optional OTLP log export + level: INFO + endpoint: http://127.0.0.1:4317/v1/logs + transport: grpc # grpc | http + metrics: + host: 127.0.0.1 + port: 9092 # Prometheus /metrics endpoint +``` + +## API + +Full OpenAPI spec: [internal/api/openapi.yaml](internal/api/openapi.yaml). +When the server is running, browse it interactively at `/api`, or fetch the +raw spec from `/api/openapi.json`. + +- `POST /api/v1/getValue` `{"code": "..."}` → `200 {"value": "..."}` / + `404` (not found or rate-limited) / `400` (bad body). +- `POST /api/v1/setValue` `{"code": "...", "value": "..."}` (Basic Auth) → + `204` / `400` (bad body) / `401` (bad credentials). + +## Metrics + +Exposed on `http://:/metrics`: + +- `http_requests_total`, `http_request_duration_seconds` — generic per-route + HTTP metrics. +- `codevault_get_value_success_total{code}` — successful getValue lookups, by + code. +- `codevault_get_value_failed_total{reason}` — failed lookups, `reason` is + `not_found` or `rate_limited`. +- `codevault_set_value_success_total{user}` — successful setValue writes, by + authenticated login. +- `codevault_set_value_failed_total{reason}` — failed writes, `reason` is + `bad_request` or `unauthorized`. + +## Development + +```sh +make build # go build +make lint # golangci-lint +make generate # regenerate internal/api from openapi.yaml (oapi-codegen) +make test # go test ./... +make docker-build +``` diff --git a/cmd/add_user.go b/cmd/add_user.go new file mode 100644 index 0000000..61a3655 --- /dev/null +++ b/cmd/add_user.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/kanootoko/codevault/internal/runcmd" +) + +var addUserCmd = &cobra.Command{ + Use: "add-user", + Short: "Add or update a user in the database", + Long: `Add or update a user in the database. Does not read the application config - +only a database path is needed; if the database file does not exist it is +created together with its schema.`, + RunE: runAddUser, +} + +func init() { + addUserCmd.Flags().String("db-path", "./codevault.db", "Path to the SQLite database file") + addUserCmd.Flags().String("login", "", "Login of the user to add or update") + addUserCmd.Flags().String("password", "", "Password for the user (if omitted, prompted interactively; "+ + "passing it as a flag can leak the password via shell history)") + _ = addUserCmd.MarkFlagRequired("login") + + rootCmd.AddCommand(addUserCmd) +} + +func runAddUser(cmd *cobra.Command, args []string) error { + dbPath, err := cmd.Flags().GetString("db-path") + if err != nil { + return fmt.Errorf("error on getting db-path option: %w", err) + } + login, err := cmd.Flags().GetString("login") + if err != nil { + return fmt.Errorf("error on getting login option: %w", err) + } + password, err := cmd.Flags().GetString("password") + if err != nil { + return fmt.Errorf("error on getting password option: %w", err) + } + + if password == "" { + password, err = readPasswordWithConfirmation(cmd) + if err != nil { + return fmt.Errorf("error on reading password: %w", err) + } + } + + if err := runcmd.AddUser(dbPath, login, password); err != nil { + return fmt.Errorf("error on adding user: %w", err) + } + + cmd.Println("user saved successfully") + return nil +} + +func readPasswordWithConfirmation(cmd *cobra.Command) (string, error) { + fd := int(os.Stdin.Fd()) + + cmd.Print("Password: ") + password, err := term.ReadPassword(fd) + cmd.Println() + if err != nil { + return "", fmt.Errorf("error on reading password: %w", err) + } + + cmd.Print("Confirm password: ") + confirmation, err := term.ReadPassword(fd) + cmd.Println() + if err != nil { + return "", fmt.Errorf("error on reading password confirmation: %w", err) + } + + if string(password) != string(confirmation) { + return "", fmt.Errorf("passwords do not match") + } + + return string(password), nil +} diff --git a/cmd/config_example.go b/cmd/config_example.go new file mode 100644 index 0000000..8bda241 --- /dev/null +++ b/cmd/config_example.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/kanootoko/codevault/internal/runcmd" +) + +var configExampleCmd = &cobra.Command{ + Use: "config-example ", + Short: "Get server config example", + Long: `Generate and save to file a sample config for further manual editing.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + configPath := args[0] + + err := runcmd.ConfigExample(configPath) + if err != nil && !errors.Is(err, context.Canceled) { + return fmt.Errorf("error on saving example config: %w", err) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(configExampleCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..7010768 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/kanootoko/codevault/internal" +) + +var rootCmd = &cobra.Command{ + Use: "codevault", + Short: "CodeVault - store and retrieve secrets by access code", + Long: `CodeVault is a small HTTP service that stores string values under access codes.`, + SilenceUsage: true, + Version: internal.Version, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 0000000..8620ebc --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/kanootoko/codevault/internal/config" + "github.com/kanootoko/codevault/internal/observability/logging" + "github.com/kanootoko/codevault/internal/observability/metrics" + "github.com/kanootoko/codevault/internal/runcmd" + "github.com/kanootoko/codevault/internal/utils" + "github.com/kanootoko/codevault/internal/utils/gshutdown" +) + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Start the CodeVault server with the given config", + Long: `Start the CodeVault HTTP server using the given config file.`, + PreRunE: preRunE, + RunE: runE, +} + +func init() { + _ = runCmd.Flags().StringP("config", "c", "", "Path to config [CONFIG envvar]") + utils.TryFlagFromEnv(runCmd, "config", "CONFIG", true) + + rootCmd.AddCommand(runCmd) +} + +func preRunE(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + ctx, cancelCtx = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + + configPath, _ := cmd.Flags().GetString("config") + if configPath == "" { + return nil // will fail on RunE() with a proper error + } + + appConfig, err := config.ReadConfig(configPath) + if err != nil { + return fmt.Errorf("error on reading config: %w", err) + } + if err = appConfig.Validate(); err != nil { + return fmt.Errorf("error on config validation: %w", err) + } + + ctx, err = configureLoggerCtx(ctx, appConfig) + if err != nil { + return fmt.Errorf("failed to configure logger: %w", err) + } + logger := logging.LoggerMustFromContext(ctx) + ctx = gshutdown.CtxWithGracefulShutdown(ctx, logger.Named("graceful_shutdown")) + + if err = configureObservability(ctx, appConfig.Observability); err != nil { + return fmt.Errorf("failed to configure observability: %w", err) + } + cmd.SetContext(ctx) + return nil +} + +func runE(cmd *cobra.Command, args []string) error { + configPath, err := cmd.Flags().GetString("config") + if err != nil { + return fmt.Errorf("error on getting config option: %w", err) + } + + appConfig, err := config.ReadConfig(configPath) + if err != nil { + return fmt.Errorf("error on reading config: %w", err) + } + + defer postRun(cmd.Context()) + + err = runcmd.RunServer(cmd.Context(), appConfig) + if err != nil && !errors.Is(err, context.Canceled) { + return fmt.Errorf("error on running server: %w", err) + } + return nil +} + +func postRun(ctx context.Context) { + logger := logging.LoggerMustFromContext(ctx) + if err := recover(); err != nil { + logger.Error("uncaught panic", zap.Any("panic data", err), zap.Stack("stack_trace")) + defer panic(err) + } + cancelCtx() + err := gshutdown.AwaitWithTimeout(ctx, time.Second*10) + if err != nil { + logger.Error("graceful shutdown failed", zap.Error(err)) + } + loggerCloseFunc() +} + +func configureLoggerCtx(ctx context.Context, appConfig config.Config) (context.Context, error) { + logger, closeFunc, err := logging.ConfigureLogger(ctx, appConfig.Observability.Logging, appConfig.Environment == config.ProductionEnvironment) + zap.ReplaceGlobals(logger) + loggerCloseFunc = closeFunc + if err != nil { + return ctx, fmt.Errorf("error on logging initialization: %w", err) + } + return logging.CtxWithLogger(ctx, logger), nil +} + +func configureObservability(ctx context.Context, cfg config.ObservabilityConfig) error { + if cfg.Metrics.Host != "" { + if err := metrics.Configure(ctx, cfg.Metrics); err != nil { + return fmt.Errorf("error on starting Prometheus metrics handler: %w", err) + } + } + return nil +} + +var loggerCloseFunc func() // so the logger is closed after all other context-wide subtasks +var cancelCtx context.CancelFunc diff --git a/docker-compose.yaml.example b/docker-compose.yaml.example new file mode 100644 index 0000000..b2033d6 --- /dev/null +++ b/docker-compose.yaml.example @@ -0,0 +1,30 @@ +services: + codevault: + build: . + image: codevault:latest + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + - "127.0.0.1:9092:9092" + volumes: + # config.yaml's database.path should point inside this volume, e.g. /app/data/codevault.db. + # server.trusted_proxies must list the nginx container/network's actual IP or CIDR + # (gin needs IPs/CIDRs, not hostnames) - e.g. this compose network's subnet. + - ./config.yaml:/app/config.yaml:ro + - codevault-data:/app/data + logging: + driver: json-file + options: + max-size: "50m" + max-file: "3" + deploy: + resources: + limits: + cpus: "1.0" + memory: 256M + reservations: + cpus: "0.25" + memory: 64M + +volumes: + codevault-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3e61519 --- /dev/null +++ b/go.mod @@ -0,0 +1,102 @@ +module github.com/kanootoko/codevault + +go 1.26.5 + +require ( + github.com/getkin/kin-openapi v0.146.0 + github.com/gin-gonic/gin v1.12.0 + github.com/oapi-codegen/runtime v1.7.0 + github.com/prometheus/client_golang v1.24.1 + github.com/spf13/cobra v1.10.2 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + go.opentelemetry.io/contrib/bridges/otelzap v0.20.0 + go.opentelemetry.io/otel v1.45.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 + go.opentelemetry.io/otel/exporters/prometheus v0.67.0 + go.opentelemetry.io/otel/log v0.21.0 + go.opentelemetry.io/otel/metric v1.45.0 + go.opentelemetry.io/otel/sdk v1.45.0 + go.opentelemetry.io/otel/sdk/log v0.21.0 + go.opentelemetry.io/otel/sdk/metric v1.45.0 + go.uber.org/multierr v1.11.0 + go.uber.org/zap v1.28.0 + golang.org/x/crypto v0.55.0 + golang.org/x/term v0.45.0 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.56.0 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // 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.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/swaggo/swag v1.8.12 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/grpc v1.83.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0cbcc47 --- /dev/null +++ b/go.sum @@ -0,0 +1,324 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.146.0 h1:RA/1RdxrSJW4oc1+6IfnYB6AO9CaGy8GTKPh0k4Ordo= +github.com/getkin/kin-openapi v0.146.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +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.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= +github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.8.12 h1:pctzkNPu0AlQP2royqX3apjKCQonAnf7KGoxeO4y64w= +github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy2its= +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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/otelzap v0.20.0 h1:wgsHT2HLf1KEZtCkd6ZGynPdeIyFsCSKsHyDBW9vEJk= +go.opentelemetry.io/contrib/bridges/otelzap v0.20.0/go.mod h1:NZCU/Hi3EdSS6LxcNJOO39Y5z95R9QY5iQ2Ym6IOFTU= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 h1:WseeVYf5dJZTsyPiyW5L14k5qsSibqXAMTSiFEDiWr0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0/go.mod h1:SiLZnQS6Qk2eCpvr2CH/XMAOa64TWGXxEZJZCpD2Lmc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 h1:fvNHGyo3CdRv/DQveXqhqBxnKTDyRaC5sMSQxilX/A0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0/go.mod h1:zyGrjRKL2B/6+Jc/m4/otPoZqV2MY9ZjC/aBraRO7zc= +go.opentelemetry.io/otel/exporters/prometheus v0.67.0 h1:7IefDa35e6V3NoiqIeLDMDxMFyZDk5qcoC0Ax4cC16E= +go.opentelemetry.io/otel/exporters/prometheus v0.67.0/go.mod h1:nsPI1awTg5Vmg1YrommL2mVarVGlqc4yXOoKAkPRD0c= +go.opentelemetry.io/otel/log v0.21.0 h1:SLsVDGmtyBrdw8/a2Z0bOIxou/+bN4z56GebH7T0LvA= +go.opentelemetry.io/otel/log v0.21.0/go.mod h1:iReetQrZL9Wyg84cCkOoCmqDHS5RCFfyxC7J+r8fn8g= +go.opentelemetry.io/otel/log/logtest v0.21.0 h1:/Zr/0DoraAjiX91pZMn72uSDkd7hA+jn3CPU2y+2rWY= +go.opentelemetry.io/otel/log/logtest v0.21.0/go.mod h1:dyswW/l7aXiiCAmbKlt+Eg2NUnN6p1YrHQPBiV7QrLU= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/log v0.21.0 h1:QsE7XSR0ktQdKmRKGnR+f1ObGF32WG+7MER/P9KgmYc= +go.opentelemetry.io/otel/sdk/log v0.21.0/go.mod h1:m9mApjCoD2/1QuKCAptjv+BrG9WKOvQLVdNx+iBldTo= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0 h1:X+JBBgKlswCGYsmgL0CnoUUtlE//VB345c84jYAYkdQ= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0/go.mod h1:HD1575K8e6sIFBBDd5tZB3t9DlMytWXq9FuR+Y4rfjE= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +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-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +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/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/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= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/doc.go b/internal/api/doc.go new file mode 100644 index 0000000..8117cf2 --- /dev/null +++ b/internal/api/doc.go @@ -0,0 +1,4 @@ +// Package api contains the OpenAPI-generated GIN strict-server code for CodeVault. +package api + +//go:generate oapi-codegen --config=oapi-codegen.yaml openapi.yaml diff --git a/internal/api/generated.go b/internal/api/generated.go new file mode 100644 index 0000000..113a239 --- /dev/null +++ b/internal/api/generated.go @@ -0,0 +1,363 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. +package api + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/gin-gonic/gin" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +const ( + BasicAuthScopes = "basicAuth.Scopes" +) + +// GetValueRequest defines model for GetValueRequest. +type GetValueRequest struct { + Code string `json:"code"` +} + +// GetValueResponse defines model for GetValueResponse. +type GetValueResponse struct { + Value string `json:"value"` +} + +// SetValueRequest defines model for SetValueRequest. +type SetValueRequest struct { + Code string `json:"code"` + Value string `json:"value"` +} + +// GetValueJSONRequestBody defines body for GetValue for application/json ContentType. +type GetValueJSONRequestBody = GetValueRequest + +// SetValueJSONRequestBody defines body for SetValue for application/json ContentType. +type SetValueJSONRequestBody = SetValueRequest + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (POST /api/v1/getValue) + GetValue(c *gin.Context) + + // (POST /api/v1/setValue) + SetValue(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// GetValue operation middleware +func (siw *ServerInterfaceWrapper) GetValue(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetValue(c) +} + +// SetValue operation middleware +func (siw *ServerInterfaceWrapper) SetValue(c *gin.Context) { + + c.Set(BasicAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.SetValue(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/api/v1/getValue", wrapper.GetValue) + router.POST(options.BaseURL+"/api/v1/setValue", wrapper.SetValue) +} + +type GetValueRequestObject struct { + Body *GetValueJSONRequestBody +} + +type GetValueResponseObject interface { + VisitGetValueResponse(w http.ResponseWriter) error +} + +type GetValue200JSONResponse GetValueResponse + +func (response GetValue200JSONResponse) VisitGetValueResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetValue400Response struct { +} + +func (response GetValue400Response) VisitGetValueResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetValue404Response struct { +} + +func (response GetValue404Response) VisitGetValueResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type SetValueRequestObject struct { + Body *SetValueJSONRequestBody +} + +type SetValueResponseObject interface { + VisitSetValueResponse(w http.ResponseWriter) error +} + +type SetValue204Response struct { +} + +func (response SetValue204Response) VisitSetValueResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type SetValue400Response struct { +} + +func (response SetValue400Response) VisitSetValueResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type SetValue401Response struct { +} + +func (response SetValue401Response) VisitSetValueResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + + // (POST /api/v1/getValue) + GetValue(ctx context.Context, request GetValueRequestObject) (GetValueResponseObject, error) + + // (POST /api/v1/setValue) + SetValue(ctx context.Context, request SetValueRequestObject) (SetValueResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// GetValue operation middleware +func (sh *strictHandler) GetValue(ctx *gin.Context) { + var request GetValueRequestObject + + var body GetValueJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetValue(ctx, request.(GetValueRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetValue") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetValueResponseObject); ok { + if err := validResponse.VisitGetValueResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// SetValue operation middleware +func (sh *strictHandler) SetValue(ctx *gin.Context) { + var request SetValueRequestObject + + var body SetValueJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.SetValue(ctx, request.(SetValueRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SetValue") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(SetValueResponseObject); ok { + if err := validResponse.VisitSetValueResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// Base64 encoded, gzipped, json marshaled Swagger object +var swaggerSpec = []string{ + + "H4sIAAAAAAAC/7STwW7bMAyGX8XgdjRiZ+3Jt26HIYcBwwL0EuSgSEzCwhVVkQ4QFH73QZKTLcuKbgV6", + "siH95E9+pJ7B8mNgj14FumcQu8dHk3+/ot6bfsAf+DSgaDoKkQNGJcwCyw7TV48BoQPRSH4H41hDxKeB", + "IjroVkW1rk8q3jygVRjr3/JLYC94bXBI1687FNnfLJZvbKH+V+scX79YwViDoB0i6XGZwBbTjRGyd4Pu", + "z8BTTD6Fc4q9aoAxZSC/5VwJaZ9uvrDDezP0Wt19XyRzjELsoYP5rJ21qXoO6E0g6OBm1s5uoIZgdJ/N", + "GxOoOcyb3YQmM+HCJpExSuwXDrrzfKC0jKKf2R0LNa/oc4gJoSebg5oHSVWcdij9fYy4hQ4+NL+WrJk2", + "rPlzvcZLthoHzAdlOXLtn9r2Heyn7cv+DsVGClp4ZkG15cG7RPW22F9qFv5genLVhKjaJEZZfHstLgk9", + "6ylpNj2NRF4dyfJ9R7J8y0hebFOUU9j/g5tfi7+RCPldxbGiKc5GdOiVTC8XDw261cUTW63HdeL8MwAA", + "//98/iCR6gQAAA==", +} + +// GetSwagger returns the content of the embedded swagger specification file +// or error if failed to decode +func decodeSpec() ([]byte, error) { + zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr, err := gzip.NewReader(bytes.NewReader(zipped)) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + var buf bytes.Buffer + _, err = buf.ReadFrom(zr) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cached of a decoded swagger spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSwagger returns the Swagger specification corresponding to the generated code +// in this file. The external references of Swagger specification are resolved. +// The logic of resolving external references is tightly connected to "import-mapping" feature. +// Externally referenced files must be embedded in the corresponding golang packages. +// Urls can be supported but this task was out of the scope. +func GetSwagger() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..d364d3b --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,55 @@ +package api + +import ( + "context" + "errors" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/kanootoko/codevault/internal/db" + "github.com/kanootoko/codevault/internal/observability/metrics" +) + +// StrictHandler implements StrictServerInterface. +type StrictHandler struct { + Secrets *db.SecretsRepository + Metrics metrics.ValueMetrics +} + +func NewHandler(secrets *db.SecretsRepository, m metrics.ValueMetrics) *StrictHandler { + return &StrictHandler{Secrets: secrets, Metrics: m} +} + +func (h *StrictHandler) GetValue(ctx context.Context, request GetValueRequestObject) (GetValueResponseObject, error) { + if request.Body == nil || request.Body.Code == "" { + return GetValue400Response{}, nil + } + + value, err := h.Secrets.Get(ctx, request.Body.Code) + if errors.Is(err, db.ErrNotFound) { + h.Metrics.GetFailedTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", "not_found"))) + return GetValue404Response{}, nil + } + if err != nil { + return nil, err + } + + h.Metrics.GetSuccessTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("code", request.Body.Code))) + return GetValue200JSONResponse{Value: value}, nil +} + +func (h *StrictHandler) SetValue(ctx context.Context, request SetValueRequestObject) (SetValueResponseObject, error) { + if request.Body == nil || request.Body.Code == "" || request.Body.Value == "" || len(request.Body.Value) > 10*1024*1024 { + h.Metrics.SetFailedTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", "bad_request"))) + return SetValue400Response{}, nil + } + + if err := h.Secrets.Set(ctx, request.Body.Code, request.Body.Value); err != nil { + return nil, err + } + + login, _ := LoginFromContext(ctx) + h.Metrics.SetSuccessTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("user", login))) + return SetValue204Response{}, nil +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..cfa2301 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,81 @@ +package api + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/kanootoko/codevault/internal/db" + "github.com/kanootoko/codevault/internal/observability/metrics" + "github.com/kanootoko/codevault/internal/ratelimit" +) + +type contextKey string + +const loginContextKey contextKey = "login" + +// LoginFromContext returns the Basic Auth login stashed by BasicAuthMiddleware, +// if any. Strict-server handlers only see context.Context, not *gin.Context, so +// this is how they recover the authenticated login. +func LoginFromContext(ctx context.Context) (string, bool) { + login, ok := ctx.Value(loginContextKey).(string) + return login, ok +} + +// BasicAuthMiddleware checks the request's HTTP Basic Auth credentials against +// the DB-backed user store. +func BasicAuthMiddleware(users *db.UsersRepository, vm metrics.ValueMetrics) gin.HandlerFunc { + return func(c *gin.Context) { + login, password, ok := c.Request.BasicAuth() + if !ok || users.Verify(c.Request.Context(), login, password) != nil { + vm.SetFailedTotal.Add(c.Request.Context(), 1, metric.WithAttributes(attribute.String("reason", "unauthorized"))) + c.Header("WWW-Authenticate", `Basic realm="codevault"`) + c.AbortWithStatus(http.StatusUnauthorized) + return + } + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), loginContextKey, login)) + c.Next() + } +} + +// RateLimitMiddleware short-circuits to 404 once the client IP has accumulated +// too many failures, without touching the handler (and thus the database). A +// short-circuited request counts as a failure itself, same as a genuine 404 from +// the handler. +func RateLimitMiddleware(limiter *ratelimit.Limiter, vm metrics.ValueMetrics) gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + if !limiter.Allowed(ip) { + limiter.RecordFailure(ip) + vm.GetFailedTotal.Add(c.Request.Context(), 1, metric.WithAttributes(attribute.String("reason", "rate_limited"))) + c.AbortWithStatus(http.StatusNotFound) + return + } + + c.Next() + + if c.Writer.Status() == http.StatusNotFound { + limiter.RecordFailure(ip) + } + } +} + +// InstrumentMiddleware records request count and duration metrics per route. +func InstrumentMiddleware(route string, m metrics.HTTPMetrics) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + duration := time.Since(start).Seconds() + + attrs := metric.WithAttributes( + attribute.String("route", route), + attribute.Int("status", c.Writer.Status()), + ) + m.RequestsTotal.Add(c.Request.Context(), 1, attrs) + m.RequestDuration.Record(c.Request.Context(), duration, metric.WithAttributes(attribute.String("route", route))) + } +} diff --git a/internal/api/oapi-codegen.yaml b/internal/api/oapi-codegen.yaml new file mode 100644 index 0000000..a43fb70 --- /dev/null +++ b/internal/api/oapi-codegen.yaml @@ -0,0 +1,7 @@ +package: api +generate: + gin-server: true + strict-server: true + models: true + embedded-spec: true +output: generated.go diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml new file mode 100644 index 0000000..7424983 --- /dev/null +++ b/internal/api/openapi.yaml @@ -0,0 +1,73 @@ +openapi: 3.0.3 +info: + title: CodeVault API + version: "1.0.0" +paths: + /api/v1/getValue: + post: + operationId: getValue + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GetValueRequest' + responses: + '200': + description: Value found + content: + application/json: + schema: + $ref: '#/components/schemas/GetValueResponse' + '400': + description: Invalid request body + '404': + description: Value not found + /api/v1/setValue: + post: + operationId: setValue + security: + - basicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SetValueRequest' + responses: + '204': + description: Value stored + '400': + description: Invalid request body + '401': + description: Missing or invalid credentials +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + schemas: + GetValueRequest: + type: object + required: + - code + properties: + code: + type: string + GetValueResponse: + type: object + required: + - value + properties: + value: + type: string + SetValueRequest: + type: object + required: + - code + - value + properties: + code: + type: string + value: + type: string diff --git a/internal/api/swagger.go b/internal/api/swagger.go new file mode 100644 index 0000000..94c29a5 --- /dev/null +++ b/internal/api/swagger.go @@ -0,0 +1,35 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +// RegisterSwaggerHandlers serves the OpenAPI spec and an interactive Swagger UI +// (embedded swagger-ui-dist assets, no CDN) under /api. +func RegisterSwaggerHandlers(router *gin.Engine) error { + spec, err := GetSwagger() + if err != nil { + return fmt.Errorf("error on building swagger specification: %w", err) + } + specBody, err := spec.MarshalJSON() + if err != nil { + return fmt.Errorf("error on marshaling swagger specification: %w", err) + } + + // /api/docs/*any (rather than /api/*any) so this wildcard doesn't collide with + // the static /api/openapi.json route at the same tree level. + router.GET("/api/openapi.json", func(c *gin.Context) { + c.Data(http.StatusOK, "application/json", specBody) + }) + router.GET("/api", func(c *gin.Context) { + c.Redirect(http.StatusFound, "/api/docs/index.html") + }) + router.GET("/api/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("/api/openapi.json"))) + + return nil +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..abc3f6c --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,37 @@ +package auth + +import ( + "crypto/sha256" + "encoding/base64" + + "golang.org/x/crypto/bcrypt" +) + +// saltedInput mixes the login into the password material before bcrypt hashing, using +// the login as a per-user salt: a leaked hash can't be replayed against a different +// login, and pre-hashing keeps the input short regardless of login/password length +// (bcrypt only considers the first 72 bytes of its input). +func saltedInput(login, password string) []byte { + sum := sha256.Sum256([]byte(login + "\x00" + password)) + return []byte(base64.StdEncoding.EncodeToString(sum[:])) +} + +// dummyHash is compared against on unknown logins so that a failed lookup takes +// roughly the same time as a real password comparison, avoiding user enumeration. +var dummyHash, _ = bcrypt.GenerateFromPassword(saltedInput("dummy-login", "dummy-password"), bcrypt.DefaultCost) + +func Hash(login, password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword(saltedInput(login, password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func Compare(login, hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), saltedInput(login, password)) == nil +} + +func CompareDummy() { + _ = bcrypt.CompareHashAndPassword(dummyHash, saltedInput("dummy-login", "dummy-password")) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1211558 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,74 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strings" + + "go.uber.org/multierr" + "gopkg.in/yaml.v3" +) + +var ErrLoggingDisabled error = errors.New("logging is disabled") + +type Config struct { + Environment Environment `yaml:"environment"` + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + RateLimit RateLimitConfig `yaml:"rate_limit"` + Observability ObservabilityConfig `yaml:"observability"` +} + +func (c *Config) Validate() error { + var err error + + err = addValidationError(err, "environment validation error", c.Environment.PreprocessAndValidate()) + err = addValidationError(err, "server section validation error", c.Server.Validate()) + err = addValidationError(err, "database section validation error", c.Database.Validate()) + err = addValidationError(err, "rate_limit section validation error", c.RateLimit.Validate()) + err = addValidationError(err, "observability section validation error", c.Observability.Validate()) + + return err +} + +func ReadConfig(path string) (Config, error) { + var c Config + yamlFile, err := os.ReadFile(path) + if err != nil { + return c, fmt.Errorf("could not read config: %w", err) + } + err = yaml.Unmarshal(yamlFile, &c) + if err != nil { + return c, fmt.Errorf("could not parse yaml: %w", err) + } + return c, nil +} + +type Environment string + +var ( + ProductionEnvironment Environment = "production" + DevelopmentEnvironment Environment = "development" +) + +func (e *Environment) PreprocessAndValidate() error { + preprocessed := strings.ToLower(string(*e)) + switch preprocessed { + case string(ProductionEnvironment), "prod": + *e = ProductionEnvironment + return nil + case string(DevelopmentEnvironment), "dev": + *e = DevelopmentEnvironment + return nil + default: + return fmt.Errorf("invalid Environment value: '%v'", *e) + } +} + +func addValidationError(err error, text string, validationError error) error { + if validationError == nil { + return err + } + return multierr.Append(err, fmt.Errorf("%s: %w", text, validationError)) +} diff --git a/internal/config/database.go b/internal/config/database.go new file mode 100644 index 0000000..62cea41 --- /dev/null +++ b/internal/config/database.go @@ -0,0 +1,14 @@ +package config + +import "fmt" + +type DatabaseConfig struct { + Path string `yaml:"path"` +} + +func (dc *DatabaseConfig) Validate() error { + if dc.Path == "" { + return fmt.Errorf("path is missing") + } + return nil +} diff --git a/internal/config/observability.go b/internal/config/observability.go new file mode 100644 index 0000000..93eb254 --- /dev/null +++ b/internal/config/observability.go @@ -0,0 +1,131 @@ +package config + +import ( + "errors" + "fmt" + "strings" + + "go.uber.org/zap/zapcore" +) + +type ObservabilityConfig struct { + Logging LoggingConfig `yaml:"logging"` + Metrics MetricsConfig `yaml:"metrics"` +} + +func (oc *ObservabilityConfig) Validate() error { + var err error + + err = addValidationError(err, "logging section validation error", oc.Logging.Validate()) + err = addValidationError(err, "metrics section validation error", oc.Metrics.Validate()) + return err +} + +type LoggingConfig struct { + Stderr LoggingLevel `yaml:"stderr"` + Files map[LoggingLevel]string `yaml:"files"` + Exporter LogsExporterConfig `yaml:"exporter"` +} + +func (lc *LoggingConfig) Validate() error { + var err error + + _, loggingErr := lc.Stderr.ToZapLevel() + if !errors.Is(loggingErr, ErrLoggingDisabled) { + err = addValidationError(err, "stderr logging level validation error", loggingErr) + } + for level, filename := range lc.Files { + _, loggingErr = level.ToZapLevel() + err = addValidationError(err, fmt.Sprintf("logging level validation error for file '%s'", filename), loggingErr) + } + return err +} + +type LogsExporterConfig struct { + Level LoggingLevel `yaml:"level"` + Endpoint string `yaml:"endpoint"` + Transport OTLPTransportType `yaml:"transport"` +} + +func (lec *LogsExporterConfig) Validate() error { + var err error + + _, loggingErr := lec.Level.ToZapLevel() + if errors.Is(loggingErr, ErrLoggingDisabled) { + return nil + } + err = addValidationError(err, "logging level validation error", loggingErr) + if !strings.HasPrefix(lec.Endpoint, "http") { + return fmt.Errorf("endpoint must has http(s) prefix") + } + err = addValidationError(err, "transport type validation error", lec.Transport.PreprocessAndValidate()) + return err +} + +type OTLPTransportType string + +var ( + GRPCTransportType OTLPTransportType = "grpc" + HTTPTransportType OTLPTransportType = "http" +) + +func (otp *OTLPTransportType) PreprocessAndValidate() error { + if *otp == "" { + *otp = GRPCTransportType + return nil + } + preprocessed := OTLPTransportType(strings.ToLower(string(*otp))) + switch preprocessed { + case GRPCTransportType, HTTPTransportType: + *otp = preprocessed + return nil + default: + return fmt.Errorf("invalid OTLPTransportType: '%v'", *otp) + } +} + +type MetricsConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +func (mc *MetricsConfig) Validate() error { + if mc.Host == "" { + return nil + } + if mc.Port < 1 || mc.Port > 65535 { + return fmt.Errorf("port value is invalid") + } + return nil +} + +type LoggingLevel string + +var ( + DebugLoggingLevel LoggingLevel = "DEBUG" + InfoLoggingLevel LoggingLevel = "INFO" + WarningLoggingLevel LoggingLevel = "WARNING" + ErrorLoggingLevel LoggingLevel = "ERROR" + PanicLoggingLevel LoggingLevel = "PANIC" + FatalLoggingLevel LoggingLevel = "FATAL" +) + +func (l LoggingLevel) ToZapLevel() (zapcore.Level, error) { + if l == "" { + return zapcore.InvalidLevel, ErrLoggingDisabled + } + levelString := strings.ToLower(string(l)) + mapping := map[string]zapcore.Level{ + "debug": zapcore.DebugLevel, + "info": zapcore.InfoLevel, + "warn": zapcore.WarnLevel, + "warning": zapcore.WarnLevel, + "error": zapcore.ErrorLevel, + "panic": zapcore.PanicLevel, + "fatal": zapcore.FatalLevel, + } + if level, ok := mapping[levelString]; ok { + return level, nil + } + return zapcore.InvalidLevel, fmt.Errorf("invalid logging level value: %v", l) +} diff --git a/internal/config/ratelimit.go b/internal/config/ratelimit.go new file mode 100644 index 0000000..25aabd9 --- /dev/null +++ b/internal/config/ratelimit.go @@ -0,0 +1,21 @@ +package config + +import ( + "fmt" + "time" +) + +type RateLimitConfig struct { + MaxFailures int `yaml:"max_failures"` + Window time.Duration `yaml:"window"` +} + +func (rc *RateLimitConfig) Validate() error { + if rc.MaxFailures <= 0 { + return fmt.Errorf("max_failures must be positive") + } + if rc.Window <= 0 { + return fmt.Errorf("window must be positive") + } + return nil +} diff --git a/internal/config/server.go b/internal/config/server.go new file mode 100644 index 0000000..fcf1a47 --- /dev/null +++ b/internal/config/server.go @@ -0,0 +1,37 @@ +package config + +import ( + "fmt" + "net/netip" + + "go.uber.org/multierr" +) + +type ServerConfig struct { + Address string `yaml:"address"` + + // TrustedProxies lists IPs/CIDRs (e.g. of a fronting nginx) allowed to set + // X-Forwarded-For/X-Real-IP when gin resolves the client IP. Empty means no + // forwarded header is trusted, and the raw remote address is used instead. + TrustedProxies []string `yaml:"trusted_proxies"` +} + +func (sc *ServerConfig) Validate() error { + var err error + + if sc.Address == "" { + err = multierr.Append(err, fmt.Errorf("address is missing")) + } + + for _, proxy := range sc.TrustedProxies { + if _, parseErr := netip.ParsePrefix(proxy); parseErr == nil { + continue + } + if _, parseErr := netip.ParseAddr(proxy); parseErr == nil { + continue + } + err = multierr.Append(err, fmt.Errorf("invalid trusted_proxies entry %q: must be an IP or CIDR", proxy)) + } + + return err +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..406d4ee --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,30 @@ +package db + +import ( + "database/sql" + _ "embed" + "fmt" + + _ "modernc.org/sqlite" +) + +//go:embed schema.sql +var schema string + +// Open opens (creating the file if it does not exist) the SQLite database at path +// and applies the embedded schema. +func Open(path string) (*sql.DB, error) { + dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)", path) + database, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("could not open database: %w", err) + } + database.SetMaxOpenConns(1) + + if _, err := database.Exec(schema); err != nil { + _ = database.Close() + return nil, fmt.Errorf("could not apply schema: %w", err) + } + + return database, nil +} diff --git a/internal/db/schema.sql b/internal/db/schema.sql new file mode 100644 index 0000000..5b47e00 --- /dev/null +++ b/internal/db/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS users ( + login TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS secrets ( + code TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/internal/db/secrets.go b/internal/db/secrets.go new file mode 100644 index 0000000..db8cfcc --- /dev/null +++ b/internal/db/secrets.go @@ -0,0 +1,42 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +var ErrNotFound = errors.New("secret not found") + +type SecretsRepository struct { + db *sql.DB +} + +func NewSecretsRepository(db *sql.DB) *SecretsRepository { + return &SecretsRepository{db: db} +} + +func (r *SecretsRepository) Get(ctx context.Context, code string) (string, error) { + var value string + err := r.db.QueryRowContext(ctx, "SELECT value FROM secrets WHERE code = ?", code).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("could not get secret: %w", err) + } + return value, nil +} + +func (r *SecretsRepository) Set(ctx context.Context, code, value string) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO secrets (code, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(code) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP + `, code, value) + if err != nil { + return fmt.Errorf("could not set secret: %w", err) + } + return nil +} diff --git a/internal/db/users.go b/internal/db/users.go new file mode 100644 index 0000000..4aa4bee --- /dev/null +++ b/internal/db/users.go @@ -0,0 +1,51 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/kanootoko/codevault/internal/auth" +) + +var ErrInvalidCredentials = errors.New("invalid login or password") + +type UsersRepository struct { + db *sql.DB +} + +func NewUsersRepository(db *sql.DB) *UsersRepository { + return &UsersRepository{db: db} +} + +func (r *UsersRepository) Upsert(ctx context.Context, login, passwordHash string) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO users (login, password_hash) + VALUES (?, ?) + ON CONFLICT(login) DO UPDATE SET password_hash = excluded.password_hash + `, login, passwordHash) + if err != nil { + return fmt.Errorf("could not upsert user: %w", err) + } + return nil +} + +// Verify checks the given login/password pair against the stored hash. A dummy hash +// comparison is performed for unknown logins so that lookup timing does not reveal +// whether the login exists. +func (r *UsersRepository) Verify(ctx context.Context, login, password string) error { + var passwordHash string + err := r.db.QueryRowContext(ctx, "SELECT password_hash FROM users WHERE login = ?", login).Scan(&passwordHash) + if errors.Is(err, sql.ErrNoRows) { + auth.CompareDummy() + return ErrInvalidCredentials + } + if err != nil { + return fmt.Errorf("could not get user: %w", err) + } + if !auth.Compare(login, passwordHash, password) { + return ErrInvalidCredentials + } + return nil +} diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go new file mode 100644 index 0000000..de9c9d1 --- /dev/null +++ b/internal/httpserver/server.go @@ -0,0 +1,116 @@ +// Package httpserver assembles the gin engine (API routes + web UI) and runs it +// with graceful shutdown. +package httpserver + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kanootoko/codevault/internal/api" + "github.com/kanootoko/codevault/internal/db" + "github.com/kanootoko/codevault/internal/observability/logging" + "github.com/kanootoko/codevault/internal/observability/metrics" + "github.com/kanootoko/codevault/internal/ratelimit" + "github.com/kanootoko/codevault/internal/utils/gshutdown" + "github.com/kanootoko/codevault/internal/webui" +) + +const shutdownTimeout = 10 * time.Second + +const cleanupInterval = time.Minute + +func New( + secrets *db.SecretsRepository, + users *db.UsersRepository, + limiter *ratelimit.Limiter, + m metrics.Metrics, + trustedProxies []string, +) (*gin.Engine, error) { + router := gin.New() + // Strict-server handlers only see context.Context (via *gin.Context, which + // implements it), not *gin.Context directly. Without this flag, gin.Context.Value + // never falls back to c.Request.Context(), so values stashed there by middleware + // (e.g. LoginFromContext) would be invisible to handlers. + router.ContextWithFallback = true + router.Use(gin.Recovery()) + if err := router.SetTrustedProxies(trustedProxies); err != nil { + return nil, fmt.Errorf("error on setting trusted proxies: %w", err) + } + + router.GET("/", func(c *gin.Context) { serveStatic(c, webui.GetPagePath) }) + router.GET("/set", func(c *gin.Context) { serveStatic(c, webui.SetPagePath) }) + + if err := api.RegisterSwaggerHandlers(router); err != nil { + return nil, fmt.Errorf("error on registering swagger handlers: %w", err) + } + + handler := api.NewHandler(secrets, m.Value) + strictHandler := api.NewStrictHandler(handler, nil) + wrapper := api.ServerInterfaceWrapper{Handler: strictHandler} + + router.POST("/api/v1/getValue", + api.InstrumentMiddleware("getValue", m.HTTP), + api.RateLimitMiddleware(limiter, m.Value), + wrapper.GetValue, + ) + router.POST("/api/v1/setValue", + api.InstrumentMiddleware("setValue", m.HTTP), + api.BasicAuthMiddleware(users, m.Value), + wrapper.SetValue, + ) + + return router, nil +} + +func serveStatic(c *gin.Context, path string) { + data, err := webui.Static.ReadFile(path) + if err != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.Data(http.StatusOK, "text/html; charset=utf-8", data) +} + +// Run starts the HTTP server and the rate limiter's cleanup goroutine, both +// tracked as gshutdown subtasks, and blocks until ctx is done. +func Run(ctx context.Context, address string, router *gin.Engine, limiter *ratelimit.Limiter) error { + logger := logging.LoggerMustFromContext(ctx) + + server := &http.Server{ + Addr: address, + Handler: router, + } + + _ = gshutdown.SubtaskStarted(ctx, "http-server") + go func() { + defer func() { _ = gshutdown.SubtaskFinished(ctx, "http-server") }() + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Error("error on http server shutdown", zap.Error(err)) + } + }() + + cleanupDone := make(chan struct{}) + _ = gshutdown.SubtaskStarted(ctx, "ratelimit-cleanup") + go func() { + defer func() { _ = gshutdown.SubtaskFinished(ctx, "ratelimit-cleanup") }() + limiter.Run(cleanupDone, cleanupInterval) + }() + go func() { + <-ctx.Done() + close(cleanupDone) + }() + + logger.Info("starting http server", zap.String("address", address)) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("http server error: %w", err) + } + return nil +} diff --git a/internal/observability/logging/configure.go b/internal/observability/logging/configure.go new file mode 100644 index 0000000..18577ab --- /dev/null +++ b/internal/observability/logging/configure.go @@ -0,0 +1,139 @@ +package logging + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "go.opentelemetry.io/contrib/bridges/otelzap" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" + "go.opentelemetry.io/otel/log/global" + "go.opentelemetry.io/otel/sdk/log" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/kanootoko/codevault/internal" + "github.com/kanootoko/codevault/internal/config" + "github.com/kanootoko/codevault/internal/observability" +) + +func ConfigureLogger(ctx context.Context, cfg config.LoggingConfig, isProductionEnv bool) (logger *zap.Logger, closeFunc func(), err error) { + stderrLevel, err := cfg.Stderr.ToZapLevel() + if err != nil && !errors.Is(err, config.ErrLoggingDisabled) { + return nil, nil, fmt.Errorf("error on getting stderr logging level: %w", err) + } + fileLoggers := make(map[string]zapcore.Level) + for level, file := range cfg.Files { + l, err := level.ToZapLevel() + if err != nil { + return nil, nil, fmt.Errorf("error on getting file logging level: %w", err) + } + fileLoggers[file] = l + } + var stderrEncoder zapcore.Encoder + var fileEncoder zapcore.Encoder + { + var fileLoggerCfg zapcore.EncoderConfig + var stderrLoggerCfg zapcore.EncoderConfig + if isProductionEnv { + fileLoggerCfg = zap.NewProductionEncoderConfig() + stderrLoggerCfg = zap.NewProductionEncoderConfig() + } else { + fileLoggerCfg = zap.NewDevelopmentEncoderConfig() + stderrLoggerCfg = zap.NewDevelopmentEncoderConfig() + } + fileLoggerCfg.TimeKey = "timestamp" + fileLoggerCfg.EncodeTime = zapcore.ISO8601TimeEncoder + + stderrLoggerCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder + + stderrEncoder = zapcore.NewConsoleEncoder(stderrLoggerCfg) + fileEncoder = zapcore.NewJSONEncoder(fileLoggerCfg) + } + + fileHandlers := make([]*os.File, 0, len(fileLoggers)) + zapWriters := make([]zapcore.Core, 0, len(fileLoggers)+1) + for filename, level := range fileLoggers { + fileHandler, err := os.Create(filename) + if err != nil { + for _, file := range fileHandlers[:len(fileHandlers)-1] { + _ = file.Close() + } + return nil, nil, fmt.Errorf("error on log file creation: %w", err) + } + fileHandlers = append(fileHandlers, fileHandler) + fileSync := zapcore.AddSync(fileHandler) + zapWriters = append(zapWriters, zapcore.NewCore(fileEncoder, fileSync, zap.NewAtomicLevelAt(level))) + } + + stderrSync := zapcore.AddSync(os.Stderr) + if stderrLevel != zapcore.InvalidLevel { + zapWriters = append(zapWriters, zapcore.NewCore(stderrEncoder, stderrSync, zap.NewAtomicLevelAt(stderrLevel))) + } + + var otlpCloseFunc func() + if cfg.Exporter.Level != "" { + var otlpWriter zapcore.Core + var err error + otlpWriter, otlpCloseFunc, err = configureOtelLogging(ctx, cfg.Exporter) + if err != nil { + return nil, nil, fmt.Errorf("cannot configure OpenTelemetry log exporter: %w", err) + } + zapWriters = append(zapWriters, otlpWriter) + } + + core := zapcore.NewTee( + zapWriters..., + ) + + logger = zap.New(core) + + closeFunc = func() { + _ = logger.Sync() + for _, file := range fileHandlers { + _ = file.Close() + } + if otlpCloseFunc != nil { + otlpCloseFunc() + } + } + + logger.Info("configured logging", zap.Any("config", cfg)) + return +} + +func configureOtelLogging(ctx context.Context, cfg config.LogsExporterConfig) (zapcore.Core, func(), error) { + var exporter log.Exporter + var err error + if cfg.Transport == config.HTTPTransportType { + exporter, err = otlploghttp.New(ctx, otlploghttp.WithEndpointURL(cfg.Endpoint)) + } else { + exporter, err = otlploggrpc.New(ctx, otlploggrpc.WithEndpointURL(cfg.Endpoint)) + } + if err != nil { + return nil, nil, fmt.Errorf("cannot create OpenTelemetry logs exporter: %w", err) + } + res, err := observability.GetOtelResource() + if err != nil { + return nil, nil, fmt.Errorf("cannot get OpenTelemetry resource: %w", err) + } + + provider := log.NewLoggerProvider( + log.WithProcessor(log.NewBatchProcessor(exporter)), + log.WithResource(res), + ) + + closeFunc := func() { + shutdownContext, cancel := context.WithTimeout(context.Background(), time.Millisecond*300) + defer cancel() + _ = provider.ForceFlush(shutdownContext) + _ = provider.Shutdown(shutdownContext) + <-shutdownContext.Done() + } + global.SetLoggerProvider(provider) + + return otelzap.NewCore(internal.PackageName, otelzap.WithLoggerProvider(provider)), closeFunc, nil +} diff --git a/internal/observability/logging/ctx.go b/internal/observability/logging/ctx.go new file mode 100644 index 0000000..38de016 --- /dev/null +++ b/internal/observability/logging/ctx.go @@ -0,0 +1,41 @@ +package logging + +import ( + "context" + "errors" + + "go.uber.org/zap" +) + +type loggerKeyType int + +const ( + loggerKey loggerKeyType = iota +) + +var ErrNotInitialized = errors.New("logger has not been initialized in the given context") + +func CtxWithLogger(ctx context.Context, logger *zap.Logger) context.Context { + if loggerCtx, ok := ctx.Value(loggerKey).(*zap.Logger); ok { + if loggerCtx == logger { + return ctx + } + } + return context.WithValue(ctx, loggerKey, logger) +} + +func LoggerFromCtx(ctx context.Context) (*zap.Logger, error) { + logger := ctx.Value(loggerKey) + if logger == nil { + return nil, ErrNotInitialized + } + return logger.(*zap.Logger), nil +} + +func LoggerMustFromContext(ctx context.Context) *zap.Logger { + logger, err := LoggerFromCtx(ctx) + if err != nil { + panic(err) + } + return logger +} diff --git a/internal/observability/metrics/configure.go b/internal/observability/metrics/configure.go new file mode 100644 index 0000000..df78cff --- /dev/null +++ b/internal/observability/metrics/configure.go @@ -0,0 +1,83 @@ +package metrics + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/prometheus" + otelmetric "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + + "github.com/kanootoko/codevault/internal" + "github.com/kanootoko/codevault/internal/config" + "github.com/kanootoko/codevault/internal/observability" + "github.com/kanootoko/codevault/internal/utils/gshutdown" +) + +// Configure starts a Prometheus server which serves metrics and finishes on context finish +func Configure(ctx context.Context, cfg config.MetricsConfig) error { + if cfg.Host == "" { + return nil + } + res, err := observability.GetOtelResource() + if err != nil { + return fmt.Errorf("cannot get OpenTelemetry resource: %w", err) + } + + meterProvider, err := newMeterProvider(res) + if err != nil { + return fmt.Errorf("cannot instanciate OpenTelemetry meter provider: %w", err) + } + + otel.SetMeterProvider(meterProvider) + + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + server := http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: mux, + ReadTimeout: time.Second * 20, + ReadHeaderTimeout: time.Second * 10, + WriteTimeout: time.Second * 20, + } + listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)) + if err != nil { + return fmt.Errorf("error on creating listener for prometheus metrics: %w", err) + } + go func() { _ = server.Serve(listener) }() + _ = gshutdown.SubtaskStarted(ctx, "prometheus") + go func() { + defer func() { + _ = listener.Close() + _ = gshutdown.SubtaskFinished(ctx, "prometheus") + }() + <-ctx.Done() + _ = server.Shutdown(context.Background()) + }() + + return nil +} + +func GetMeter() otelmetric.Meter { + mp := otel.GetMeterProvider() + return mp.Meter(internal.PackageName, otelmetric.WithInstrumentationVersion(internal.Version)) +} + +func newMeterProvider(res *resource.Resource) (*metric.MeterProvider, error) { + metricExporter, err := prometheus.New() + if err != nil { + return nil, err + } + + meterProvider := metric.NewMeterProvider( + metric.WithResource(res), + metric.WithReader(metricExporter), + ) + return meterProvider, nil +} diff --git a/internal/observability/metrics/metrics.go b/internal/observability/metrics/metrics.go new file mode 100644 index 0000000..81bbac7 --- /dev/null +++ b/internal/observability/metrics/metrics.go @@ -0,0 +1,110 @@ +package metrics + +import ( + "context" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/kanootoko/codevault/internal" +) + +type Metrics struct { + HTTP HTTPMetrics + Value ValueMetrics +} + +func NewMetrics(meter metric.Meter) Metrics { + metrics := Metrics{ + HTTP: newHTTPMetrics(meter), + Value: newValueMetrics(meter), + } + addObservableMetrics(meter) + return metrics +} + +type HTTPMetrics struct { + // Total HTTP requests with [route, status] + RequestsTotal metric.Int64Counter + + // Request duration histogram in seconds with [route] + RequestDuration metric.Float64Histogram +} + +func newHTTPMetrics(meter metric.Meter) HTTPMetrics { + requestsTotal, _ := meter.Int64Counter( + "http_requests_total", + metric.WithDescription("Total number of HTTP requests"), + ) + requestDuration, _ := meter.Float64Histogram( + "http_request_duration_seconds", + metric.WithDescription("HTTP request duration histogram in seconds"), + metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0), + metric.WithUnit("s"), + ) + return HTTPMetrics{ + RequestsTotal: requestsTotal, + RequestDuration: requestDuration, + } +} + +type ValueMetrics struct { + // Successful getValue lookups, with [code]. Only successful lookups carry a + // code label since the set of stored codes is bounded, unlike guessed/failed ones. + GetSuccessTotal metric.Int64Counter + + // Failed getValue lookups, with [reason] (not_found|rate_limited). + GetFailedTotal metric.Int64Counter + + // Successful setValue writes, with [user]. + SetSuccessTotal metric.Int64Counter + + // Failed setValue writes, with [reason] (bad_request|unauthorized). + SetFailedTotal metric.Int64Counter +} + +func newValueMetrics(meter metric.Meter) ValueMetrics { + getSuccessTotal, _ := meter.Int64Counter( + "codevault_get_value_success_total", + metric.WithDescription("Total number of successful getValue lookups"), + ) + getFailedTotal, _ := meter.Int64Counter( + "codevault_get_value_failed_total", + metric.WithDescription("Total number of failed getValue lookups"), + ) + setSuccessTotal, _ := meter.Int64Counter( + "codevault_set_value_success_total", + metric.WithDescription("Total number of successful setValue writes"), + ) + setFailedTotal, _ := meter.Int64Counter( + "codevault_set_value_failed_total", + metric.WithDescription("Total number of failed setValue writes"), + ) + return ValueMetrics{ + GetSuccessTotal: getSuccessTotal, + GetFailedTotal: getFailedTotal, + SetSuccessTotal: setSuccessTotal, + SetFailedTotal: setFailedTotal, + } +} + +func addObservableMetrics(meter metric.Meter) { + timeStarted := time.Now() + _, _ = meter.Int64ObservableGauge( + "startup_time", + metric.WithDescription("Startup timestamp"), + metric.WithInt64Callback(func(ctx context.Context, io metric.Int64Observer) error { + io.Observe(timeStarted.Unix(), metric.WithAttributes(attribute.String("version", internal.Version))) + return nil + }), + ) + _, _ = meter.Int64ObservableGauge( + "last_update_time", + metric.WithDescription("Current timestamp"), + metric.WithInt64Callback(func(ctx context.Context, io metric.Int64Observer) error { + io.Observe(time.Now().Unix(), metric.WithAttributes(attribute.String("version", internal.Version))) + return nil + }), + ) +} diff --git a/internal/observability/otel.go b/internal/observability/otel.go new file mode 100644 index 0000000..bf38dac --- /dev/null +++ b/internal/observability/otel.go @@ -0,0 +1,18 @@ +package observability + +import ( + "github.com/kanootoko/codevault/internal" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.43.0" +) + +func GetOtelResource() (*resource.Resource, error) { + return resource.Merge( + resource.Default(), + resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(internal.PackageName), + semconv.ServiceVersion(internal.Version), + ), + ) +} diff --git a/internal/ratelimit/limiter.go b/internal/ratelimit/limiter.go new file mode 100644 index 0000000..3f06d94 --- /dev/null +++ b/internal/ratelimit/limiter.go @@ -0,0 +1,145 @@ +// Package ratelimit implements a per-IP sliding-window failure counter used to +// hide the database behind a cap on failed lookups. +package ratelimit + +import ( + "sync" + "time" +) + +type bucket struct { + index int64 + count int +} + +type ipState struct { + current bucket + previous bucket +} + +// Limiter is a two-bucket sliding-window counter per IP. Allowed is a pure read +// (RLock) that estimates the failure count in the trailing window from whatever +// is already stored, without mutating it. RecordFailure takes the write lock and +// performs the actual window roll and increment. Keeping the previous window's +// count means a client blocked near the end of one window stays throttled into +// the next, instead of getting an instant reset at the window boundary. +type Limiter struct { + mu sync.RWMutex + state map[string]ipState + window time.Duration + max int +} + +func NewLimiter(max int, window time.Duration) *Limiter { + return &Limiter{ + state: make(map[string]ipState), + window: window, + max: max, + } +} + +func (l *Limiter) windowIndex(t time.Time) int64 { + return t.UnixNano() / int64(l.window) +} + +// Allowed reports whether ip is currently under the failure threshold. It never +// mutates the limiter's state. +func (l *Limiter) Allowed(ip string) bool { + now := time.Now() + idx := l.windowIndex(now) + + l.mu.RLock() + state, ok := l.state[ip] + l.mu.RUnlock() + if !ok { + return true + } + + current, previous := effectiveBuckets(state, idx) + + overlap := overlapFraction(now, l.window, previous.index) + estimate := float64(current.count) + float64(previous.count)*overlap + + return estimate < float64(l.max) +} + +// RecordFailure increments the failure count for ip in the current window, +// rolling the previous stored window forward if it has become stale. +func (l *Limiter) RecordFailure(ip string) { + now := time.Now() + idx := l.windowIndex(now) + + l.mu.Lock() + defer l.mu.Unlock() + + state := l.state[ip] + current, previous := effectiveBuckets(state, idx) + current.count++ + l.state[ip] = ipState{current: current, previous: previous} +} + +// Cleanup removes IPs whose state is more than 2 windows stale, bounding memory. +func (l *Limiter) Cleanup() { + idx := l.windowIndex(time.Now()) + + l.mu.Lock() + defer l.mu.Unlock() + + for ip, state := range l.state { + if idx-state.current.index > 2 { + delete(l.state, ip) + } + } +} + +// Run periodically calls Cleanup until ctx is done. +func (l *Limiter) Run(done <-chan struct{}, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + l.Cleanup() + } + } +} + +// effectiveBuckets derives the current/previous buckets as seen from window idx, +// without mutating the stored state: if the stored current bucket is stale by +// exactly one window it becomes the effective previous bucket; if it is older +// than that, both buckets are treated as empty. +func effectiveBuckets(state ipState, idx int64) (current, previous bucket) { + switch { + case state.current.index == idx: + current = state.current + if state.previous.index == idx-1 { + previous = state.previous + } else { + previous = bucket{index: idx - 1} + } + case state.current.index == idx-1: + current = bucket{index: idx} + previous = state.current + default: + current = bucket{index: idx} + previous = bucket{index: idx - 1} + } + return current, previous +} + +// overlapFraction returns how much of the previous window (identified by +// previousIdx) still falls within the trailing lookback window of length +// windowSize measured back from now. +func overlapFraction(now time.Time, windowSize time.Duration, previousIdx int64) float64 { + previousWindowEnd := time.Unix(0, (previousIdx+1)*int64(windowSize)) + elapsedSincePreviousEnd := now.Sub(previousWindowEnd) + if elapsedSincePreviousEnd >= windowSize { + return 0 + } + if elapsedSincePreviousEnd <= 0 { + return 1 + } + return 1 - float64(elapsedSincePreviousEnd)/float64(windowSize) +} diff --git a/internal/ratelimit/limiter_test.go b/internal/ratelimit/limiter_test.go new file mode 100644 index 0000000..305c5dc --- /dev/null +++ b/internal/ratelimit/limiter_test.go @@ -0,0 +1,101 @@ +package ratelimit + +import ( + "testing" + "time" +) + +func TestLimiter_AllowsUnderThreshold(t *testing.T) { + l := NewLimiter(10, time.Minute) + for i := range 9 { + if !l.Allowed("1.2.3.4") { + t.Fatalf("expected allowed at failure %d", i) + } + l.RecordFailure("1.2.3.4") + } + if !l.Allowed("1.2.3.4") { + t.Fatalf("expected allowed after 9 failures") + } +} + +func TestLimiter_BlocksAtThreshold(t *testing.T) { + l := NewLimiter(10, time.Minute) + for range 10 { + l.RecordFailure("1.2.3.4") + } + if l.Allowed("1.2.3.4") { + t.Fatalf("expected blocked after 10 failures") + } +} + +func TestLimiter_BlockedClientStaysThrottledAcrossWindowBoundary(t *testing.T) { + window := 300 * time.Millisecond + l := NewLimiter(10, window) + + for range 20 { + l.RecordFailure("1.2.3.4") + } + if l.Allowed("1.2.3.4") { + t.Fatalf("expected blocked immediately after 20 failures") + } + + idx := l.windowIndex(time.Now()) + windowEnd := time.Unix(0, (idx+1)*int64(window)) + // Sleep just past the window boundary so the failures roll into the previous + // bucket, then check that their weighted contribution still blocks the client. + time.Sleep(time.Until(windowEnd) + window/10) + + if l.Allowed("1.2.3.4") { + t.Fatalf("expected still blocked shortly after the window boundary") + } +} + +func TestLimiter_ResetsAfterIdlePeriod(t *testing.T) { + window := 50 * time.Millisecond + l := NewLimiter(10, window) + + for range 10 { + l.RecordFailure("1.2.3.4") + } + if l.Allowed("1.2.3.4") { + t.Fatalf("expected blocked immediately after 10 failures") + } + + time.Sleep(window * 3) + + if !l.Allowed("1.2.3.4") { + t.Fatalf("expected allowed after idling for 2+ windows") + } +} + +func TestLimiter_Cleanup(t *testing.T) { + window := 20 * time.Millisecond + l := NewLimiter(10, window) + l.RecordFailure("1.2.3.4") + + time.Sleep(window * 4) + l.Cleanup() + + l.mu.RLock() + _, ok := l.state["1.2.3.4"] + l.mu.RUnlock() + if ok { + t.Fatalf("expected stale IP to be cleaned up") + } +} + +func TestOverlapFraction(t *testing.T) { + window := 100 * time.Millisecond + previousIdx := int64(1000) + previousWindowEnd := time.Unix(0, (previousIdx+1)*int64(window)) + + if got := overlapFraction(previousWindowEnd.Add(-10*time.Millisecond), window, previousIdx); got != 1 { + t.Fatalf("expected overlap 1 before window end, got %v", got) + } + if got := overlapFraction(previousWindowEnd.Add(20*time.Millisecond), window, previousIdx); got < 0.79 || got > 0.81 { + t.Fatalf("expected overlap ~0.8 at 20ms into next window, got %v", got) + } + if got := overlapFraction(previousWindowEnd.Add(200*time.Millisecond), window, previousIdx); got != 0 { + t.Fatalf("expected overlap 0 once fully elapsed, got %v", got) + } +} diff --git a/internal/runcmd/add_user.go b/internal/runcmd/add_user.go new file mode 100644 index 0000000..0d80fbd --- /dev/null +++ b/internal/runcmd/add_user.go @@ -0,0 +1,32 @@ +package runcmd + +import ( + "context" + "fmt" + + "github.com/kanootoko/codevault/internal/auth" + "github.com/kanootoko/codevault/internal/db" +) + +// AddUser opens (creating if necessary) the database at dbPath and upserts a +// user with the given login/password. It intentionally does not depend on the +// application config. +func AddUser(dbPath, login, password string) error { + database, err := db.Open(dbPath) + if err != nil { + return fmt.Errorf("error on opening database: %w", err) + } + defer func() { _ = database.Close() }() + + hash, err := auth.Hash(login, password) + if err != nil { + return fmt.Errorf("error on hashing password: %w", err) + } + + users := db.NewUsersRepository(database) + if err := users.Upsert(context.Background(), login, hash); err != nil { + return fmt.Errorf("error on upserting user: %w", err) + } + + return nil +} diff --git a/internal/runcmd/config_example.go b/internal/runcmd/config_example.go new file mode 100644 index 0000000..33f625b --- /dev/null +++ b/internal/runcmd/config_example.go @@ -0,0 +1,59 @@ +package runcmd + +import ( + "bytes" + "fmt" + "os" + "time" + + "gopkg.in/yaml.v3" + + "github.com/kanootoko/codevault/internal/config" +) + +func ConfigExample(configPath string) error { + configExample := config.Config{ + Environment: config.DevelopmentEnvironment, + Server: config.ServerConfig{ + Address: ":8080", + // Example: a Docker-network CIDR in front of a reverse proxy nginx. + // Replace with the actual proxy's IP/CIDR, or leave empty to trust + // no forwarded-for headers. + TrustedProxies: []string{"172.16.0.0/12"}, + }, + Database: config.DatabaseConfig{ + Path: "./codevault.db", + }, + RateLimit: config.RateLimitConfig{ + MaxFailures: 10, + Window: time.Minute, + }, + Observability: config.ObservabilityConfig{ + Logging: config.LoggingConfig{ + Stderr: config.InfoLoggingLevel, + Files: map[config.LoggingLevel]string{config.InfoLoggingLevel: "./info.log"}, + Exporter: config.LogsExporterConfig{ + Level: config.InfoLoggingLevel, + Endpoint: "http://127.0.0.1:4317/v1/logs", + Transport: "grpc", + }, + }, + Metrics: config.MetricsConfig{ + Host: "127.0.0.1", + Port: 9092, + }, + }, + } + configBytesBuffer := bytes.NewBuffer(nil) + encoder := yaml.NewEncoder(configBytesBuffer) + encoder.SetIndent(2) + err := encoder.Encode(configExample) + if err != nil { + return fmt.Errorf("cannot marshal example config: %w", err) + } + err = os.WriteFile(configPath, configBytesBuffer.Bytes(), os.ModePerm) + if err != nil { + return fmt.Errorf("cannot write config file: %w", err) + } + return nil +} diff --git a/internal/runcmd/run.go b/internal/runcmd/run.go new file mode 100644 index 0000000..03bec73 --- /dev/null +++ b/internal/runcmd/run.go @@ -0,0 +1,54 @@ +package runcmd + +import ( + "context" + "fmt" + + "go.uber.org/zap" + + "github.com/kanootoko/codevault/internal/config" + "github.com/kanootoko/codevault/internal/db" + "github.com/kanootoko/codevault/internal/httpserver" + "github.com/kanootoko/codevault/internal/observability/logging" + "github.com/kanootoko/codevault/internal/observability/metrics" + "github.com/kanootoko/codevault/internal/ratelimit" + "github.com/kanootoko/codevault/internal/utils/gshutdown" +) + +func RunServer(ctx context.Context, appConfig config.Config) error { + logger, err := logging.LoggerFromCtx(ctx) + if err != nil { + return fmt.Errorf("error on getting logger: %w", err) + } + + appMetrics := metrics.NewMetrics(metrics.GetMeter()) + + database, err := db.Open(appConfig.Database.Path) + if err != nil { + return fmt.Errorf("error on opening database: %w", err) + } + _ = gshutdown.SubtaskStarted(ctx, "database") + go func() { + defer func() { _ = gshutdown.SubtaskFinished(ctx, "database") }() + <-ctx.Done() + if err := database.Close(); err != nil { + logger.Error("error on closing database", zap.Error(err)) + } + }() + + secrets := db.NewSecretsRepository(database) + users := db.NewUsersRepository(database) + + limiter := ratelimit.NewLimiter(appConfig.RateLimit.MaxFailures, appConfig.RateLimit.Window) + + router, err := httpserver.New(secrets, users, limiter, appMetrics, appConfig.Server.TrustedProxies) + if err != nil { + return fmt.Errorf("error on building http router: %w", err) + } + + logger.Info("starting codevault server") + if err := httpserver.Run(ctx, appConfig.Server.Address, router, limiter); err != nil { + return fmt.Errorf("error on running http server: %w", err) + } + return nil +} diff --git a/internal/utils/env.go b/internal/utils/env.go new file mode 100644 index 0000000..b8f05b4 --- /dev/null +++ b/internal/utils/env.go @@ -0,0 +1,17 @@ +package utils + +import ( + "os" + + "github.com/spf13/cobra" +) + +func TryFlagFromEnv(cmd *cobra.Command, flag string, envvar string, required bool) { + if value, ok := os.LookupEnv(envvar); ok { + _ = cmd.Flags().Set(flag, value) + return + } + if required { + cmd.MarkFlagRequired(flag) + } +} diff --git a/internal/utils/gshutdown/graceful_shutdown.go b/internal/utils/gshutdown/graceful_shutdown.go new file mode 100644 index 0000000..6ebe60d --- /dev/null +++ b/internal/utils/gshutdown/graceful_shutdown.go @@ -0,0 +1,174 @@ +package gshutdown + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "go.uber.org/zap" +) + +var ( + ErrNotInitialized = errors.New("seems that graceful shutdown has not been initialized") + ErrTimeout = errors.New("timeout reached") + ErrContextFinished = errors.New("context is finished, unable to add subtasks") + ErrContextNotFinished = errors.New("context is not finished, unable to await subtasks") +) + +func CtxWithGracefulShutdown(ctx context.Context, logger *zap.Logger) context.Context { + if _, ok := ctx.Value(gracefulShutdownKey).(*gracefulShutdownData); ok { + return ctx + } + if logger == nil { + logger = zap.L().Named("graceful_shutdown") + } + data := gracefulShutdownData{ + subtasks: make(map[string]int), + operationsChan: make(chan operation, 1), + finishedChan: make(chan struct{}), + logger: logger, + } + ctx = context.WithValue(ctx, gracefulShutdownKey, &data) + + go data.run(ctx) + data.logger.Info("initialized") + + return ctx +} + +func SubtaskStarted(ctx context.Context, subtaskName string) error { + if ctx.Err() != nil { + return ErrContextFinished + } + gs, ok := ctx.Value(gracefulShutdownKey).(*gracefulShutdownData) + if !ok { + return ErrNotInitialized + } + gs.operationsChan <- operation{Type: startOperationType, SubtaskName: subtaskName} + return nil +} + +func SubtaskFinished(ctx context.Context, subtaskName string) error { + gs, ok := ctx.Value(gracefulShutdownKey).(*gracefulShutdownData) + if !ok { + return ErrNotInitialized + } + gs.operationsChan <- operation{Type: finishOperationType, SubtaskName: subtaskName} + return nil +} + +func AwaitWithTimeout(ctx context.Context, timeout time.Duration) error { + if ctx.Err() == nil { + return fmt.Errorf("context is not finished yet") + } + gs, ok := ctx.Value(gracefulShutdownKey).(*gracefulShutdownData) + if !ok { + return ErrNotInitialized + } + timer := time.NewTimer(timeout) + select { + case <-timer.C: + return ErrTimeout + case <-gs.finishedChan: + return gs.err + } +} + +type gracefulShutdownKeyType int + +const gracefulShutdownKey gracefulShutdownKeyType = iota + +type gracefulShutdownData struct { + mu sync.Mutex + subtasks map[string]int + operationsChan chan operation + finishedChan chan struct{} + err error + logger *zap.Logger +} + +type operationType string + +var ( + startOperationType operationType = "start" + finishOperationType operationType = "finish" +) + +type operation struct { + Type operationType + SubtaskName string +} + +func (data *gracefulShutdownData) run(ctx context.Context) { + for { + select { + case <-ctx.Done(): + if data.isDone() { + close(data.finishedChan) + data.logger.Info("no tasks to be awaited") + return + } + case op := <-data.operationsChan: + switch op.Type { + case startOperationType: + data.acknowledgeSubtask(op.SubtaskName) + default: + data.finishSubtask(op.SubtaskName) + } + if data.isDone() { + close(data.finishedChan) + data.logger.Info("all tasks successfully awaited") + return + } + data.logger.Debug("awaiting in progress", zap.Strings("to_be_awaited", data.toBeAwaited())) + } + } +} + +func (data *gracefulShutdownData) acknowledgeSubtask(subtaskName string) { + data.mu.Lock() + defer data.mu.Unlock() + + data.subtasks[subtaskName]++ + data.logger.Info( + "successful acknowledge", + zap.String("subtask", subtaskName), + zap.Int("total_count", data.subtasks[subtaskName]), + ) +} + +func (data *gracefulShutdownData) finishSubtask(subtaskName string) { + data.mu.Lock() + defer data.mu.Unlock() + + data.subtasks[subtaskName]-- + data.logger.Info( + "successful finish", + zap.String("subtask", subtaskName), + zap.Int("left_count", data.subtasks[subtaskName]), + ) + if data.subtasks[subtaskName] == 0 { + delete(data.subtasks, subtaskName) + } +} + +func (data *gracefulShutdownData) isDone() bool { + data.mu.Lock() + defer data.mu.Unlock() + return len(data.subtasks) == 0 +} + +func (data *gracefulShutdownData) toBeAwaited() []string { + data.mu.Lock() + defer data.mu.Unlock() + + res := make([]string, 0, len(data.subtasks)) + for subtaskName, count := range data.subtasks { + for range count { + res = append(res, subtaskName) + } + } + return res +} diff --git a/internal/version.go b/internal/version.go new file mode 100644 index 0000000..0d55bb3 --- /dev/null +++ b/internal/version.go @@ -0,0 +1,4 @@ +package internal + +const Version = "0.1.0" +const PackageName = "codevault" diff --git a/internal/webui/embed.go b/internal/webui/embed.go new file mode 100644 index 0000000..627bf74 --- /dev/null +++ b/internal/webui/embed.go @@ -0,0 +1,12 @@ +// Package webui embeds the static HTML+JS pages served for the get/set flows. +package webui + +import "embed" + +//go:embed static/get.html static/set.html +var Static embed.FS + +const ( + GetPagePath = "static/get.html" + SetPagePath = "static/set.html" +) diff --git a/internal/webui/static/get.html b/internal/webui/static/get.html new file mode 100644 index 0000000..8521f14 --- /dev/null +++ b/internal/webui/static/get.html @@ -0,0 +1,48 @@ + + + + +CodeVault - Get value + + + +

Get value by code

+ + +
+

Set a value instead

+ + + diff --git a/internal/webui/static/set.html b/internal/webui/static/set.html new file mode 100644 index 0000000..3a42471 --- /dev/null +++ b/internal/webui/static/set.html @@ -0,0 +1,59 @@ + + + + +CodeVault - Set value + + + +

Set value for a code

+ + + + + +
+

Get a value instead

+ + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..2208488 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/kanootoko/codevault/cmd" + +func main() { + cmd.Execute() +}