From 0f28f2d088cca31947d58892b17c6e566c9ae1d7 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 28 Feb 2026 10:10:26 +0100 Subject: [PATCH 1/2] callbacks service draft --- .woodpecker/callbacks.yml | 90 +++ Makefile | 6 +- README.md | 64 ++- api/edge/callbacks/.air.toml | 46 ++ api/edge/callbacks/.gitignore | 3 + api/edge/callbacks/config.dev.yml | 64 +++ api/edge/callbacks/config.yml | 63 +++ api/edge/callbacks/entrypoint.sh | 15 + api/edge/callbacks/go.mod | 64 +++ api/edge/callbacks/go.sum | 256 +++++++++ .../callbacks/internal/appversion/version.go | 28 + api/edge/callbacks/internal/config/module.go | 182 +++++++ api/edge/callbacks/internal/config/service.go | 162 ++++++ .../callbacks/internal/delivery/classifier.go | 27 + .../callbacks/internal/delivery/module.go | 48 ++ .../callbacks/internal/delivery/service.go | 263 +++++++++ api/edge/callbacks/internal/events/module.go | 33 ++ api/edge/callbacks/internal/events/service.go | 86 +++ api/edge/callbacks/internal/ingest/module.go | 51 ++ api/edge/callbacks/internal/ingest/service.go | 204 +++++++ api/edge/callbacks/internal/ops/module.go | 36 ++ api/edge/callbacks/internal/ops/server.go | 119 ++++ api/edge/callbacks/internal/ops/service.go | 75 +++ api/edge/callbacks/internal/retry/module.go | 8 + api/edge/callbacks/internal/retry/service.go | 59 ++ api/edge/callbacks/internal/secrets/module.go | 33 ++ .../callbacks/internal/secrets/service.go | 224 ++++++++ .../callbacks/internal/security/module.go | 16 + .../callbacks/internal/security/service.go | 163 ++++++ .../internal/server/internal/serverimp.go | 271 +++++++++ .../internal/server/internal/types.go | 37 ++ api/edge/callbacks/internal/server/server.go | 11 + api/edge/callbacks/internal/signing/module.go | 36 ++ .../callbacks/internal/signing/service.go | 80 +++ api/edge/callbacks/internal/storage/module.go | 99 ++++ .../callbacks/internal/storage/service.go | 513 ++++++++++++++++++ .../internal/subscriptions/module.go | 17 + .../internal/subscriptions/service.go | 38 ++ api/edge/callbacks/main.go | 17 + api/gateway/chain/go.mod | 4 +- .../internal/keymanager/vault/manager.go | 236 +------- api/gateway/tron/go.mod | 2 +- .../tron/internal/keymanager/vault/manager.go | 232 +------- .../internal/server/internal/serverimp.go | 2 +- .../service/orchestrationv2/psvc/execute.go | 12 + .../service/orchestrationv2/psvc/module.go | 2 + .../service/orchestrationv2/psvc/runtime.go | 17 + .../service/orchestrationv2/psvc/service.go | 2 + .../orchestrationv2/psvc/service_e2e_test.go | 118 ++++ .../orchestrationv2/psvc/status_publish.go | 209 +++++++ .../internal/service/orchestrator/service.go | 9 +- .../service/orchestrator/service_v2.go | 3 + api/pkg/go.mod | 25 +- api/pkg/go.sum | 97 +++- api/pkg/model/client.go | 1 + api/pkg/vault/kv/module.go | 34 ++ api/pkg/vault/kv/service.go | 151 ++++++ api/pkg/vault/managedkey/module.go | 54 ++ api/pkg/vault/managedkey/service.go | 218 ++++++++ ci/dev/README.md | 6 +- ci/dev/callbacks.dockerfile | 39 ++ ci/dev/vault-agent/callbacks.hcl | 20 + ci/prod/.env.runtime | 14 + ci/prod/compose/callbacks.dockerfile | 42 ++ ci/prod/compose/callbacks.yml | 88 +++ ci/prod/compose/vault-agent/callbacks.hcl | 20 + ci/prod/scripts/deploy/callbacks.sh | 157 ++++++ ci/scripts/callbacks/build-image.sh | 85 +++ ci/scripts/callbacks/deploy.sh | 66 +++ ci/scripts/common/run_backend_tests.sh | 6 + docker-compose.dev.yml | 80 +++ 71 files changed, 5212 insertions(+), 446 deletions(-) create mode 100644 .woodpecker/callbacks.yml create mode 100644 api/edge/callbacks/.air.toml create mode 100644 api/edge/callbacks/.gitignore create mode 100644 api/edge/callbacks/config.dev.yml create mode 100644 api/edge/callbacks/config.yml create mode 100755 api/edge/callbacks/entrypoint.sh create mode 100644 api/edge/callbacks/go.mod create mode 100644 api/edge/callbacks/go.sum create mode 100644 api/edge/callbacks/internal/appversion/version.go create mode 100644 api/edge/callbacks/internal/config/module.go create mode 100644 api/edge/callbacks/internal/config/service.go create mode 100644 api/edge/callbacks/internal/delivery/classifier.go create mode 100644 api/edge/callbacks/internal/delivery/module.go create mode 100644 api/edge/callbacks/internal/delivery/service.go create mode 100644 api/edge/callbacks/internal/events/module.go create mode 100644 api/edge/callbacks/internal/events/service.go create mode 100644 api/edge/callbacks/internal/ingest/module.go create mode 100644 api/edge/callbacks/internal/ingest/service.go create mode 100644 api/edge/callbacks/internal/ops/module.go create mode 100644 api/edge/callbacks/internal/ops/server.go create mode 100644 api/edge/callbacks/internal/ops/service.go create mode 100644 api/edge/callbacks/internal/retry/module.go create mode 100644 api/edge/callbacks/internal/retry/service.go create mode 100644 api/edge/callbacks/internal/secrets/module.go create mode 100644 api/edge/callbacks/internal/secrets/service.go create mode 100644 api/edge/callbacks/internal/security/module.go create mode 100644 api/edge/callbacks/internal/security/service.go create mode 100644 api/edge/callbacks/internal/server/internal/serverimp.go create mode 100644 api/edge/callbacks/internal/server/internal/types.go create mode 100644 api/edge/callbacks/internal/server/server.go create mode 100644 api/edge/callbacks/internal/signing/module.go create mode 100644 api/edge/callbacks/internal/signing/service.go create mode 100644 api/edge/callbacks/internal/storage/module.go create mode 100644 api/edge/callbacks/internal/storage/service.go create mode 100644 api/edge/callbacks/internal/subscriptions/module.go create mode 100644 api/edge/callbacks/internal/subscriptions/service.go create mode 100644 api/edge/callbacks/main.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/status_publish.go create mode 100644 api/pkg/vault/kv/module.go create mode 100644 api/pkg/vault/kv/service.go create mode 100644 api/pkg/vault/managedkey/module.go create mode 100644 api/pkg/vault/managedkey/service.go create mode 100644 ci/dev/callbacks.dockerfile create mode 100644 ci/dev/vault-agent/callbacks.hcl create mode 100644 ci/prod/compose/callbacks.dockerfile create mode 100644 ci/prod/compose/callbacks.yml create mode 100644 ci/prod/compose/vault-agent/callbacks.hcl create mode 100755 ci/prod/scripts/deploy/callbacks.sh create mode 100755 ci/scripts/callbacks/build-image.sh create mode 100755 ci/scripts/callbacks/deploy.sh diff --git a/.woodpecker/callbacks.yml b/.woodpecker/callbacks.yml new file mode 100644 index 00000000..2ddc6051 --- /dev/null +++ b/.woodpecker/callbacks.yml @@ -0,0 +1,90 @@ +matrix: + include: + - CALLBACKS_IMAGE_PATH: edge/callbacks + CALLBACKS_DOCKERFILE: ci/prod/compose/callbacks.dockerfile + CALLBACKS_MONGO_SECRET_PATH: sendico/db + CALLBACKS_VAULT_SECRET_PATH: sendico/edge/callbacks/vault + CALLBACKS_ENV: prod + +when: + - event: push + branch: main + path: + include: + - api/edge/callbacks/** + - api/proto/** + - api/pkg/** + - ci/prod/** + - .woodpecker/callbacks.yml + ignore_message: '[rebuild]' + +steps: + - name: version + image: alpine:latest + commands: + - set -euo pipefail 2>/dev/null || set -eu + - apk add --no-cache git + - GIT_REV="$(git rev-parse --short HEAD)" + - BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)" + - APP_V="$(cat version)" + - BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + - BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}" + - printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \ + "$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version + + - name: proto + image: golang:alpine + depends_on: [ version ] + commands: + - set -eu + - apk add --no-cache bash git build-base protoc protobuf-dev + - go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + - go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + - export PATH="$(go env GOPATH)/bin:$PATH" + - bash ci/scripts/proto/generate.sh + + - name: backend-tests + image: golang:alpine + depends_on: [ proto ] + commands: + - set -eu + - apk add --no-cache bash git build-base + - sh ci/scripts/common/run_backend_tests.sh callbacks + + - name: secrets + image: alpine:latest + depends_on: [ version ] + environment: + VAULT_ADDR: { from_secret: VAULT_ADDR } + VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE } + VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID } + commands: + - set -euo pipefail + - apk add --no-cache bash coreutils openssh-keygen curl sed python3 + - mkdir -p secrets + - ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600 + - base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY + - chmod 600 secrets/SSH_KEY + - ssh-keygen -y -f secrets/SSH_KEY >/dev/null + - ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER + - ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD + + - name: build-image + image: gcr.io/kaniko-project/executor:debug + depends_on: [ backend-tests, secrets ] + commands: + - sh ci/scripts/callbacks/build-image.sh + + - name: deploy + image: alpine:latest + depends_on: [ secrets, build-image ] + environment: + VAULT_ADDR: { from_secret: VAULT_ADDR } + VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE } + VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID } + commands: + - set -euo pipefail + - apk add --no-cache bash openssh-client rsync coreutils curl sed python3 + - mkdir -p /root/.ssh + - install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa + - sh ci/scripts/callbacks/deploy.sh diff --git a/Makefile b/Makefile index 9841257c..9594c0aa 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ help: @echo " make build-fx Build FX services (oracle, ingestor)" @echo " make build-payments Build payment orchestrator" @echo " make build-gateways Build gateway services (chain, tron, mntx, tgsettle)" - @echo " make build-api Build API services (notification, bff)" + @echo " make build-api Build API services (notification, callbacks, bff)" @echo " make build-frontend Build Flutter web frontend" @echo "" @echo "$(YELLOW)Development:$(NC)" @@ -225,6 +225,7 @@ services-up: dev-mntx-gateway \ dev-tgsettle-gateway \ dev-notification \ + dev-callbacks \ dev-bff \ dev-frontend @@ -254,6 +255,7 @@ list-services: @echo " - dev-mntx-gateway :50075, :9405, :8084 (Card Payouts)" @echo " - dev-tgsettle-gateway :50080, :9406 (Telegram Settlements)" @echo " - dev-notification :8081 (Notifications)" + @echo " - dev-callbacks :9420 (Webhook Callbacks)" @echo " - dev-bff :8080 (Backend for Frontend)" @echo " - dev-frontend :3000 (Flutter Web UI)" @@ -285,7 +287,7 @@ build-gateways: build-api: @echo "$(GREEN)Building API services...$(NC)" - @$(COMPOSE) build dev-notification dev-bff + @$(COMPOSE) build dev-notification dev-callbacks dev-bff build-frontend: @echo "$(GREEN)Building frontend...$(NC)" diff --git a/README.md b/README.md index 607c7316..2eeb43f2 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Financial services platform providing payment orchestration, ledger accounting, | Gateway TGSettle | `api/gateway/tgsettle/` | Telegram settlements with MNTX | | Notification | `api/notification/` | Notifications | | BFF | `api/edge/bff/` | Backend for frontend | +| Callbacks | `api/edge/callbacks/` | Webhook callbacks delivery | | Frontend | `frontend/pweb/` | Flutter web UI | ## Development @@ -70,7 +71,7 @@ make build-core # discovery, ledger, fees, documents make build-fx # oracle, ingestor make build-payments # orchestrator make build-gateways # chain, tron, mntx, tgsettle -make build-api # notification, bff +make build-api # notification, callbacks, bff make build-frontend # Flutter web UI ``` @@ -98,3 +99,64 @@ make update # Update all Go and Flutter dependencies make update-api # Update Go dependencies only make update-frontend # Update Flutter dependencies only ``` + +### Callbacks Secret References + +Callbacks (`api/edge/callbacks`) supports three secret reference formats: + +- `env:MY_SECRET_ENV` to read from environment variables. +- `vault:some/path#field` to read a field from Vault KV v2. +- `some/path#field` to read from Vault KV v2 when `secrets.vault` is configured. + +If `#field` is omitted, callbacks uses `secrets.vault.default_field` (default: `value`). + +### Callbacks Vault Auth (Dev + Prod) + +Callbacks now authenticates to Vault through a sidecar Vault Agent (AppRole), same pattern as chain/tron gateways. + +- Dev compose: + - service: `dev-callbacks-vault-agent` + - shared token file: `/run/vault/token` + - app reads token via `VAULT_TOKEN_FILE=/run/vault/token` and `token_env: VAULT_TOKEN` +- Prod compose: + - service: `sendico_callbacks_vault_agent` + - same token sink and env flow + - AppRole creds are injected at deploy from `CALLBACKS_VAULT_SECRET_PATH` (default `sendico/edge/callbacks/vault`) + +Required Vault policy (minimal read-only for KV v2 mount `kv`): + +```hcl +path "kv/data/callbacks/*" { + capabilities = ["read"] +} + +path "kv/metadata/callbacks/*" { + capabilities = ["read", "list"] +} +``` + +Create policy + role (example): + +```bash +vault policy write callbacks callbacks-policy.hcl +vault write auth/approle/role/callbacks \ + token_policies="callbacks" \ + token_ttl="1h" \ + token_max_ttl="24h" +vault read -field=role_id auth/approle/role/callbacks/role-id +vault write -f -field=secret_id auth/approle/role/callbacks/secret-id +``` + +Store AppRole creds for prod deploy pipeline: + +```bash +vault kv put kv/sendico/edge/callbacks/vault \ + role_id="" \ + secret_id="" +``` + +Store webhook signing secrets (example path consumed by `secret_ref`): + +```bash +vault kv put kv/callbacks/client-a/webhook secret="super-secret" +``` diff --git a/api/edge/callbacks/.air.toml b/api/edge/callbacks/.air.toml new file mode 100644 index 00000000..16f8c34b --- /dev/null +++ b/api/edge/callbacks/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + entrypoint = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go", "_templ.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/api/edge/callbacks/.gitignore b/api/edge/callbacks/.gitignore new file mode 100644 index 00000000..443dbb1d --- /dev/null +++ b/api/edge/callbacks/.gitignore @@ -0,0 +1,3 @@ +app +.gocache +tmp diff --git a/api/edge/callbacks/config.dev.yml b/api/edge/callbacks/config.dev.yml new file mode 100644 index 00000000..0d1ae1b3 --- /dev/null +++ b/api/edge/callbacks/config.dev.yml @@ -0,0 +1,64 @@ +runtime: + shutdown_timeout_seconds: 15 + +metrics: + address: ":9420" + +database: + driver: mongodb + settings: + host_env: CALLBACKS_MONGO_HOST + port_env: CALLBACKS_MONGO_PORT + database_env: CALLBACKS_MONGO_DATABASE + user_env: CALLBACKS_MONGO_USER + password_env: CALLBACKS_MONGO_PASSWORD + auth_source_env: CALLBACKS_MONGO_AUTH_SOURCE + replica_set_env: CALLBACKS_MONGO_REPLICA_SET + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: Edge Callbacks Service + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + +ingest: + stream: CALLBACKS + subject: callbacks.events + durable: callbacks-ingest + batch_size: 32 + fetch_timeout_ms: 2000 + idle_sleep_ms: 500 + + +delivery: + worker_concurrency: 8 + worker_poll_ms: 200 + lock_ttl_seconds: 30 + request_timeout_ms: 10000 + max_attempts: 8 + min_delay_ms: 1000 + max_delay_ms: 300000 + jitter_ratio: 0.2 + +security: + require_https: true + allowed_hosts: [] + allowed_ports: [443] + dns_resolve_timeout_ms: 2000 + +secrets: + cache_ttl_seconds: 60 + static: {} + vault: + address: "http://dev-vault:8200" + token_env: VAULT_TOKEN + namespace: "" + mount_path: kv + default_field: value diff --git a/api/edge/callbacks/config.yml b/api/edge/callbacks/config.yml new file mode 100644 index 00000000..39908990 --- /dev/null +++ b/api/edge/callbacks/config.yml @@ -0,0 +1,63 @@ +runtime: + shutdown_timeout_seconds: 15 + +metrics: + address: ":9420" + +database: + driver: mongodb + settings: + host_env: CALLBACKS_MONGO_HOST + port_env: CALLBACKS_MONGO_PORT + database_env: CALLBACKS_MONGO_DATABASE + user_env: CALLBACKS_MONGO_USER + password_env: CALLBACKS_MONGO_PASSWORD + auth_source_env: CALLBACKS_MONGO_AUTH_SOURCE + replica_set_env: CALLBACKS_MONGO_REPLICA_SET + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: Edge Callbacks Service + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + +ingest: + stream: CALLBACKS + subject: callbacks.events + durable: callbacks-ingest + batch_size: 32 + fetch_timeout_ms: 2000 + idle_sleep_ms: 500 + +delivery: + worker_concurrency: 8 + worker_poll_ms: 200 + lock_ttl_seconds: 30 + request_timeout_ms: 10000 + max_attempts: 8 + min_delay_ms: 1000 + max_delay_ms: 300000 + jitter_ratio: 0.2 + +security: + require_https: true + allowed_hosts: [] + allowed_ports: [443] + dns_resolve_timeout_ms: 2000 + +secrets: + cache_ttl_seconds: 60 + static: {} + vault: + address: "https://vault.sendico.io" + token_env: VAULT_TOKEN + namespace: "" + mount_path: kv + default_field: value diff --git a/api/edge/callbacks/entrypoint.sh b/api/edge/callbacks/entrypoint.sh new file mode 100755 index 00000000..b3a445f1 --- /dev/null +++ b/api/edge/callbacks/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +if [ -n "${VAULT_TOKEN_FILE:-}" ] && [ -f "${VAULT_TOKEN_FILE}" ]; then + token="$(cat "${VAULT_TOKEN_FILE}" 2>/dev/null | tr -d '[:space:]')" + if [ -n "${token}" ]; then + export VAULT_TOKEN="${token}" + fi +fi + +if [ -z "${VAULT_TOKEN:-}" ]; then + echo "[entrypoint] VAULT_TOKEN is not set; expected Vault Agent sink to write a token to ${VAULT_TOKEN_FILE:-/run/vault/token}" >&2 +fi + +exec "$@" diff --git a/api/edge/callbacks/go.mod b/api/edge/callbacks/go.mod new file mode 100644 index 00000000..35bebcf4 --- /dev/null +++ b/api/edge/callbacks/go.mod @@ -0,0 +1,64 @@ +module github.com/tech/sendico/edge/callbacks + +go 1.25.7 + +replace github.com/tech/sendico/pkg => ../../pkg + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/nats-io/nats.go v1.49.0 + github.com/prometheus/client_golang v1.23.2 + github.com/tech/sendico/pkg v0.1.0 + go.mongodb.org/mongo-driver/v2 v2.5.0 + go.uber.org/zap v1.27.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/casbin/casbin/v2 v2.135.0 // indirect + github.com/casbin/govaluate v1.10.0 // indirect + github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/vault/api v1.22.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/grpc v1.79.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/api/edge/callbacks/go.sum b/api/edge/callbacks/go.sum new file mode 100644 index 00000000..b860b3c9 --- /dev/null +++ b/api/edge/callbacks/go.sum @@ -0,0 +1,256 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +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/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0lCi7JKrS4qQ= +github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +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/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/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-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +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/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +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.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +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/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +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/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.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/edge/callbacks/internal/appversion/version.go b/api/edge/callbacks/internal/appversion/version.go new file mode 100644 index 00000000..63465b33 --- /dev/null +++ b/api/edge/callbacks/internal/appversion/version.go @@ -0,0 +1,28 @@ +package appversion + +import ( + "github.com/tech/sendico/pkg/version" + vf "github.com/tech/sendico/pkg/version/factory" +) + +// Build information. Populated at build-time. +var ( + Version string + Revision string + Branch string + BuildUser string + BuildDate string +) + +func Create() version.Printer { + info := version.Info{ + Program: "Sendico Edge Callbacks Service", + Revision: Revision, + Branch: Branch, + BuildUser: BuildUser, + BuildDate: BuildDate, + Version: Version, + } + + return vf.Create(&info) +} diff --git a/api/edge/callbacks/internal/config/module.go b/api/edge/callbacks/internal/config/module.go new file mode 100644 index 00000000..89d87929 --- /dev/null +++ b/api/edge/callbacks/internal/config/module.go @@ -0,0 +1,182 @@ +package config + +import ( + "time" + + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/messaging" +) + +const ( + defaultShutdownTimeoutSeconds = 15 + defaultMetricsAddress = ":9420" + defaultIngestStream = "CALLBACKS" + defaultIngestSubject = "callbacks.events" + defaultIngestDurable = "callbacks-ingest" + defaultIngestBatchSize = 32 + defaultIngestFetchTimeoutMS = 2000 + defaultIngestIdleSleepMS = 500 + defaultTaskCollection = "callback_tasks" + defaultInboxCollection = "callback_inbox" + defaultEndpointsCollection = "webhook_endpoints" + defaultWorkerConcurrency = 8 + defaultWorkerPollIntervalMS = 200 + defaultLockTTLSeconds = 30 + defaultRequestTimeoutMS = 10000 + defaultMaxAttempts = 8 + defaultMinDelayMS = 1000 + defaultMaxDelayMS = 300000 + defaultJitterRatio = 0.20 + defaultDNSResolveTimeoutMS = 2000 + defaultSecretsVaultField = "value" +) + +// Loader parses callbacks service configuration. +type Loader interface { + Load(path string) (*Config, error) +} + +// Config is the full callbacks service configuration. +type Config struct { + Runtime *RuntimeConfig `yaml:"runtime"` + Metrics *MetricsConfig `yaml:"metrics"` + Database *db.Config `yaml:"database"` + Messaging *messaging.Config `yaml:"messaging"` + Ingest IngestConfig `yaml:"ingest"` + Delivery DeliveryConfig `yaml:"delivery"` + Security SecurityConfig `yaml:"security"` + Secrets SecretsConfig `yaml:"secrets"` +} + +// RuntimeConfig contains process lifecycle settings. +type RuntimeConfig struct { + ShutdownTimeoutSeconds int `yaml:"shutdown_timeout_seconds"` +} + +func (c *RuntimeConfig) ShutdownTimeout() time.Duration { + if c == nil || c.ShutdownTimeoutSeconds <= 0 { + return defaultShutdownTimeoutSeconds * time.Second + } + return time.Duration(c.ShutdownTimeoutSeconds) * time.Second +} + +// MetricsConfig configures observability endpoints. +type MetricsConfig struct { + Address string `yaml:"address"` +} + +func (c *MetricsConfig) ListenAddress() string { + if c == nil || c.Address == "" { + return defaultMetricsAddress + } + return c.Address +} + +// IngestConfig configures JetStream ingestion. +type IngestConfig struct { + Stream string `yaml:"stream"` + Subject string `yaml:"subject"` + Durable string `yaml:"durable"` + BatchSize int `yaml:"batch_size"` + FetchTimeoutMS int `yaml:"fetch_timeout_ms"` + IdleSleepMS int `yaml:"idle_sleep_ms"` +} + +func (c *IngestConfig) FetchTimeout() time.Duration { + if c.FetchTimeoutMS <= 0 { + return time.Duration(defaultIngestFetchTimeoutMS) * time.Millisecond + } + return time.Duration(c.FetchTimeoutMS) * time.Millisecond +} + +func (c *IngestConfig) IdleSleep() time.Duration { + if c.IdleSleepMS <= 0 { + return time.Duration(defaultIngestIdleSleepMS) * time.Millisecond + } + return time.Duration(c.IdleSleepMS) * time.Millisecond +} + +// DeliveryConfig controls dispatcher behavior. +type DeliveryConfig struct { + WorkerConcurrency int `yaml:"worker_concurrency"` + WorkerPollMS int `yaml:"worker_poll_ms"` + LockTTLSeconds int `yaml:"lock_ttl_seconds"` + RequestTimeoutMS int `yaml:"request_timeout_ms"` + MaxAttempts int `yaml:"max_attempts"` + MinDelayMS int `yaml:"min_delay_ms"` + MaxDelayMS int `yaml:"max_delay_ms"` + JitterRatio float64 `yaml:"jitter_ratio"` +} + +func (c *DeliveryConfig) WorkerPollInterval() time.Duration { + if c.WorkerPollMS <= 0 { + return time.Duration(defaultWorkerPollIntervalMS) * time.Millisecond + } + return time.Duration(c.WorkerPollMS) * time.Millisecond +} + +func (c *DeliveryConfig) LockTTL() time.Duration { + if c.LockTTLSeconds <= 0 { + return time.Duration(defaultLockTTLSeconds) * time.Second + } + return time.Duration(c.LockTTLSeconds) * time.Second +} + +func (c *DeliveryConfig) RequestTimeout() time.Duration { + if c.RequestTimeoutMS <= 0 { + return time.Duration(defaultRequestTimeoutMS) * time.Millisecond + } + return time.Duration(c.RequestTimeoutMS) * time.Millisecond +} + +func (c *DeliveryConfig) MinDelay() time.Duration { + if c.MinDelayMS <= 0 { + return time.Duration(defaultMinDelayMS) * time.Millisecond + } + return time.Duration(c.MinDelayMS) * time.Millisecond +} + +func (c *DeliveryConfig) MaxDelay() time.Duration { + if c.MaxDelayMS <= 0 { + return time.Duration(defaultMaxDelayMS) * time.Millisecond + } + return time.Duration(c.MaxDelayMS) * time.Millisecond +} + +// SecurityConfig controls outbound callback safety checks. +type SecurityConfig struct { + RequireHTTPS bool `yaml:"require_https"` + AllowedHosts []string `yaml:"allowed_hosts"` + AllowedPorts []int `yaml:"allowed_ports"` + DNSResolveTimeout int `yaml:"dns_resolve_timeout_ms"` +} + +func (c *SecurityConfig) DNSResolveTimeoutMS() time.Duration { + if c.DNSResolveTimeout <= 0 { + return time.Duration(defaultDNSResolveTimeoutMS) * time.Millisecond + } + return time.Duration(c.DNSResolveTimeout) * time.Millisecond +} + +// SecretsConfig controls secret lookup behavior. +type SecretsConfig struct { + CacheTTLSeconds int `yaml:"cache_ttl_seconds"` + Static map[string]string `yaml:"static"` + Vault VaultSecretsConfig `yaml:"vault"` +} + +// VaultSecretsConfig controls Vault KV secret resolution. +type VaultSecretsConfig struct { + Address string `yaml:"address"` + TokenEnv string `yaml:"token_env"` + Namespace string `yaml:"namespace"` + MountPath string `yaml:"mount_path"` + DefaultField string `yaml:"default_field"` +} + +func (c *SecretsConfig) CacheTTL() time.Duration { + if c == nil || c.CacheTTLSeconds <= 0 { + return 0 + } + return time.Duration(c.CacheTTLSeconds) * time.Second +} diff --git a/api/edge/callbacks/internal/config/service.go b/api/edge/callbacks/internal/config/service.go new file mode 100644 index 00000000..c6881f2d --- /dev/null +++ b/api/edge/callbacks/internal/config/service.go @@ -0,0 +1,162 @@ +package config + +import ( + "os" + "strings" + + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +type service struct { + logger mlogger.Logger +} + +// New creates a configuration loader. +func New(logger mlogger.Logger) Loader { + if logger == nil { + logger = zap.NewNop() + } + return &service{logger: logger.Named("config")} +} + +func (s *service) Load(path string) (*Config, error) { + if strings.TrimSpace(path) == "" { + return nil, merrors.InvalidArgument("config path is required", "path") + } + + data, err := os.ReadFile(path) + if err != nil { + s.logger.Error("Failed to read config file", zap.String("path", path), zap.Error(err)) + return nil, merrors.InternalWrap(err, "failed to read callbacks config") + } + + cfg := &Config{} + if err := yaml.Unmarshal(data, cfg); err != nil { + s.logger.Error("Failed to parse config yaml", zap.String("path", path), zap.Error(err)) + return nil, merrors.InternalWrap(err, "failed to parse callbacks config") + } + + s.applyDefaults(cfg) + if err := s.validate(cfg); err != nil { + return nil, err + } + + return cfg, nil +} + +func (s *service) applyDefaults(cfg *Config) { + if cfg.Runtime == nil { + cfg.Runtime = &RuntimeConfig{ShutdownTimeoutSeconds: defaultShutdownTimeoutSeconds} + } + + if cfg.Metrics == nil { + cfg.Metrics = &MetricsConfig{Address: defaultMetricsAddress} + } else if strings.TrimSpace(cfg.Metrics.Address) == "" { + cfg.Metrics.Address = defaultMetricsAddress + } + + if strings.TrimSpace(cfg.Ingest.Stream) == "" { + cfg.Ingest.Stream = defaultIngestStream + } + if strings.TrimSpace(cfg.Ingest.Subject) == "" { + cfg.Ingest.Subject = defaultIngestSubject + } + if strings.TrimSpace(cfg.Ingest.Durable) == "" { + cfg.Ingest.Durable = defaultIngestDurable + } + if cfg.Ingest.BatchSize <= 0 { + cfg.Ingest.BatchSize = defaultIngestBatchSize + } + if cfg.Ingest.FetchTimeoutMS <= 0 { + cfg.Ingest.FetchTimeoutMS = defaultIngestFetchTimeoutMS + } + if cfg.Ingest.IdleSleepMS <= 0 { + cfg.Ingest.IdleSleepMS = defaultIngestIdleSleepMS + } + + if cfg.Delivery.WorkerConcurrency <= 0 { + cfg.Delivery.WorkerConcurrency = defaultWorkerConcurrency + } + if cfg.Delivery.WorkerPollMS <= 0 { + cfg.Delivery.WorkerPollMS = defaultWorkerPollIntervalMS + } + if cfg.Delivery.LockTTLSeconds <= 0 { + cfg.Delivery.LockTTLSeconds = defaultLockTTLSeconds + } + if cfg.Delivery.RequestTimeoutMS <= 0 { + cfg.Delivery.RequestTimeoutMS = defaultRequestTimeoutMS + } + if cfg.Delivery.MaxAttempts <= 0 { + cfg.Delivery.MaxAttempts = defaultMaxAttempts + } + if cfg.Delivery.MinDelayMS <= 0 { + cfg.Delivery.MinDelayMS = defaultMinDelayMS + } + if cfg.Delivery.MaxDelayMS <= 0 { + cfg.Delivery.MaxDelayMS = defaultMaxDelayMS + } + if cfg.Delivery.JitterRatio <= 0 { + cfg.Delivery.JitterRatio = defaultJitterRatio + } + if cfg.Delivery.JitterRatio > 1 { + cfg.Delivery.JitterRatio = 1 + } + + if cfg.Security.DNSResolveTimeout <= 0 { + cfg.Security.DNSResolveTimeout = defaultDNSResolveTimeoutMS + } + if len(cfg.Security.AllowedPorts) == 0 { + cfg.Security.AllowedPorts = []int{443} + } + if !cfg.Security.RequireHTTPS { + cfg.Security.RequireHTTPS = true + } + + if cfg.Secrets.Static == nil { + cfg.Secrets.Static = map[string]string{} + } + if strings.TrimSpace(cfg.Secrets.Vault.DefaultField) == "" { + cfg.Secrets.Vault.DefaultField = defaultSecretsVaultField + } +} + +func (s *service) validate(cfg *Config) error { + if cfg.Database == nil { + return merrors.InvalidArgument("database configuration is required", "database") + } + if cfg.Messaging == nil { + return merrors.InvalidArgument("messaging configuration is required", "messaging") + } + if strings.TrimSpace(string(cfg.Messaging.Driver)) == "" { + return merrors.InvalidArgument("messaging.driver is required", "messaging.driver") + } + if cfg.Delivery.MinDelay() > cfg.Delivery.MaxDelay() { + return merrors.InvalidArgument("delivery min delay must be <= max delay", "delivery.min_delay_ms", "delivery.max_delay_ms") + } + if cfg.Delivery.MaxAttempts < 1 { + return merrors.InvalidArgument("delivery.max_attempts must be > 0", "delivery.max_attempts") + } + if cfg.Ingest.BatchSize < 1 { + return merrors.InvalidArgument("ingest.batch_size must be > 0", "ingest.batch_size") + } + vaultAddress := strings.TrimSpace(cfg.Secrets.Vault.Address) + vaultTokenEnv := strings.TrimSpace(cfg.Secrets.Vault.TokenEnv) + vaultMountPath := strings.TrimSpace(cfg.Secrets.Vault.MountPath) + hasVault := vaultAddress != "" || vaultTokenEnv != "" || vaultMountPath != "" + if hasVault { + if vaultAddress == "" { + return merrors.InvalidArgument("secrets.vault.address is required when vault settings are configured", "secrets.vault.address") + } + if vaultTokenEnv == "" { + return merrors.InvalidArgument("secrets.vault.token_env is required when vault settings are configured", "secrets.vault.token_env") + } + if vaultMountPath == "" { + return merrors.InvalidArgument("secrets.vault.mount_path is required when vault settings are configured", "secrets.vault.mount_path") + } + } + + return nil +} diff --git a/api/edge/callbacks/internal/delivery/classifier.go b/api/edge/callbacks/internal/delivery/classifier.go new file mode 100644 index 00000000..9eed8db9 --- /dev/null +++ b/api/edge/callbacks/internal/delivery/classifier.go @@ -0,0 +1,27 @@ +package delivery + +import "net/http" + +type outcome string + +const ( + outcomeDelivered outcome = "delivered" + outcomeRetry outcome = "retry" + outcomeFailed outcome = "failed" +) + +func classify(statusCode int, reqErr error) outcome { + if reqErr != nil { + return outcomeRetry + } + if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices { + return outcomeDelivered + } + if statusCode == http.StatusTooManyRequests || statusCode == http.StatusRequestTimeout { + return outcomeRetry + } + if statusCode >= http.StatusInternalServerError { + return outcomeRetry + } + return outcomeFailed +} diff --git a/api/edge/callbacks/internal/delivery/module.go b/api/edge/callbacks/internal/delivery/module.go new file mode 100644 index 00000000..77c42b70 --- /dev/null +++ b/api/edge/callbacks/internal/delivery/module.go @@ -0,0 +1,48 @@ +package delivery + +import ( + "context" + "time" + + "github.com/tech/sendico/edge/callbacks/internal/retry" + "github.com/tech/sendico/edge/callbacks/internal/security" + "github.com/tech/sendico/edge/callbacks/internal/signing" + "github.com/tech/sendico/edge/callbacks/internal/storage" + "github.com/tech/sendico/pkg/mlogger" +) + +// Observer captures delivery metrics. +type Observer interface { + ObserveDelivery(result string, statusCode int, duration time.Duration) +} + +// Config controls delivery worker runtime. +type Config struct { + WorkerConcurrency int + WorkerPoll time.Duration + LockTTL time.Duration + RequestTimeout time.Duration + JitterRatio float64 +} + +// Dependencies configure delivery dispatcher. +type Dependencies struct { + Logger mlogger.Logger + Config Config + Tasks storage.TaskRepo + Retry retry.Policy + Security security.Validator + Signer signing.Signer + Observer Observer +} + +// Service executes callback delivery tasks. +type Service interface { + Start(ctx context.Context) + Stop() +} + +// New creates delivery service. +func New(deps Dependencies) (Service, error) { + return newService(deps) +} diff --git a/api/edge/callbacks/internal/delivery/service.go b/api/edge/callbacks/internal/delivery/service.go new file mode 100644 index 00000000..2b6f2322 --- /dev/null +++ b/api/edge/callbacks/internal/delivery/service.go @@ -0,0 +1,263 @@ +package delivery + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strconv" + "sync" + "time" + + "github.com/tech/sendico/edge/callbacks/internal/signing" + "github.com/tech/sendico/edge/callbacks/internal/storage" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +const responseDrainLimit = 64 * 1024 + +type service struct { + logger mlogger.Logger + cfg Config + tasks storage.TaskRepo + retry interface { + NextAttempt(attempt int, now time.Time, minDelay, maxDelay time.Duration, jitterRatio float64) time.Time + } + security interface { + ValidateURL(ctx context.Context, target string) error + } + signer signing.Signer + obs Observer + client *http.Client + + cancel context.CancelFunc + once sync.Once + stop sync.Once + wg sync.WaitGroup +} + +func newService(deps Dependencies) (Service, error) { + if deps.Tasks == nil { + return nil, merrors.InvalidArgument("delivery: task repo is required", "tasks") + } + if deps.Retry == nil { + return nil, merrors.InvalidArgument("delivery: retry policy is required", "retry") + } + if deps.Security == nil { + return nil, merrors.InvalidArgument("delivery: security validator is required", "security") + } + if deps.Signer == nil { + return nil, merrors.InvalidArgument("delivery: signer is required", "signer") + } + + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } + + cfg := deps.Config + if cfg.WorkerConcurrency <= 0 { + cfg.WorkerConcurrency = 1 + } + if cfg.WorkerPoll <= 0 { + cfg.WorkerPoll = 200 * time.Millisecond + } + if cfg.LockTTL <= 0 { + cfg.LockTTL = 30 * time.Second + } + if cfg.RequestTimeout <= 0 { + cfg.RequestTimeout = 10 * time.Second + } + + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 200, + MaxIdleConnsPerHost: 32, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + ExpectContinueTimeout: time.Second, + } + + client := &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + return &service{ + logger: logger.Named("delivery"), + cfg: cfg, + tasks: deps.Tasks, + retry: deps.Retry, + security: deps.Security, + signer: deps.Signer, + obs: deps.Observer, + client: client, + }, nil +} + +func (s *service) Start(ctx context.Context) { + s.once.Do(func() { + runCtx := ctx + if runCtx == nil { + runCtx = context.Background() + } + runCtx, s.cancel = context.WithCancel(runCtx) + + for i := 0; i < s.cfg.WorkerConcurrency; i++ { + workerID := "worker-" + strconv.Itoa(i+1) + s.wg.Add(1) + go func(id string) { + defer s.wg.Done() + s.runWorker(runCtx, id) + }(workerID) + } + s.logger.Info("Delivery workers started", zap.Int("workers", s.cfg.WorkerConcurrency)) + }) +} + +func (s *service) Stop() { + s.stop.Do(func() { + if s.cancel != nil { + s.cancel() + } + s.wg.Wait() + s.logger.Info("Delivery workers stopped") + }) +} + +func (s *service) runWorker(ctx context.Context, workerID string) { + for { + select { + case <-ctx.Done(): + return + default: + } + + now := time.Now().UTC() + task, err := s.tasks.LockNextTask(ctx, now, workerID, s.cfg.LockTTL) + if err != nil { + s.logger.Warn("Failed to lock next task", zap.String("worker_id", workerID), zap.Error(err)) + time.Sleep(s.cfg.WorkerPoll) + continue + } + if task == nil { + time.Sleep(s.cfg.WorkerPoll) + continue + } + + s.handleTask(ctx, workerID, task) + } +} + +func (s *service) handleTask(ctx context.Context, workerID string, task *storage.Task) { + started := time.Now() + statusCode := 0 + result := "failed" + attempt := task.Attempt + 1 + + defer func() { + if s.obs != nil { + s.obs.ObserveDelivery(result, statusCode, time.Since(started)) + } + }() + + if err := s.security.ValidateURL(ctx, task.EndpointURL); err != nil { + result = "blocked" + _ = s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC()) + return + } + + timeout := task.RequestTimeout + if timeout <= 0 { + timeout = s.cfg.RequestTimeout + } + + signed, err := s.signer.Sign(ctx, task.SigningMode, task.SecretRef, task.Payload, time.Now().UTC()) + if err != nil { + result = "sign_error" + _ = s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC()) + return + } + + reqCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, task.EndpointURL, bytes.NewReader(signed.Body)) + if err != nil { + result = "request_error" + _ = s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC()) + return + } + req.Header.Set("Content-Type", "application/json") + for key, val := range task.Headers { + req.Header.Set(key, val) + } + for key, val := range signed.Headers { + req.Header.Set(key, val) + } + + resp, reqErr := s.client.Do(req) + if resp != nil { + statusCode = resp.StatusCode + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, responseDrainLimit)) + _ = resp.Body.Close() + } + + out := classify(statusCode, reqErr) + now := time.Now().UTC() + switch out { + case outcomeDelivered: + result = string(outcomeDelivered) + if err := s.tasks.MarkDelivered(ctx, task.ID, statusCode, time.Since(started), now); err != nil { + s.logger.Warn("Failed to mark task delivered", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err)) + } + case outcomeRetry: + if attempt < task.MaxAttempts { + next := s.retry.NextAttempt(attempt, now, task.MinDelay, task.MaxDelay, s.cfg.JitterRatio) + result = string(outcomeRetry) + lastErr := stringifyErr(reqErr) + if reqErr == nil && statusCode > 0 { + lastErr = "upstream returned retryable status" + } + if err := s.tasks.MarkRetry(ctx, task.ID, attempt, next, lastErr, statusCode, now); err != nil { + s.logger.Warn("Failed to mark task retry", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err)) + } + } else { + result = string(outcomeFailed) + lastErr := stringifyErr(reqErr) + if reqErr == nil && statusCode > 0 { + lastErr = "upstream returned retryable status but max attempts reached" + } + if err := s.tasks.MarkFailed(ctx, task.ID, attempt, lastErr, statusCode, now); err != nil { + s.logger.Warn("Failed to mark task failed", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err)) + } + } + default: + result = string(outcomeFailed) + lastErr := stringifyErr(reqErr) + if reqErr == nil && statusCode > 0 { + lastErr = "upstream returned non-retryable status" + } + if err := s.tasks.MarkFailed(ctx, task.ID, attempt, lastErr, statusCode, now); err != nil { + s.logger.Warn("Failed to mark task failed", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err)) + } + } +} + +func stringifyErr(err error) string { + if err == nil { + return "" + } + if errors.Is(err, context.Canceled) { + return "request canceled" + } + if errors.Is(err, context.DeadlineExceeded) { + return "request timeout" + } + return err.Error() +} diff --git a/api/edge/callbacks/internal/events/module.go b/api/edge/callbacks/internal/events/module.go new file mode 100644 index 00000000..f38a52e0 --- /dev/null +++ b/api/edge/callbacks/internal/events/module.go @@ -0,0 +1,33 @@ +package events + +import ( + "context" + "encoding/json" + "time" +) + +// Envelope is the canonical incoming event envelope. +type Envelope struct { + EventID string `json:"event_id"` + Type string `json:"type"` + ClientID string `json:"client_id"` + OccurredAt time.Time `json:"occurred_at"` + PublishedAt time.Time `json:"published_at,omitempty"` + Data json.RawMessage `json:"data"` +} + +// Service parses incoming messages and builds outbound payload bytes. +type Service interface { + Parse(data []byte) (*Envelope, error) + BuildPayload(ctx context.Context, envelope *Envelope) ([]byte, error) +} + +// Payload is the stable outbound JSON body. +type Payload struct { + EventID string `json:"event_id"` + Type string `json:"type"` + ClientID string `json:"client_id"` + OccurredAt string `json:"occurred_at"` + PublishedAt string `json:"published_at,omitempty"` + Data json.RawMessage `json:"data"` +} diff --git a/api/edge/callbacks/internal/events/service.go b/api/edge/callbacks/internal/events/service.go new file mode 100644 index 00000000..d2bb4ca9 --- /dev/null +++ b/api/edge/callbacks/internal/events/service.go @@ -0,0 +1,86 @@ +package events + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type parserService struct { + logger mlogger.Logger +} + +// New creates event parser/payload builder service. +func New(logger mlogger.Logger) Service { + if logger == nil { + logger = zap.NewNop() + } + return &parserService{logger: logger.Named("events")} +} + +func (s *parserService) Parse(data []byte) (*Envelope, error) { + if len(data) == 0 { + return nil, merrors.InvalidArgument("event payload is empty", "data") + } + + var envelope Envelope + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, merrors.InvalidArgumentWrap(err, "event payload is not valid JSON", "data") + } + + if strings.TrimSpace(envelope.EventID) == "" { + return nil, merrors.InvalidArgument("event_id is required", "event_id") + } + if strings.TrimSpace(envelope.Type) == "" { + return nil, merrors.InvalidArgument("type is required", "type") + } + if strings.TrimSpace(envelope.ClientID) == "" { + return nil, merrors.InvalidArgument("client_id is required", "client_id") + } + if envelope.OccurredAt.IsZero() { + return nil, merrors.InvalidArgument("occurred_at is required", "occurred_at") + } + if len(envelope.Data) == 0 { + envelope.Data = []byte("{}") + } + + envelope.EventID = strings.TrimSpace(envelope.EventID) + envelope.Type = strings.TrimSpace(envelope.Type) + envelope.ClientID = strings.TrimSpace(envelope.ClientID) + envelope.OccurredAt = envelope.OccurredAt.UTC() + if !envelope.PublishedAt.IsZero() { + envelope.PublishedAt = envelope.PublishedAt.UTC() + } + + return &envelope, nil +} + +func (s *parserService) BuildPayload(_ context.Context, envelope *Envelope) ([]byte, error) { + if envelope == nil { + return nil, merrors.InvalidArgument("event envelope is required", "envelope") + } + + payload := Payload{ + EventID: envelope.EventID, + Type: envelope.Type, + ClientID: envelope.ClientID, + OccurredAt: envelope.OccurredAt.UTC().Format(time.RFC3339Nano), + Data: envelope.Data, + } + if !envelope.PublishedAt.IsZero() { + payload.PublishedAt = envelope.PublishedAt.UTC().Format(time.RFC3339Nano) + } + + data, err := json.Marshal(payload) + if err != nil { + s.logger.Warn("Failed to marshal callback payload", zap.Error(err), zap.String("event_id", envelope.EventID)) + return nil, merrors.InternalWrap(err, "failed to marshal callback payload") + } + + return data, nil +} diff --git a/api/edge/callbacks/internal/ingest/module.go b/api/edge/callbacks/internal/ingest/module.go new file mode 100644 index 00000000..4677c8fa --- /dev/null +++ b/api/edge/callbacks/internal/ingest/module.go @@ -0,0 +1,51 @@ +package ingest + +import ( + "context" + "time" + + "github.com/nats-io/nats.go" + "github.com/tech/sendico/edge/callbacks/internal/events" + "github.com/tech/sendico/edge/callbacks/internal/storage" + "github.com/tech/sendico/edge/callbacks/internal/subscriptions" + "github.com/tech/sendico/pkg/mlogger" +) + +// Observer captures ingest metrics. +type Observer interface { + ObserveIngest(result string, duration time.Duration) +} + +// Config contains JetStream ingest settings. +type Config struct { + Stream string + Subject string + Durable string + BatchSize int + FetchTimeout time.Duration + IdleSleep time.Duration +} + +// Dependencies configure the ingest service. +type Dependencies struct { + Logger mlogger.Logger + JetStream nats.JetStreamContext + Config Config + Events events.Service + Resolver subscriptions.Resolver + InboxRepo storage.InboxRepo + TaskRepo storage.TaskRepo + TaskDefaults storage.TaskDefaults + Observer Observer +} + +// Service runs JetStream ingest workers. +type Service interface { + Start(ctx context.Context) + Stop() +} + +// New creates ingest service. +func New(deps Dependencies) (Service, error) { + return newService(deps) +} diff --git a/api/edge/callbacks/internal/ingest/service.go b/api/edge/callbacks/internal/ingest/service.go new file mode 100644 index 00000000..a352c74d --- /dev/null +++ b/api/edge/callbacks/internal/ingest/service.go @@ -0,0 +1,204 @@ +package ingest + +import ( + "context" + "errors" + "strings" + "sync" + "time" + + "github.com/nats-io/nats.go" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type service struct { + logger mlogger.Logger + js nats.JetStreamContext + cfg Config + deps Dependencies + + cancel context.CancelFunc + wg sync.WaitGroup + once sync.Once + stop sync.Once +} + +func newService(deps Dependencies) (Service, error) { + if deps.JetStream == nil { + return nil, merrors.InvalidArgument("ingest: jetstream context is required", "jetstream") + } + if deps.Events == nil { + return nil, merrors.InvalidArgument("ingest: events service is required", "events") + } + if deps.Resolver == nil { + return nil, merrors.InvalidArgument("ingest: subscriptions resolver is required", "resolver") + } + if deps.InboxRepo == nil { + return nil, merrors.InvalidArgument("ingest: inbox repo is required", "inboxRepo") + } + if deps.TaskRepo == nil { + return nil, merrors.InvalidArgument("ingest: task repo is required", "taskRepo") + } + if strings.TrimSpace(deps.Config.Subject) == "" { + return nil, merrors.InvalidArgument("ingest: subject is required", "config.subject") + } + if strings.TrimSpace(deps.Config.Durable) == "" { + return nil, merrors.InvalidArgument("ingest: durable is required", "config.durable") + } + if deps.Config.BatchSize <= 0 { + deps.Config.BatchSize = 1 + } + if deps.Config.FetchTimeout <= 0 { + deps.Config.FetchTimeout = 2 * time.Second + } + if deps.Config.IdleSleep <= 0 { + deps.Config.IdleSleep = 500 * time.Millisecond + } + + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } + + return &service{ + logger: logger.Named("ingest"), + js: deps.JetStream, + cfg: deps.Config, + deps: deps, + }, nil +} + +func (s *service) Start(ctx context.Context) { + s.once.Do(func() { + runCtx := ctx + if runCtx == nil { + runCtx = context.Background() + } + runCtx, s.cancel = context.WithCancel(runCtx) + + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.run(runCtx) + }() + }) +} + +func (s *service) Stop() { + s.stop.Do(func() { + if s.cancel != nil { + s.cancel() + } + s.wg.Wait() + }) +} + +func (s *service) run(ctx context.Context) { + subOpts := []nats.SubOpt{} + if stream := strings.TrimSpace(s.cfg.Stream); stream != "" { + subOpts = append(subOpts, nats.BindStream(stream)) + } + + sub, err := s.js.PullSubscribe(strings.TrimSpace(s.cfg.Subject), strings.TrimSpace(s.cfg.Durable), subOpts...) + if err != nil { + s.logger.Error("Failed to start JetStream subscription", zap.String("subject", s.cfg.Subject), zap.String("durable", s.cfg.Durable), zap.Error(err)) + return + } + + s.logger.Info("Ingest consumer started", zap.String("subject", s.cfg.Subject), zap.String("durable", s.cfg.Durable), zap.Int("batch_size", s.cfg.BatchSize)) + + for { + select { + case <-ctx.Done(): + s.logger.Info("Ingest consumer stopped") + return + default: + } + + msgs, err := sub.Fetch(s.cfg.BatchSize, nats.MaxWait(s.cfg.FetchTimeout)) + if err != nil { + if errors.Is(err, nats.ErrTimeout) { + time.Sleep(s.cfg.IdleSleep) + continue + } + if ctx.Err() != nil { + return + } + s.logger.Warn("Failed to fetch JetStream messages", zap.Error(err)) + time.Sleep(s.cfg.IdleSleep) + continue + } + + for _, msg := range msgs { + s.handleMessage(ctx, msg) + } + } +} + +func (s *service) handleMessage(ctx context.Context, msg *nats.Msg) { + start := time.Now() + result := "ok" + nak := false + + defer func() { + if s.deps.Observer != nil { + s.deps.Observer.ObserveIngest(result, time.Since(start)) + } + + var ackErr error + if nak { + ackErr = msg.Nak() + } else { + ackErr = msg.Ack() + } + if ackErr != nil { + s.logger.Warn("Failed to ack ingest message", zap.Bool("nak", nak), zap.Error(ackErr)) + } + }() + + envelope, err := s.deps.Events.Parse(msg.Data) + if err != nil { + result = "invalid_event" + nak = false + return + } + + inserted, err := s.deps.InboxRepo.TryInsert(ctx, envelope.EventID, envelope.ClientID, envelope.Type, time.Now().UTC()) + if err != nil { + result = "inbox_error" + nak = true + return + } + if !inserted { + result = "duplicate" + nak = false + return + } + + endpoints, err := s.deps.Resolver.Resolve(ctx, envelope.ClientID, envelope.Type) + if err != nil { + result = "resolve_error" + nak = true + return + } + if len(endpoints) == 0 { + result = "no_endpoints" + nak = false + return + } + + payload, err := s.deps.Events.BuildPayload(ctx, envelope) + if err != nil { + result = "payload_error" + nak = true + return + } + + if err := s.deps.TaskRepo.UpsertTasks(ctx, envelope.EventID, endpoints, payload, s.deps.TaskDefaults, time.Now().UTC()); err != nil { + result = "task_error" + nak = true + return + } +} diff --git a/api/edge/callbacks/internal/ops/module.go b/api/edge/callbacks/internal/ops/module.go new file mode 100644 index 00000000..c5cf1115 --- /dev/null +++ b/api/edge/callbacks/internal/ops/module.go @@ -0,0 +1,36 @@ +package ops + +import ( + "context" + "time" + + "github.com/tech/sendico/pkg/api/routers/health" + "github.com/tech/sendico/pkg/mlogger" +) + +// Observer records service metrics. +type Observer interface { + ObserveIngest(result string, duration time.Duration) + ObserveDelivery(result string, statusCode int, duration time.Duration) +} + +// HTTPServer exposes /metrics and /health. +type HTTPServer interface { + SetStatus(status health.ServiceStatus) + Close(ctx context.Context) +} + +// HTTPServerConfig configures observability endpoint. +type HTTPServerConfig struct { + Address string +} + +// NewObserver creates process metrics observer. +func NewObserver() Observer { + return newObserver() +} + +// NewHTTPServer creates observability HTTP server. +func NewHTTPServer(logger mlogger.Logger, cfg HTTPServerConfig) (HTTPServer, error) { + return newHTTPServer(logger, cfg) +} diff --git a/api/edge/callbacks/internal/ops/server.go b/api/edge/callbacks/internal/ops/server.go new file mode 100644 index 00000000..d834a17f --- /dev/null +++ b/api/edge/callbacks/internal/ops/server.go @@ -0,0 +1,119 @@ +package ops + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/api/routers/health" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +const ( + defaultAddress = ":9420" + readHeaderTimeout = 5 * time.Second + defaultShutdownWindow = 5 * time.Second +) + +type httpServer struct { + logger mlogger.Logger + server *http.Server + health routers.Health + timeout time.Duration +} + +func newHTTPServer(logger mlogger.Logger, cfg HTTPServerConfig) (HTTPServer, error) { + if logger == nil { + return nil, merrors.InvalidArgument("ops: logger is nil") + } + + address := strings.TrimSpace(cfg.Address) + if address == "" { + address = defaultAddress + } + + r := chi.NewRouter() + r.Handle("/metrics", promhttp.Handler()) + + metricsLogger := logger.Named("ops") + var healthRouter routers.Health + hr, err := routers.NewHealthRouter(metricsLogger, r, "") + if err != nil { + metricsLogger.Warn("Failed to initialise health router", zap.Error(err)) + } else { + hr.SetStatus(health.SSStarting) + healthRouter = hr + } + + httpSrv := &http.Server{ + Addr: address, + Handler: r, + ReadHeaderTimeout: readHeaderTimeout, + } + + wrapper := &httpServer{ + logger: metricsLogger, + server: httpSrv, + health: healthRouter, + timeout: defaultShutdownWindow, + } + + go func() { + metricsLogger.Info("Prometheus endpoint listening", zap.String("address", address)) + serveErr := httpSrv.ListenAndServe() + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + metricsLogger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(serveErr)) + if healthRouter != nil { + healthRouter.SetStatus(health.SSTerminating) + } + } + }() + + return wrapper, nil +} + +func (s *httpServer) SetStatus(status health.ServiceStatus) { + if s == nil || s.health == nil { + return + } + s.health.SetStatus(status) +} + +func (s *httpServer) Close(ctx context.Context) { + if s == nil { + return + } + + if s.health != nil { + s.health.SetStatus(health.SSTerminating) + s.health.Finish() + s.health = nil + } + + if s.server == nil { + return + } + + shutdownCtx := ctx + if shutdownCtx == nil { + shutdownCtx = context.Background() + } + if s.timeout > 0 { + var cancel context.CancelFunc + shutdownCtx, cancel = context.WithTimeout(shutdownCtx, s.timeout) + defer cancel() + } + + if err := s.server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Warn("Failed to stop metrics server", zap.Error(err)) + } else { + s.logger.Info("Metrics server stopped") + } +} diff --git a/api/edge/callbacks/internal/ops/service.go b/api/edge/callbacks/internal/ops/service.go new file mode 100644 index 00000000..3bf0b607 --- /dev/null +++ b/api/edge/callbacks/internal/ops/service.go @@ -0,0 +1,75 @@ +package ops + +import ( + "strconv" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + metricsOnce sync.Once + ingestTotal *prometheus.CounterVec + ingestLatency *prometheus.HistogramVec + deliveryTotal *prometheus.CounterVec + deliveryLatency *prometheus.HistogramVec +) + +type observer struct{} + +func newObserver() Observer { + initMetrics() + return observer{} +} + +func initMetrics() { + metricsOnce.Do(func() { + ingestTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "callbacks", + Name: "ingest_total", + Help: "Total ingest attempts by result", + }, []string{"result"}) + + ingestLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "sendico", + Subsystem: "callbacks", + Name: "ingest_duration_seconds", + Help: "Ingest latency in seconds", + Buckets: prometheus.DefBuckets, + }, []string{"result"}) + + deliveryTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "callbacks", + Name: "delivery_total", + Help: "Total delivery attempts by result and status code", + }, []string{"result", "status_code"}) + + deliveryLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "sendico", + Subsystem: "callbacks", + Name: "delivery_duration_seconds", + Help: "Delivery latency in seconds", + Buckets: prometheus.DefBuckets, + }, []string{"result"}) + }) +} + +func (observer) ObserveIngest(result string, duration time.Duration) { + if result == "" { + result = "unknown" + } + ingestTotal.WithLabelValues(result).Inc() + ingestLatency.WithLabelValues(result).Observe(duration.Seconds()) +} + +func (observer) ObserveDelivery(result string, statusCode int, duration time.Duration) { + if result == "" { + result = "unknown" + } + deliveryTotal.WithLabelValues(result, strconv.Itoa(statusCode)).Inc() + deliveryLatency.WithLabelValues(result).Observe(duration.Seconds()) +} diff --git a/api/edge/callbacks/internal/retry/module.go b/api/edge/callbacks/internal/retry/module.go new file mode 100644 index 00000000..40807f92 --- /dev/null +++ b/api/edge/callbacks/internal/retry/module.go @@ -0,0 +1,8 @@ +package retry + +import "time" + +// Policy computes retry schedules. +type Policy interface { + NextAttempt(attempt int, now time.Time, minDelay, maxDelay time.Duration, jitterRatio float64) time.Time +} diff --git a/api/edge/callbacks/internal/retry/service.go b/api/edge/callbacks/internal/retry/service.go new file mode 100644 index 00000000..5c60c8ba --- /dev/null +++ b/api/edge/callbacks/internal/retry/service.go @@ -0,0 +1,59 @@ +package retry + +import ( + "math" + "math/rand" + "sync" + "time" +) + +type service struct { + mu sync.Mutex + rnd *rand.Rand +} + +// New creates retry policy service. +func New() Policy { + return &service{rnd: rand.New(rand.NewSource(time.Now().UnixNano()))} +} + +func (s *service) NextAttempt(attempt int, now time.Time, minDelay, maxDelay time.Duration, jitterRatio float64) time.Time { + if attempt < 1 { + attempt = 1 + } + if minDelay <= 0 { + minDelay = time.Second + } + if maxDelay < minDelay { + maxDelay = minDelay + } + + base := float64(minDelay) + delay := time.Duration(base * math.Pow(2, float64(attempt-1))) + if delay > maxDelay { + delay = maxDelay + } + + if jitterRatio > 0 { + if jitterRatio > 1 { + jitterRatio = 1 + } + maxJitter := int64(float64(delay) * jitterRatio) + if maxJitter > 0 { + s.mu.Lock() + jitter := s.rnd.Int63n((maxJitter * 2) + 1) + s.mu.Unlock() + delta := jitter - maxJitter + delay += time.Duration(delta) + } + } + + if delay < minDelay { + delay = minDelay + } + if delay > maxDelay { + delay = maxDelay + } + + return now.UTC().Add(delay) +} diff --git a/api/edge/callbacks/internal/secrets/module.go b/api/edge/callbacks/internal/secrets/module.go new file mode 100644 index 00000000..9aafdd25 --- /dev/null +++ b/api/edge/callbacks/internal/secrets/module.go @@ -0,0 +1,33 @@ +package secrets + +import ( + "context" + "time" + + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/vault/kv" +) + +// Provider resolves secrets by reference. +type Provider interface { + GetSecret(ctx context.Context, ref string) (string, error) +} + +// VaultOptions configure Vault KV secret resolution. +type VaultOptions struct { + Config kv.Config + DefaultField string +} + +// Options configure secret lookup behavior. +type Options struct { + Logger mlogger.Logger + Static map[string]string + CacheTTL time.Duration + Vault VaultOptions +} + +// New creates secrets provider. +func New(opts Options) (Provider, error) { + return newProvider(opts) +} diff --git a/api/edge/callbacks/internal/secrets/service.go b/api/edge/callbacks/internal/secrets/service.go new file mode 100644 index 00000000..61df6ac9 --- /dev/null +++ b/api/edge/callbacks/internal/secrets/service.go @@ -0,0 +1,224 @@ +package secrets + +import ( + "context" + "os" + "strings" + "sync" + "time" + + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/vault/kv" + "go.uber.org/zap" +) + +const ( + defaultVaultField = "value" + vaultRefPrefix = "vault:" +) + +type cacheEntry struct { + value string + expiresAt time.Time +} + +type provider struct { + logger mlogger.Logger + static map[string]string + ttl time.Duration + vault kv.Client + vaultEnabled bool + vaultDefField string + + mu sync.RWMutex + cache map[string]cacheEntry +} + +func newProvider(opts Options) (Provider, error) { + logger := opts.Logger + if logger == nil { + logger = zap.NewNop() + } + + static := map[string]string{} + for k, v := range opts.Static { + key := strings.TrimSpace(k) + if key == "" { + continue + } + static[key] = v + } + + vaultField := strings.TrimSpace(opts.Vault.DefaultField) + if vaultField == "" { + vaultField = defaultVaultField + } + + var vaultClient kv.Client + vaultEnabled := false + hasVaultConfig := strings.TrimSpace(opts.Vault.Config.Address) != "" || + strings.TrimSpace(opts.Vault.Config.TokenEnv) != "" || + strings.TrimSpace(opts.Vault.Config.MountPath) != "" + if hasVaultConfig { + client, err := kv.New(kv.Options{ + Logger: logger.Named("vault"), + Config: opts.Vault.Config, + Component: "callbacks secrets", + }) + if err != nil { + return nil, err + } + vaultClient = client + vaultEnabled = true + } + + return &provider{ + logger: logger.Named("secrets"), + static: static, + ttl: opts.CacheTTL, + vault: vaultClient, + vaultEnabled: vaultEnabled, + vaultDefField: vaultField, + cache: map[string]cacheEntry{}, + }, nil +} + +func (p *provider) GetSecret(ctx context.Context, ref string) (string, error) { + key := strings.TrimSpace(ref) + if key == "" { + return "", merrors.InvalidArgument("secret reference is required", "secret_ref") + } + if ctx == nil { + ctx = context.Background() + } + + if value, ok := p.fromCache(key); ok { + return value, nil + } + + value, err := p.resolve(ctx, key) + if err != nil { + return "", err + } + if strings.TrimSpace(value) == "" { + return "", merrors.NoData("secret reference resolved to empty value") + } + + p.toCache(key, value) + return value, nil +} + +func (p *provider) resolve(ctx context.Context, key string) (string, error) { + if value, ok := p.static[key]; ok { + return value, nil + } + if strings.HasPrefix(key, "env:") { + envKey := strings.TrimSpace(strings.TrimPrefix(key, "env:")) + if envKey == "" { + return "", merrors.InvalidArgument("secret env reference is invalid", "secret_ref") + } + value := strings.TrimSpace(os.Getenv(envKey)) + if value == "" { + return "", merrors.NoData("secret env variable not set: " + envKey) + } + return value, nil + } + + if strings.HasPrefix(strings.ToLower(key), vaultRefPrefix) && !p.vaultEnabled { + return "", merrors.InvalidArgument("vault secret reference provided but vault is not configured", "secret_ref") + } + if p.vaultEnabled { + value, resolved, err := p.resolveVault(ctx, key) + if err != nil { + return "", err + } + if resolved { + return value, nil + } + } + + return "", merrors.NoData("secret reference not found: " + key) +} + +func (p *provider) resolveVault(ctx context.Context, ref string) (string, bool, error) { + path, field, resolved, err := parseVaultRef(ref, p.vaultDefField) + if err != nil { + return "", false, err + } + if !resolved { + return "", false, nil + } + + value, err := p.vault.GetString(ctx, path, field) + if err != nil { + p.logger.Warn("Failed to resolve vault secret", zap.String("path", path), zap.String("field", field), zap.Error(err)) + return "", true, err + } + return value, true, nil +} + +func parseVaultRef(ref, defaultField string) (string, string, bool, error) { + raw := strings.TrimSpace(ref) + lowered := strings.ToLower(raw) + explicit := false + if strings.HasPrefix(lowered, vaultRefPrefix) { + explicit = true + raw = strings.TrimSpace(raw[len(vaultRefPrefix):]) + } + + if !explicit && !strings.Contains(raw, "/") && !strings.Contains(raw, "#") { + return "", "", false, nil + } + + field := strings.TrimSpace(defaultField) + if field == "" { + field = defaultVaultField + } + + if idx := strings.Index(raw, "#"); idx >= 0 { + field = strings.TrimSpace(raw[idx+1:]) + raw = strings.TrimSpace(raw[:idx]) + if field == "" { + return "", "", false, merrors.InvalidArgument("vault secret field is required", "secret_ref") + } + } + + path := strings.Trim(strings.TrimSpace(raw), "/") + if path == "" { + return "", "", false, merrors.InvalidArgument("vault secret path is required", "secret_ref") + } + + return path, field, true, nil +} + +func (p *provider) fromCache(key string) (string, bool) { + if p.ttl <= 0 { + return "", false + } + p.mu.RLock() + entry, ok := p.cache[key] + p.mu.RUnlock() + if !ok { + return "", false + } + if time.Now().After(entry.expiresAt) { + p.mu.Lock() + delete(p.cache, key) + p.mu.Unlock() + return "", false + } + return entry.value, true +} + +func (p *provider) toCache(key, value string) { + if p.ttl <= 0 { + return + } + p.mu.Lock() + p.cache[key] = cacheEntry{ + value: value, + expiresAt: time.Now().Add(p.ttl), + } + p.mu.Unlock() +} diff --git a/api/edge/callbacks/internal/security/module.go b/api/edge/callbacks/internal/security/module.go new file mode 100644 index 00000000..b42a72d0 --- /dev/null +++ b/api/edge/callbacks/internal/security/module.go @@ -0,0 +1,16 @@ +package security + +import "context" + +// Config controls URL validation and SSRF checks. +type Config struct { + RequireHTTPS bool + AllowedHosts []string + AllowedPorts []int + DNSResolveTimeout int +} + +// Validator validates outbound callback URLs. +type Validator interface { + ValidateURL(ctx context.Context, target string) error +} diff --git a/api/edge/callbacks/internal/security/service.go b/api/edge/callbacks/internal/security/service.go new file mode 100644 index 00000000..fd33b95c --- /dev/null +++ b/api/edge/callbacks/internal/security/service.go @@ -0,0 +1,163 @@ +package security + +import ( + "context" + "net" + "net/netip" + "net/url" + "strconv" + "strings" + "time" + + "github.com/tech/sendico/pkg/merrors" +) + +type service struct { + requireHTTPS bool + allowedHosts map[string]struct{} + allowedPorts map[int]struct{} + dnsTimeout time.Duration + resolver *net.Resolver +} + +// New creates URL validator. +func New(cfg Config) Validator { + hosts := make(map[string]struct{}, len(cfg.AllowedHosts)) + for _, host := range cfg.AllowedHosts { + h := strings.ToLower(strings.TrimSpace(host)) + if h == "" { + continue + } + hosts[h] = struct{}{} + } + ports := make(map[int]struct{}, len(cfg.AllowedPorts)) + for _, port := range cfg.AllowedPorts { + if port > 0 { + ports[port] = struct{}{} + } + } + + timeout := time.Duration(cfg.DNSResolveTimeout) * time.Millisecond + if timeout <= 0 { + timeout = 2 * time.Second + } + + return &service{ + requireHTTPS: cfg.RequireHTTPS, + allowedHosts: hosts, + allowedPorts: ports, + dnsTimeout: timeout, + resolver: net.DefaultResolver, + } +} + +func (s *service) ValidateURL(ctx context.Context, target string) error { + parsed, err := url.Parse(strings.TrimSpace(target)) + if err != nil { + return merrors.InvalidArgumentWrap(err, "invalid callback URL", "url") + } + if parsed == nil || parsed.Host == "" { + return merrors.InvalidArgument("callback URL host is required", "url") + } + if parsed.User != nil { + return merrors.InvalidArgument("callback URL credentials are not allowed", "url") + } + if s.requireHTTPS && !strings.EqualFold(parsed.Scheme, "https") { + return merrors.InvalidArgument("callback URL must use HTTPS", "url") + } + + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + if host == "" { + return merrors.InvalidArgument("callback URL host is empty", "url") + } + if len(s.allowedHosts) > 0 { + if _, ok := s.allowedHosts[host]; !ok { + return merrors.InvalidArgument("callback host is not in allowlist", "url.host") + } + } + + port, err := resolvePort(parsed) + if err != nil { + return err + } + if len(s.allowedPorts) > 0 { + if _, ok := s.allowedPorts[port]; !ok { + return merrors.InvalidArgument("callback URL port is not allowed", "url.port") + } + } + + if addr, addrErr := netip.ParseAddr(host); addrErr == nil { + if isBlocked(addr) { + return merrors.InvalidArgument("callback URL resolves to blocked IP range", "url") + } + return nil + } + + lookupCtx := ctx + if lookupCtx == nil { + lookupCtx = context.Background() + } + lookupCtx, cancel := context.WithTimeout(lookupCtx, s.dnsTimeout) + defer cancel() + + ips, err := s.resolver.LookupIPAddr(lookupCtx, host) + if err != nil { + return merrors.InternalWrap(err, "failed to resolve callback host") + } + if len(ips) == 0 { + return merrors.InvalidArgument("callback host did not resolve", "url.host") + } + for _, ip := range ips { + if ip.IP == nil { + continue + } + addr, ok := netip.AddrFromSlice(ip.IP) + if ok && isBlocked(addr) { + return merrors.InvalidArgument("callback URL resolves to blocked IP range", "url.host") + } + } + + return nil +} + +func resolvePort(parsed *url.URL) (int, error) { + if parsed == nil { + return 0, merrors.InvalidArgument("callback URL is required", "url") + } + portStr := strings.TrimSpace(parsed.Port()) + if portStr == "" { + if strings.EqualFold(parsed.Scheme, "https") { + return 443, nil + } + if strings.EqualFold(parsed.Scheme, "http") { + return 80, nil + } + return 0, merrors.InvalidArgument("callback URL scheme is not supported", "url.scheme") + } + + port, err := strconv.Atoi(portStr) + if err != nil || port <= 0 || port > 65535 { + return 0, merrors.InvalidArgument("callback URL port is invalid", "url.port") + } + + return port, nil +} + +func isBlocked(ip netip.Addr) bool { + if !ip.IsValid() { + return true + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsMulticast() || ip.IsUnspecified() { + return true + } + if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + + // Block common cloud metadata endpoint. + if ip.Is4() && ip.String() == "169.254.169.254" { + return true + } + + return false +} diff --git a/api/edge/callbacks/internal/server/internal/serverimp.go b/api/edge/callbacks/internal/server/internal/serverimp.go new file mode 100644 index 00000000..2c3ec752 --- /dev/null +++ b/api/edge/callbacks/internal/server/internal/serverimp.go @@ -0,0 +1,271 @@ +package serverimp + +import ( + "context" + "time" + + "github.com/nats-io/nats.go" + "github.com/tech/sendico/edge/callbacks/internal/config" + "github.com/tech/sendico/edge/callbacks/internal/delivery" + "github.com/tech/sendico/edge/callbacks/internal/events" + "github.com/tech/sendico/edge/callbacks/internal/ingest" + "github.com/tech/sendico/edge/callbacks/internal/ops" + "github.com/tech/sendico/edge/callbacks/internal/retry" + "github.com/tech/sendico/edge/callbacks/internal/secrets" + "github.com/tech/sendico/edge/callbacks/internal/security" + "github.com/tech/sendico/edge/callbacks/internal/signing" + "github.com/tech/sendico/edge/callbacks/internal/storage" + "github.com/tech/sendico/edge/callbacks/internal/subscriptions" + "github.com/tech/sendico/pkg/api/routers/health" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/vault/kv" + "go.uber.org/zap" +) + +const defaultShutdownTimeout = 15 * time.Second + +type jetStreamProvider interface { + JetStream() nats.JetStreamContext +} + +func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { + return &Imp{ + logger: logger.Named("server"), + file: file, + debug: debug, + }, nil +} + +func (i *Imp) Start() error { + i.initStopChannels() + defer i.closeDone() + + loader := config.New(i.logger) + cfg, err := loader.Load(i.file) + if err != nil { + return err + } + i.config = cfg + + observer := ops.NewObserver() + metricsSrv, err := ops.NewHTTPServer(i.logger, ops.HTTPServerConfig{Address: cfg.Metrics.ListenAddress()}) + if err != nil { + return err + } + i.opServer = metricsSrv + i.opServer.SetStatus(health.SSStarting) + + conn, err := db.ConnectMongo(i.logger.Named("mongo"), cfg.Database) + if err != nil { + i.shutdownRuntime(context.Background()) + return err + } + i.mongoConn = conn + + repo, err := storage.New(i.logger, conn) + if err != nil { + i.shutdownRuntime(context.Background()) + return err + } + + resolver, err := subscriptions.New(subscriptions.Dependencies{EndpointRepo: repo.Endpoints()}) + if err != nil { + i.shutdownRuntime(context.Background()) + return err + } + + securityValidator := security.New(security.Config{ + RequireHTTPS: cfg.Security.RequireHTTPS, + AllowedHosts: cfg.Security.AllowedHosts, + AllowedPorts: cfg.Security.AllowedPorts, + DNSResolveTimeout: int(cfg.Security.DNSResolveTimeoutMS() / time.Millisecond), + }) + + secretProvider, err := secrets.New(secrets.Options{ + Logger: i.logger, + Static: cfg.Secrets.Static, + CacheTTL: cfg.Secrets.CacheTTL(), + Vault: secrets.VaultOptions{ + Config: kv.Config{ + Address: cfg.Secrets.Vault.Address, + TokenEnv: cfg.Secrets.Vault.TokenEnv, + Namespace: cfg.Secrets.Vault.Namespace, + MountPath: cfg.Secrets.Vault.MountPath, + }, + DefaultField: cfg.Secrets.Vault.DefaultField, + }, + }) + if err != nil { + i.shutdownRuntime(context.Background()) + return err + } + signer, err := signing.New(signing.Dependencies{Logger: i.logger, Provider: secretProvider}) + if err != nil { + i.shutdownRuntime(context.Background()) + return err + } + + retryPolicy := retry.New() + eventSvc := events.New(i.logger) + + broker, err := msg.CreateMessagingBroker(i.logger.Named("messaging"), cfg.Messaging) + if err != nil { + i.shutdownRuntime(context.Background()) + return err + } + i.broker = broker + + jsProvider, ok := broker.(jetStreamProvider) + if !ok || jsProvider.JetStream() == nil { + i.shutdownRuntime(context.Background()) + return merrors.Internal("callbacks: messaging broker does not provide JetStream") + } + + ingestSvc, err := ingest.New(ingest.Dependencies{ + Logger: i.logger, + JetStream: jsProvider.JetStream(), + Config: ingest.Config{ + Stream: cfg.Ingest.Stream, + Subject: cfg.Ingest.Subject, + Durable: cfg.Ingest.Durable, + BatchSize: cfg.Ingest.BatchSize, + FetchTimeout: cfg.Ingest.FetchTimeout(), + IdleSleep: cfg.Ingest.IdleSleep(), + }, + Events: eventSvc, + Resolver: resolver, + InboxRepo: repo.Inbox(), + TaskRepo: repo.Tasks(), + TaskDefaults: deliveryTaskDefaults(cfg), + Observer: observer, + }) + if err != nil { + i.shutdownRuntime(context.Background()) + return err + } + i.ingest = ingestSvc + + deliverySvc, err := delivery.New(delivery.Dependencies{ + Logger: i.logger, + Config: delivery.Config{ + WorkerConcurrency: cfg.Delivery.WorkerConcurrency, + WorkerPoll: cfg.Delivery.WorkerPollInterval(), + LockTTL: cfg.Delivery.LockTTL(), + RequestTimeout: cfg.Delivery.RequestTimeout(), + JitterRatio: cfg.Delivery.JitterRatio, + }, + Tasks: repo.Tasks(), + Retry: retryPolicy, + Security: securityValidator, + Signer: signer, + Observer: observer, + }) + if err != nil { + i.shutdownRuntime(context.Background()) + return err + } + i.delivery = deliverySvc + + runCtx, cancel := context.WithCancel(context.Background()) + i.runCancel = cancel + i.ingest.Start(runCtx) + i.delivery.Start(runCtx) + i.opServer.SetStatus(health.SSRunning) + + i.logger.Info("Callbacks service ready", + zap.String("subject", cfg.Ingest.Subject), + zap.String("stream", cfg.Ingest.Stream), + zap.Int("workers", cfg.Delivery.WorkerConcurrency), + ) + + <-i.stopCh + i.logger.Info("Callbacks service stop signal received") + i.shutdownRuntime(context.Background()) + + return nil +} + +func (i *Imp) Shutdown() { + i.signalStop() + if i.doneCh != nil { + <-i.doneCh + } +} + +func (i *Imp) initStopChannels() { + if i.stopCh == nil { + i.stopCh = make(chan struct{}) + } + if i.doneCh == nil { + i.doneCh = make(chan struct{}) + } +} + +func (i *Imp) signalStop() { + i.stopOnce.Do(func() { + if i.stopCh != nil { + close(i.stopCh) + } + }) +} + +func (i *Imp) closeDone() { + i.doneOnce.Do(func() { + if i.doneCh != nil { + close(i.doneCh) + } + }) +} + +func (i *Imp) shutdownRuntime(ctx context.Context) { + i.shutdown.Do(func() { + if i.opServer != nil { + i.opServer.SetStatus(health.SSTerminating) + } + if i.runCancel != nil { + i.runCancel() + } + if i.ingest != nil { + i.ingest.Stop() + } + if i.delivery != nil { + i.delivery.Stop() + } + if i.opServer != nil { + i.opServer.Close(ctx) + i.opServer = nil + } + + if i.mongoConn != nil { + timeout := i.shutdownTimeout() + shutdownCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if err := i.mongoConn.Disconnect(shutdownCtx); err != nil { + i.logger.Warn("Failed to close MongoDB connection", zap.Error(err)) + } + i.mongoConn = nil + } + }) +} + +func (i *Imp) shutdownTimeout() time.Duration { + if i.config != nil && i.config.Runtime != nil { + return i.config.Runtime.ShutdownTimeout() + } + return defaultShutdownTimeout +} + +func deliveryTaskDefaults(cfg *config.Config) storage.TaskDefaults { + if cfg == nil { + return storage.TaskDefaults{} + } + return storage.TaskDefaults{ + MaxAttempts: cfg.Delivery.MaxAttempts, + MinDelay: cfg.Delivery.MinDelay(), + MaxDelay: cfg.Delivery.MaxDelay(), + RequestTimeout: cfg.Delivery.RequestTimeout(), + } +} diff --git a/api/edge/callbacks/internal/server/internal/types.go b/api/edge/callbacks/internal/server/internal/types.go new file mode 100644 index 00000000..c31326f6 --- /dev/null +++ b/api/edge/callbacks/internal/server/internal/types.go @@ -0,0 +1,37 @@ +package serverimp + +import ( + "context" + "sync" + + "github.com/tech/sendico/edge/callbacks/internal/config" + "github.com/tech/sendico/edge/callbacks/internal/delivery" + "github.com/tech/sendico/edge/callbacks/internal/ingest" + "github.com/tech/sendico/edge/callbacks/internal/ops" + "github.com/tech/sendico/pkg/db" + mb "github.com/tech/sendico/pkg/messaging/broker" + "github.com/tech/sendico/pkg/mlogger" +) + +type Imp struct { + logger mlogger.Logger + file string + debug bool + + config *config.Config + + mongoConn *db.MongoConnection + broker mb.Broker + + ingest ingest.Service + delivery delivery.Service + + opServer ops.HTTPServer + + runCancel context.CancelFunc + shutdown sync.Once + stopOnce sync.Once + doneOnce sync.Once + stopCh chan struct{} + doneCh chan struct{} +} diff --git a/api/edge/callbacks/internal/server/server.go b/api/edge/callbacks/internal/server/server.go new file mode 100644 index 00000000..305d3519 --- /dev/null +++ b/api/edge/callbacks/internal/server/server.go @@ -0,0 +1,11 @@ +package server + +import ( + serverimp "github.com/tech/sendico/edge/callbacks/internal/server/internal" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" +) + +func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return serverimp.Create(logger, file, debug) +} diff --git a/api/edge/callbacks/internal/signing/module.go b/api/edge/callbacks/internal/signing/module.go new file mode 100644 index 00000000..0e3ae93a --- /dev/null +++ b/api/edge/callbacks/internal/signing/module.go @@ -0,0 +1,36 @@ +package signing + +import ( + "context" + "time" + + "github.com/tech/sendico/edge/callbacks/internal/secrets" + "github.com/tech/sendico/pkg/mlogger" +) + +const ( + ModeNone = "none" + ModeHMACSHA256 = "hmac_sha256" +) + +// SignedPayload is what gets sent over HTTP. +type SignedPayload struct { + Body []byte + Headers map[string]string +} + +// Signer signs callback payloads. +type Signer interface { + Sign(ctx context.Context, mode, secretRef string, payload []byte, now time.Time) (*SignedPayload, error) +} + +// Dependencies configures signer service. +type Dependencies struct { + Logger mlogger.Logger + Provider secrets.Provider +} + +// New creates signer service. +func New(deps Dependencies) (Signer, error) { + return newService(deps) +} diff --git a/api/edge/callbacks/internal/signing/service.go b/api/edge/callbacks/internal/signing/service.go new file mode 100644 index 00000000..e12ba87d --- /dev/null +++ b/api/edge/callbacks/internal/signing/service.go @@ -0,0 +1,80 @@ +package signing + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strconv" + "strings" + "time" + + "github.com/tech/sendico/edge/callbacks/internal/secrets" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type service struct { + logger mlogger.Logger + provider secrets.Provider +} + +func newService(deps Dependencies) (Signer, error) { + if deps.Provider == nil { + return nil, merrors.InvalidArgument("signing: secrets provider is required", "provider") + } + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } + + return &service{ + logger: logger.Named("signing"), + provider: deps.Provider, + }, nil +} + +func (s *service) Sign(ctx context.Context, mode, secretRef string, payload []byte, now time.Time) (*SignedPayload, error) { + normalizedMode := strings.ToLower(strings.TrimSpace(mode)) + if normalizedMode == "" { + normalizedMode = ModeNone + } + + switch normalizedMode { + case ModeNone: + return &SignedPayload{ + Body: append([]byte(nil), payload...), + Headers: map[string]string{}, + }, nil + case ModeHMACSHA256: + if strings.TrimSpace(secretRef) == "" { + return nil, merrors.InvalidArgument("signing: secret reference is required for hmac", "secret_ref") + } + secret, err := s.provider.GetSecret(ctx, secretRef) + if err != nil { + s.logger.Warn("Failed to load signing secret", zap.String("secret_ref", secretRef), zap.Error(err)) + return nil, err + } + + ts := now.UTC().Format(time.RFC3339Nano) + mac := hmac.New(sha256.New, []byte(secret)) + message := append([]byte(ts+"."), payload...) + if _, err := mac.Write(message); err != nil { + return nil, merrors.InternalWrap(err, "signing: failed to compute hmac") + } + signature := hex.EncodeToString(mac.Sum(nil)) + + return &SignedPayload{ + Body: append([]byte(nil), payload...), + Headers: map[string]string{ + "X-Callback-Timestamp": ts, + "X-Callback-Signature": signature, + "X-Callback-Algorithm": "hmac-sha256", + "Content-Length": strconv.Itoa(len(payload)), + }, + }, nil + default: + return nil, merrors.InvalidArgument("signing: unsupported mode", "mode") + } +} diff --git a/api/edge/callbacks/internal/storage/module.go b/api/edge/callbacks/internal/storage/module.go new file mode 100644 index 00000000..c0648161 --- /dev/null +++ b/api/edge/callbacks/internal/storage/module.go @@ -0,0 +1,99 @@ +package storage + +import ( + "context" + "time" + + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// TaskStatus tracks delivery task lifecycle. +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = "PENDING" + TaskStatusRetry TaskStatus = "RETRY" + TaskStatusDelivered TaskStatus = "DELIVERED" + TaskStatusFailed TaskStatus = "FAILED" +) + +// Endpoint describes one target callback endpoint. +type Endpoint struct { + ID bson.ObjectID + ClientID string + URL string + SigningMode string + SecretRef string + Headers map[string]string + MaxAttempts int + MinDelay time.Duration + MaxDelay time.Duration + RequestTimeout time.Duration +} + +// Task is one callback delivery job. +type Task struct { + ID bson.ObjectID + EventID string + EndpointID bson.ObjectID + EndpointURL string + SigningMode string + SecretRef string + Headers map[string]string + Payload []byte + Attempt int + MaxAttempts int + MinDelay time.Duration + MaxDelay time.Duration + RequestTimeout time.Duration + Status TaskStatus + NextAttemptAt time.Time +} + +// TaskDefaults are applied when creating tasks. +type TaskDefaults struct { + MaxAttempts int + MinDelay time.Duration + MaxDelay time.Duration + RequestTimeout time.Duration +} + +// Options configures mongo collections. +type Options struct { + InboxCollection string + TasksCollection string + EndpointsCollection string +} + +// InboxRepo controls event dedupe state. +type InboxRepo interface { + TryInsert(ctx context.Context, eventID, clientID, eventType string, at time.Time) (bool, error) +} + +// EndpointRepo resolves endpoints for events. +type EndpointRepo interface { + FindActiveByClientAndType(ctx context.Context, clientID, eventType string) ([]Endpoint, error) +} + +// TaskRepo manages callback tasks. +type TaskRepo interface { + UpsertTasks(ctx context.Context, eventID string, endpoints []Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error + LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*Task, error) + MarkDelivered(ctx context.Context, taskID bson.ObjectID, httpCode int, latency time.Duration, at time.Time) error + MarkRetry(ctx context.Context, taskID bson.ObjectID, attempt int, nextAttemptAt time.Time, lastError string, httpCode int, at time.Time) error + MarkFailed(ctx context.Context, taskID bson.ObjectID, attempt int, lastError string, httpCode int, at time.Time) error +} + +// Repository is the callbacks persistence contract. +type Repository interface { + Inbox() InboxRepo + Endpoints() EndpointRepo + Tasks() TaskRepo +} + +// New creates a Mongo-backed callbacks repository. +func New(logger mlogger.Logger, conn *db.MongoConnection) (Repository, error) { + return newMongoRepository(logger, conn) +} diff --git a/api/edge/callbacks/internal/storage/service.go b/api/edge/callbacks/internal/storage/service.go new file mode 100644 index 00000000..67962399 --- /dev/null +++ b/api/edge/callbacks/internal/storage/service.go @@ -0,0 +1,513 @@ +package storage + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/db/repository" + "github.com/tech/sendico/pkg/db/repository/builder" + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + mutil "github.com/tech/sendico/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +const ( + inboxCollection string = "inbox" + tasksCollection string = "tasks" + endpointsCollection string = "endpoints" +) + +type mongoRepository struct { + logger mlogger.Logger + + inboxRepo repository.Repository + tasksRepo repository.Repository + endpointsRepo repository.Repository + + inbox InboxRepo + endpoints EndpointRepo + tasks TaskRepo +} + +type inboxDoc struct { + storable.Base `bson:",inline"` + EventID string `bson:"event_id"` + ClientID string `bson:"client_id"` + EventType string `bson:"event_type"` +} + +func (d *inboxDoc) Collection() string { + return inboxCollection +} + +type delayConfig struct { + MinDelayMS int `bson:"min_ms"` + MaxDelayMS int `bson:"max_ms"` +} + +type deliveryPolicy struct { + delayConfig `bson:",inline"` + SigningMode string `bson:"signing_mode"` + SecretRef string `bson:"secret_ref"` + Headers map[string]string `bson:"headers"` + MaxAttempts int `bson:"max_attempts"` + RequestTimeoutMS int `bson:"request_timeout_ms"` +} + +type endpointDoc struct { + storable.Base `bson:",inline"` + deliveryPolicy `bson:"retry_policy"` + ClientID string `bson:"client_id"` + Status string `bson:"status"` + URL string `bson:"url"` + EventTypes []string `bson:"event_types"` +} + +func (d *endpointDoc) Collection() string { + return endpointsCollection +} + +type taskDoc struct { + storable.Base `bson:",inline"` + deliveryPolicy `bson:"retry_policy"` + EventID string `bson:"event_id"` + EndpointID bson.ObjectID `bson:"endpoint_id"` + EndpointURL string `bson:"endpoint_url"` + Payload []byte `bson:"payload"` + Status TaskStatus `bson:"status"` + Attempt int `bson:"attempt"` + LastError string `bson:"last_error,omitempty"` + LastHTTPCode int `bson:"last_http_code,omitempty"` + NextAttemptAt time.Time `bson:"next_attempt_at"` + LockedUntil *time.Time `bson:"locked_until,omitempty"` + WorkerID string `bson:"worker_id,omitempty"` + DeliveredAt *time.Time `bson:"delivered_at,omitempty"` +} + +func (d *taskDoc) Collection() string { + return tasksCollection +} + +func newMongoRepository(logger mlogger.Logger, conn *db.MongoConnection) (Repository, error) { + if logger == nil { + logger = zap.NewNop() + } + if conn == nil { + return nil, merrors.InvalidArgument("callbacks storage: mongo connection is required", "conn") + } + + repo := &mongoRepository{ + logger: logger.Named("storage"), + inboxRepo: repository.CreateMongoRepository(conn.Database(), inboxCollection), + tasksRepo: repository.CreateMongoRepository(conn.Database(), tasksCollection), + endpointsRepo: repository.CreateMongoRepository(conn.Database(), endpointsCollection), + } + + if err := repo.ensureIndexes(); err != nil { + return nil, err + } + + repo.inbox = &inboxStore{logger: repo.logger.Named(repo.inboxRepo.Collection()), repo: repo.inboxRepo} + repo.endpoints = &endpointStore{logger: repo.logger.Named(repo.endpointsRepo.Collection()), repo: repo.endpointsRepo} + repo.tasks = &taskStore{logger: repo.logger.Named(repo.tasksRepo.Collection()), repo: repo.tasksRepo} + + return repo, nil +} + +func (m *mongoRepository) Inbox() InboxRepo { + return m.inbox +} + +func (m *mongoRepository) Endpoints() EndpointRepo { + return m.endpoints +} + +func (m *mongoRepository) Tasks() TaskRepo { + return m.tasks +} + +func (m *mongoRepository) ensureIndexes() error { + if err := m.inboxRepo.CreateIndex(&ri.Definition{ + Name: "uq_event_id", + Unique: true, + Keys: []ri.Key{ + {Field: "event_id", Sort: ri.Asc}, + }, + }); err != nil { + return merrors.InternalWrap(err, "callbacks storage: failed to create inbox indexes") + } + + for _, def := range []*ri.Definition{ + { + Name: "uq_event_endpoint", + Unique: true, + Keys: []ri.Key{ + {Field: "event_id", Sort: ri.Asc}, + {Field: "endpoint_id", Sort: ri.Asc}, + }, + }, + { + Name: "idx_dispatch_scan", + Keys: []ri.Key{ + {Field: "status", Sort: ri.Asc}, + {Field: "next_attempt_at", Sort: ri.Asc}, + {Field: "locked_until", Sort: ri.Asc}, + }, + }, + } { + if err := m.tasksRepo.CreateIndex(def); err != nil { + return merrors.InternalWrap(err, "callbacks storage: failed to create tasks indexes") + } + } + + if err := m.endpointsRepo.CreateIndex(&ri.Definition{ + Name: "idx_client_event", + Keys: []ri.Key{ + {Field: "client_id", Sort: ri.Asc}, + {Field: "status", Sort: ri.Asc}, + {Field: "event_types", Sort: ri.Asc}, + }, + }); err != nil { + return merrors.InternalWrap(err, "callbacks storage: failed to create endpoint indexes") + } + + return nil +} + +type inboxStore struct { + logger mlogger.Logger + repo repository.Repository +} + +func (r *inboxStore) TryInsert(ctx context.Context, eventID, clientID, eventType string, at time.Time) (bool, error) { + doc := &inboxDoc{ + EventID: strings.TrimSpace(eventID), + ClientID: strings.TrimSpace(clientID), + EventType: strings.TrimSpace(eventType), + } + + filter := repository.Filter("event_id", doc.EventID) + if err := r.repo.Insert(ctx, doc, filter); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + return false, nil + } + r.logger.Warn("Failed to insert inbox dedupe marker", zap.String("event_id", eventID), zap.Error(err)) + return false, merrors.InternalWrap(err, "callbacks inbox insert failed") + } + + return true, nil +} + +type endpointStore struct { + logger mlogger.Logger + repo repository.Repository +} + +func (r *endpointStore) FindActiveByClientAndType(ctx context.Context, clientID, eventType string) ([]Endpoint, error) { + clientID = strings.TrimSpace(clientID) + eventType = strings.TrimSpace(eventType) + if clientID == "" { + return nil, merrors.InvalidArgument("client_id is required", "client_id") + } + if eventType == "" { + return nil, merrors.InvalidArgument("event type is required", "event_type") + } + + query := repository.Query(). + Filter(repository.Field("client_id"), clientID). + In(repository.Field("status"), "active", "enabled") + + out := make([]Endpoint, 0) + err := r.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error { + doc := &endpointDoc{} + if err := cur.Decode(doc); err != nil { + return err + } + if strings.TrimSpace(doc.URL) == "" { + return nil + } + if !supportsEventType(doc.EventTypes, eventType) { + return nil + } + out = append(out, Endpoint{ + ID: doc.ID, + ClientID: doc.ClientID, + URL: strings.TrimSpace(doc.URL), + SigningMode: strings.TrimSpace(doc.SigningMode), + SecretRef: strings.TrimSpace(doc.SecretRef), + Headers: cloneHeaders(doc.Headers), + MaxAttempts: doc.MaxAttempts, + MinDelay: time.Duration(doc.MinDelayMS) * time.Millisecond, + MaxDelay: time.Duration(doc.MaxDelayMS) * time.Millisecond, + RequestTimeout: time.Duration(doc.RequestTimeoutMS) * time.Millisecond, + }) + return nil + }) + if err != nil && !errors.Is(err, merrors.ErrNoData) { + return nil, merrors.InternalWrap(err, "callbacks endpoint lookup failed") + } + + return out, nil +} + +func supportsEventType(eventTypes []string, eventType string) bool { + if len(eventTypes) == 0 { + return true + } + eventType = strings.TrimSpace(eventType) + for _, t := range eventTypes { + current := strings.TrimSpace(t) + if current == "" { + continue + } + if current == "*" || current == eventType { + return true + } + } + return false +} + +type taskStore struct { + logger mlogger.Logger + repo repository.Repository +} + +func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints []Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error { + eventID = strings.TrimSpace(eventID) + if eventID == "" { + return merrors.InvalidArgument("event id is required", "event_id") + } + if len(endpoints) == 0 { + return nil + } + + now := at.UTC() + for _, endpoint := range endpoints { + if endpoint.ID == bson.NilObjectID { + continue + } + + maxAttempts := endpoint.MaxAttempts + if maxAttempts <= 0 { + maxAttempts = defaults.MaxAttempts + } + if maxAttempts <= 0 { + maxAttempts = 1 + } + + minDelay := endpoint.MinDelay + if minDelay <= 0 { + minDelay = defaults.MinDelay + } + if minDelay <= 0 { + minDelay = time.Second + } + + maxDelay := endpoint.MaxDelay + if maxDelay <= 0 { + maxDelay = defaults.MaxDelay + } + if maxDelay < minDelay { + maxDelay = minDelay + } + + requestTimeout := endpoint.RequestTimeout + if requestTimeout <= 0 { + requestTimeout = defaults.RequestTimeout + } + + doc := &taskDoc{} + doc.EventID = eventID + doc.EndpointID = endpoint.ID + doc.EndpointURL = strings.TrimSpace(endpoint.URL) + doc.SigningMode = strings.TrimSpace(endpoint.SigningMode) + doc.SecretRef = strings.TrimSpace(endpoint.SecretRef) + doc.Headers = cloneHeaders(endpoint.Headers) + doc.Payload = append([]byte(nil), payload...) + doc.Status = TaskStatusPending + doc.Attempt = 0 + doc.MaxAttempts = maxAttempts + doc.MinDelayMS = int(minDelay / time.Millisecond) + doc.MaxDelayMS = int(maxDelay / time.Millisecond) + doc.RequestTimeoutMS = int(requestTimeout / time.Millisecond) + doc.NextAttemptAt = now + + filter := repository.Filter("event_id", eventID).And(repository.Filter("endpoint_id", endpoint.ID)) + if err := r.repo.Insert(ctx, doc, filter); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + continue + } + return merrors.InternalWrap(err, "callbacks task upsert failed") + } + } + + return nil +} + +func (r *taskStore) LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*Task, error) { + workerID = strings.TrimSpace(workerID) + if workerID == "" { + return nil, merrors.InvalidArgument("worker id is required", "worker_id") + } + + now = now.UTC() + limit := int64(32) + lockFilter := repository.Query().Or( + repository.Query().Comparison(repository.Field("locked_until"), builder.Exists, false), + repository.Query().Filter(repository.Field("locked_until"), nil), + repository.Query().Comparison(repository.Field("locked_until"), builder.Lte, now), + ) + + query := repository.Query(). + In(repository.Field("status"), string(TaskStatusPending), string(TaskStatusRetry)). + Comparison(repository.Field("next_attempt_at"), builder.Lte, now). + And(lockFilter). + Sort(repository.Field("next_attempt_at"), true). + Sort(repository.Field("created_at"), true). + Limit(&limit) + + candidates, err := mutil.GetObjects[taskDoc](ctx, r.logger, query, nil, r.repo) + if err != nil { + if errors.Is(err, merrors.ErrNoData) { + return nil, nil + } + return nil, merrors.InternalWrap(err, "callbacks task query failed") + } + + lockedUntil := now.Add(lockTTL) + for _, candidate := range candidates { + patch := repository.Patch(). + Set(repository.Field("locked_until"), lockedUntil). + Set(repository.Field("worker_id"), workerID) + + conditional := repository.IDFilter(candidate.ID).And( + repository.Query().In(repository.Field("status"), string(TaskStatusPending), string(TaskStatusRetry)), + repository.Query().Comparison(repository.Field("next_attempt_at"), builder.Lte, now), + lockFilter, + ) + + updated, err := r.repo.PatchMany(ctx, conditional, patch) + if err != nil { + return nil, merrors.InternalWrap(err, "callbacks task lock update failed") + } + if updated == 0 { + continue + } + + locked := &taskDoc{} + if err := r.repo.Get(ctx, candidate.ID, locked); err != nil { + if errors.Is(err, merrors.ErrNoData) { + continue + } + return nil, merrors.InternalWrap(err, "callbacks task lock reload failed") + } + if strings.TrimSpace(locked.WorkerID) != workerID { + continue + } + + return mapTaskDoc(locked), nil + } + + return nil, nil +} + +func (r *taskStore) MarkDelivered(ctx context.Context, taskID bson.ObjectID, httpCode int, latency time.Duration, at time.Time) error { + _ = latency + if taskID == bson.NilObjectID { + return merrors.InvalidArgument("task id is required", "task_id") + } + + patch := repository.Patch(). + Set(repository.Field("status"), TaskStatusDelivered). + Set(repository.Field("last_http_code"), httpCode). + Set(repository.Field("delivered_at"), time.Now()). + Set(repository.Field("locked_until"), nil). + Set(repository.Field("worker_id"), ""). + Set(repository.Field("last_error"), "") + + if err := r.repo.Patch(ctx, taskID, patch); err != nil { + return merrors.InternalWrap(err, "callbacks task mark delivered failed") + } + return nil +} + +func (r *taskStore) MarkRetry(ctx context.Context, taskID bson.ObjectID, attempt int, nextAttemptAt time.Time, lastError string, httpCode int, at time.Time) error { + if taskID == bson.NilObjectID { + return merrors.InvalidArgument("task id is required", "task_id") + } + + patch := repository.Patch(). + Set(repository.Field("status"), TaskStatusRetry). + Set(repository.Field("attempt"), attempt). + Set(repository.Field("next_attempt_at"), nextAttemptAt.UTC()). + Set(repository.Field("last_error"), strings.TrimSpace(lastError)). + Set(repository.Field("last_http_code"), httpCode). + Set(repository.Field("locked_until"), nil). + Set(repository.Field("worker_id"), "") + + if err := r.repo.Patch(ctx, taskID, patch); err != nil { + return merrors.InternalWrap(err, "callbacks task mark retry failed") + } + return nil +} + +func (r *taskStore) MarkFailed(ctx context.Context, taskID bson.ObjectID, attempt int, lastError string, httpCode int, at time.Time) error { + if taskID == bson.NilObjectID { + return merrors.InvalidArgument("task id is required", "task_id") + } + + patch := repository.Patch(). + Set(repository.Field("status"), TaskStatusFailed). + Set(repository.Field("attempt"), attempt). + Set(repository.Field("last_error"), strings.TrimSpace(lastError)). + Set(repository.Field("last_http_code"), httpCode). + Set(repository.Field("locked_until"), nil). + Set(repository.Field("worker_id"), "") + + if err := r.repo.Patch(ctx, taskID, patch); err != nil { + return merrors.InternalWrap(err, "callbacks task mark failed failed") + } + return nil +} + +func mapTaskDoc(doc *taskDoc) *Task { + if doc == nil { + return nil + } + return &Task{ + ID: doc.ID, + EventID: doc.EventID, + EndpointID: doc.EndpointID, + EndpointURL: doc.EndpointURL, + SigningMode: doc.SigningMode, + SecretRef: doc.SecretRef, + Headers: cloneHeaders(doc.Headers), + Payload: append([]byte(nil), doc.Payload...), + Attempt: doc.Attempt, + MaxAttempts: doc.MaxAttempts, + MinDelay: time.Duration(doc.MinDelayMS) * time.Millisecond, + MaxDelay: time.Duration(doc.MaxDelayMS) * time.Millisecond, + RequestTimeout: time.Duration(doc.RequestTimeoutMS) * time.Millisecond, + Status: doc.Status, + NextAttemptAt: doc.NextAttemptAt, + } +} + +func cloneHeaders(in map[string]string) map[string]string { + if len(in) == 0 { + return map[string]string{} + } + out := make(map[string]string, len(in)) + for key, val := range in { + out[key] = val + } + return out +} diff --git a/api/edge/callbacks/internal/subscriptions/module.go b/api/edge/callbacks/internal/subscriptions/module.go new file mode 100644 index 00000000..7d7c2ecc --- /dev/null +++ b/api/edge/callbacks/internal/subscriptions/module.go @@ -0,0 +1,17 @@ +package subscriptions + +import ( + "context" + + "github.com/tech/sendico/edge/callbacks/internal/storage" +) + +// Resolver resolves active webhook endpoints for an event. +type Resolver interface { + Resolve(ctx context.Context, clientID, eventType string) ([]storage.Endpoint, error) +} + +// Dependencies defines subscriptions resolver dependencies. +type Dependencies struct { + EndpointRepo storage.EndpointRepo +} diff --git a/api/edge/callbacks/internal/subscriptions/service.go b/api/edge/callbacks/internal/subscriptions/service.go new file mode 100644 index 00000000..cee1e604 --- /dev/null +++ b/api/edge/callbacks/internal/subscriptions/service.go @@ -0,0 +1,38 @@ +package subscriptions + +import ( + "context" + "strings" + + "github.com/tech/sendico/edge/callbacks/internal/storage" + "github.com/tech/sendico/pkg/merrors" +) + +type service struct { + repo storage.EndpointRepo +} + +// New creates endpoint resolver service. +func New(deps Dependencies) (Resolver, error) { + if deps.EndpointRepo == nil { + return nil, merrors.InvalidArgument("subscriptions: endpoint repo is required", "endpointRepo") + } + + return &service{repo: deps.EndpointRepo}, nil +} + +func (s *service) Resolve(ctx context.Context, clientID, eventType string) ([]storage.Endpoint, error) { + if strings.TrimSpace(clientID) == "" { + return nil, merrors.InvalidArgument("subscriptions: client id is required", "clientID") + } + if strings.TrimSpace(eventType) == "" { + return nil, merrors.InvalidArgument("subscriptions: event type is required", "eventType") + } + + endpoints, err := s.repo.FindActiveByClientAndType(ctx, clientID, eventType) + if err != nil { + return nil, err + } + + return endpoints, nil +} diff --git a/api/edge/callbacks/main.go b/api/edge/callbacks/main.go new file mode 100644 index 00000000..1791720b --- /dev/null +++ b/api/edge/callbacks/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/tech/sendico/edge/callbacks/internal/appversion" + si "github.com/tech/sendico/edge/callbacks/internal/server" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" + smain "github.com/tech/sendico/pkg/server/main" +) + +func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return si.Create(logger, file, debug) +} + +func main() { + smain.RunServer("callbacks", appversion.Create(), factory) +} diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index a84aa694..b65c0ab9 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -7,9 +7,7 @@ replace github.com/tech/sendico/pkg => ../../pkg replace github.com/tech/sendico/gateway/common => ../common require ( - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 github.com/ethereum/go-ethereum v1.17.0 - github.com/hashicorp/vault/api v1.22.0 github.com/mitchellh/mapstructure v1.5.0 github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 @@ -38,6 +36,7 @@ require ( github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect @@ -55,6 +54,7 @@ require ( github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/vault/api v1.22.0 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/api/gateway/chain/internal/keymanager/vault/manager.go b/api/gateway/chain/internal/keymanager/vault/manager.go index bbe9fbe9..2765acfb 100644 --- a/api/gateway/chain/internal/keymanager/vault/manager.go +++ b/api/gateway/chain/internal/keymanager/vault/manager.go @@ -2,18 +2,11 @@ package vault import ( "context" - "crypto/ecdsa" - "crypto/rand" - "encoding/hex" "math/big" - "os" - "path" "strings" - "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" - "github.com/hashicorp/vault/api" + "github.com/tech/sendico/pkg/vault/managedkey" "go.uber.org/zap" "github.com/tech/sendico/gateway/chain/internal/keymanager" @@ -23,20 +16,12 @@ import ( ) // Config describes how to connect to Vault for managed wallet keys. -type Config struct { - Address string `mapstructure:"address"` - TokenEnv string `mapstructure:"token_env"` - Namespace string `mapstructure:"namespace"` - MountPath string `mapstructure:"mount_path"` - KeyPrefix string `mapstructure:"key_prefix"` -} +type Config = managedkey.Config // Manager implements the keymanager.Manager contract backed by HashiCorp Vault. type Manager struct { - logger mlogger.Logger - client *api.Client - store *api.KVv2 - keyPrefix string + logger mlogger.Logger + keys managedkey.Service } // New constructs a Vault-backed key manager. @@ -44,227 +29,56 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) { if logger == nil { return nil, merrors.InvalidArgument("vault key manager: logger is required") } - address := strings.TrimSpace(cfg.Address) - if address == "" { - logger.Error("Vault address missing") - return nil, merrors.InvalidArgument("vault key manager: address is required") - } - tokenEnv := strings.TrimSpace(cfg.TokenEnv) - if tokenEnv == "" { - logger.Error("Vault token env missing") - return nil, merrors.InvalidArgument("vault key manager: token_env is required") - } - token := strings.TrimSpace(os.Getenv(tokenEnv)) - if token == "" { - logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv)) - return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)") - } - mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/") - if mountPath == "" { - logger.Error("Vault mount path missing") - return nil, merrors.InvalidArgument("vault key manager: mount_path is required") - } - keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/") - if keyPrefix == "" { - keyPrefix = "gateway/chain/wallets" - } - - clientCfg := api.DefaultConfig() - clientCfg.Address = address - - client, err := api.NewClient(clientCfg) + keys, err := managedkey.New(managedkey.Options{ + Logger: logger, + Config: managedkey.Config(cfg), + Component: "vault key manager", + DefaultKeyPrefix: "gateway/chain/wallets", + }) if err != nil { - logger.Error("Failed to create vault client", zap.Error(err)) - return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error()) + logger.Error("Failed to initialise vault key manager", zap.Error(err)) + return nil, err } - client.SetToken(token) - if ns := strings.TrimSpace(cfg.Namespace); ns != "" { - client.SetNamespace(ns) - } - - kv := client.KVv2(mountPath) return &Manager{ - logger: logger.Named("vault"), - client: client, - store: kv, - keyPrefix: keyPrefix, + logger: logger.Named("vault"), + keys: keys, }, nil } // CreateManagedWalletKey creates a new managed wallet key and stores it in Vault. func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network pmodel.ChainNetwork) (*keymanager.ManagedWalletKey, error) { - if strings.TrimSpace(walletRef) == "" { - m.logger.Warn("WalletRef missing for managed key creation", zap.String("network", string(network))) - return nil, merrors.InvalidArgument("vault key manager: walletRef is required") - } if network == pmodel.ChainNetworkUnspecified { m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef)) return nil, merrors.InvalidArgument("vault key manager: network is required") } - - privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader) - if err != nil { - m.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", string(network)), zap.Error(err)) - return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error()) + networkValue := strings.TrimSpace(string(network)) + if networkValue == "" { + m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef)) + return nil, merrors.InvalidArgument("vault key manager: network is required") } - privateKeyBytes := crypto.FromECDSA(privateKey) - publicKey := privateKey.PublicKey - publicKeyBytes := crypto.FromECDSAPub(&publicKey) - publicKeyHex := hex.EncodeToString(publicKeyBytes) - address := crypto.PubkeyToAddress(publicKey).Hex() - err = m.persistKey(ctx, walletRef, string(network), privateKeyBytes, publicKeyBytes, address) + created, err := m.keys.CreateManagedWalletKey(ctx, walletRef, networkValue) if err != nil { - m.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", string(network)), zap.Error(err)) - zeroBytes(privateKeyBytes) - zeroBytes(publicKeyBytes) + m.logger.Warn("Failed to create managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", networkValue), zap.Error(err)) return nil, err } - zeroBytes(privateKeyBytes) - zeroBytes(publicKeyBytes) - - m.logger.Info("Managed wallet key created", - zap.String("wallet_ref", walletRef), - zap.String("network", string(network)), - zap.String("address", strings.ToLower(address)), - ) return &keymanager.ManagedWalletKey{ - KeyID: m.buildKeyID(string(network), walletRef), - Address: strings.ToLower(address), - PublicKey: publicKeyHex, + KeyID: created.KeyID, + Address: created.Address, + PublicKey: created.PublicKey, }, nil } -func (m *Manager) persistKey(ctx context.Context, walletRef, network string, privateKey, publicKey []byte, address string) error { - secretPath := m.buildKeyID(network, walletRef) - payload := map[string]interface{}{ - "private_key": hex.EncodeToString(privateKey), - "public_key": hex.EncodeToString(publicKey), - "address": strings.ToLower(address), - "network": strings.ToLower(network), - } - if _, err := m.store.Put(ctx, secretPath, payload); err != nil { - return merrors.Internal("vault key manager: failed to write secret at " + secretPath + ": " + err.Error()) - } - return nil -} - -func (m *Manager) buildKeyID(network, walletRef string) string { - net := strings.Trim(strings.ToLower(network), "/") - return path.Join(m.keyPrefix, net, walletRef) -} - // SignTransaction loads the key material from Vault and signs the transaction. func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { - if strings.TrimSpace(keyID) == "" { - m.logger.Warn("Signing failed: empty key id") - return nil, merrors.InvalidArgument("vault key manager: keyID is required") - } - if tx == nil { - m.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID)) - return nil, merrors.InvalidArgument("vault key manager: transaction is nil") - } - if chainID == nil { - m.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID)) - return nil, merrors.InvalidArgument("vault key manager: chainID is nil") - } - - material, err := m.loadKey(ctx, keyID) - if err != nil { - m.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err)) - return nil, err - } - - keyBytes, err := hex.DecodeString(material.PrivateKey) - if err != nil { - m.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err)) - return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error()) - } - defer zeroBytes(keyBytes) - - privateKey, err := crypto.ToECDSA(keyBytes) - if err != nil { - m.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err)) - return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error()) - } - - signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey) + signed, err := m.keys.SignEVMTransaction(ctx, keyID, tx, chainID) if err != nil { m.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err)) - return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error()) + return nil, err } - m.logger.Info("Transaction signed with managed key", - zap.String("key_id", keyID), - zap.String("network", material.Network), - zap.String("tx_hash", signed.Hash().Hex()), - ) return signed, nil } -type keyMaterial struct { - PrivateKey string - PublicKey string - Address string - Network string -} - -func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, error) { - secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/") - secret, err := m.store.Get(ctx, secretPath) - if err != nil { - m.logger.Warn("Failed to read secret", zap.String("path", secretPath), zap.Error(err)) - return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error()) - } - if secret == nil || secret.Data == nil { - m.logger.Warn("Secret not found", zap.String("path", secretPath)) - return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found") - } - - getString := func(key string) (string, error) { - val, ok := secret.Data[key] - if !ok { - m.logger.Warn("Secret missing field", zap.String("path", secretPath), zap.String("field", key)) - return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key) - } - str, ok := val.(string) - if !ok || strings.TrimSpace(str) == "" { - m.logger.Warn("Secret field invalid", zap.String("path", secretPath), zap.String("field", key)) - return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key) - } - return str, nil - } - - privateKey, err := getString("private_key") - if err != nil { - return nil, err - } - publicKey, err := getString("public_key") - if err != nil { - return nil, err - } - address, err := getString("address") - if err != nil { - return nil, err - } - network, err := getString("network") - if err != nil { - return nil, err - } - - return &keyMaterial{ - PrivateKey: privateKey, - PublicKey: publicKey, - Address: address, - Network: network, - }, nil -} - -func zeroBytes(data []byte) { - for i := range data { - data[i] = 0 - } -} - var _ keymanager.Manager = (*Manager)(nil) diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index 8275bd54..25b45cc0 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -10,7 +10,6 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 github.com/ethereum/go-ethereum v1.17.0 github.com/fbsobreira/gotron-sdk v0.24.1 - github.com/hashicorp/vault/api v1.22.0 github.com/mitchellh/mapstructure v1.5.0 github.com/prometheus/client_golang v1.23.2 github.com/shengdoushi/base58 v1.0.0 @@ -59,6 +58,7 @@ require ( github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/vault/api v1.22.0 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/api/gateway/tron/internal/keymanager/vault/manager.go b/api/gateway/tron/internal/keymanager/vault/manager.go index a0ec67af..105e2e17 100644 --- a/api/gateway/tron/internal/keymanager/vault/manager.go +++ b/api/gateway/tron/internal/keymanager/vault/manager.go @@ -2,21 +2,16 @@ package vault import ( "context" - stdecdsa "crypto/ecdsa" - "crypto/rand" "crypto/sha256" "encoding/hex" "math/big" - "os" - "path" "strings" "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" troncore "github.com/fbsobreira/gotron-sdk/pkg/proto/core" - "github.com/hashicorp/vault/api" + "github.com/tech/sendico/pkg/vault/managedkey" "go.uber.org/zap" "google.golang.org/protobuf/proto" @@ -26,20 +21,12 @@ import ( ) // Config describes how to connect to Vault for managed wallet keys. -type Config struct { - Address string `mapstructure:"address"` - TokenEnv string `mapstructure:"token_env"` - Namespace string `mapstructure:"namespace"` - MountPath string `mapstructure:"mount_path"` - KeyPrefix string `mapstructure:"key_prefix"` -} +type Config = managedkey.Config // Manager implements the keymanager.Manager contract backed by HashiCorp Vault. type Manager struct { - logger mlogger.Logger - client *api.Client - store *api.KVv2 - keyPrefix string + logger mlogger.Logger + keys managedkey.Service } // New constructs a Vault-backed key manager. @@ -47,162 +34,45 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) { if logger == nil { return nil, merrors.InvalidArgument("vault key manager: logger is required") } - address := strings.TrimSpace(cfg.Address) - if address == "" { - logger.Error("Vault address missing") - return nil, merrors.InvalidArgument("vault key manager: address is required") - } - tokenEnv := strings.TrimSpace(cfg.TokenEnv) - if tokenEnv == "" { - logger.Error("Vault token env missing") - return nil, merrors.InvalidArgument("vault key manager: token_env is required") - } - token := strings.TrimSpace(os.Getenv(tokenEnv)) - if token == "" { - logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv)) - return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)") - } - mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/") - if mountPath == "" { - logger.Error("Vault mount path missing") - return nil, merrors.InvalidArgument("vault key manager: mount_path is required") - } - keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/") - if keyPrefix == "" { - keyPrefix = "gateway/chain/wallets" - } - - clientCfg := api.DefaultConfig() - clientCfg.Address = address - - client, err := api.NewClient(clientCfg) + keys, err := managedkey.New(managedkey.Options{ + Logger: logger, + Config: managedkey.Config(cfg), + Component: "vault key manager", + DefaultKeyPrefix: "gateway/tron/wallets", + }) if err != nil { - logger.Error("Failed to create vault client", zap.Error(err)) - return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error()) + logger.Error("Failed to initialise vault key manager", zap.Error(err)) + return nil, err } - client.SetToken(token) - if ns := strings.TrimSpace(cfg.Namespace); ns != "" { - client.SetNamespace(ns) - } - - kv := client.KVv2(mountPath) return &Manager{ - logger: logger.Named("vault"), - client: client, - store: kv, - keyPrefix: keyPrefix, + logger: logger.Named("vault"), + keys: keys, }, nil } // CreateManagedWalletKey creates a new managed wallet key and stores it in Vault. func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) { - if strings.TrimSpace(walletRef) == "" { - m.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network)) - return nil, merrors.InvalidArgument("vault key manager: walletRef is required") - } - if strings.TrimSpace(network) == "" { - m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef)) - return nil, merrors.InvalidArgument("vault key manager: network is required") - } - - privateKey, err := stdecdsa.GenerateKey(secp256k1.S256(), rand.Reader) + created, err := m.keys.CreateManagedWalletKey(ctx, walletRef, network) if err != nil { - m.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) - return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error()) - } - privateKeyBytes := crypto.FromECDSA(privateKey) - publicKey := privateKey.PublicKey - publicKeyBytes := crypto.FromECDSAPub(&publicKey) - publicKeyHex := hex.EncodeToString(publicKeyBytes) - address := crypto.PubkeyToAddress(publicKey).Hex() - - err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address) - if err != nil { - m.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) - zeroBytes(privateKeyBytes) - zeroBytes(publicKeyBytes) + m.logger.Warn("Failed to create managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) return nil, err } - zeroBytes(privateKeyBytes) - zeroBytes(publicKeyBytes) - - m.logger.Info("Managed wallet key created", - zap.String("wallet_ref", walletRef), - zap.String("network", network), - zap.String("address", strings.ToLower(address)), - ) return &keymanager.ManagedWalletKey{ - KeyID: m.buildKeyID(network, walletRef), - Address: strings.ToLower(address), - PublicKey: publicKeyHex, + KeyID: created.KeyID, + Address: created.Address, + PublicKey: created.PublicKey, }, nil } -func (m *Manager) persistKey(ctx context.Context, walletRef, network string, privateKey, publicKey []byte, address string) error { - secretPath := m.buildKeyID(network, walletRef) - payload := map[string]interface{}{ - "private_key": hex.EncodeToString(privateKey), - "public_key": hex.EncodeToString(publicKey), - "address": strings.ToLower(address), - "network": strings.ToLower(network), - } - if _, err := m.store.Put(ctx, secretPath, payload); err != nil { - return merrors.Internal("vault key manager: failed to write secret at " + secretPath + ": " + err.Error()) - } - return nil -} - -func (m *Manager) buildKeyID(network, walletRef string) string { - net := strings.Trim(strings.ToLower(network), "/") - return path.Join(m.keyPrefix, net, walletRef) -} - // SignTransaction loads the key material from Vault and signs the transaction. func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { - if strings.TrimSpace(keyID) == "" { - m.logger.Warn("Signing failed: empty key id") - return nil, merrors.InvalidArgument("vault key manager: keyID is required") - } - if tx == nil { - m.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID)) - return nil, merrors.InvalidArgument("vault key manager: transaction is nil") - } - if chainID == nil { - m.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID)) - return nil, merrors.InvalidArgument("vault key manager: chainID is nil") - } - - material, err := m.loadKey(ctx, keyID) - if err != nil { - m.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err)) - return nil, err - } - - keyBytes, err := hex.DecodeString(material.PrivateKey) - if err != nil { - m.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err)) - return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error()) - } - defer zeroBytes(keyBytes) - - privateKey, err := crypto.ToECDSA(keyBytes) - if err != nil { - m.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err)) - return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error()) - } - - signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey) + signed, err := m.keys.SignEVMTransaction(ctx, keyID, tx, chainID) if err != nil { m.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err)) - return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error()) + return nil, err } - m.logger.Info("Transaction signed with managed key", - zap.String("key_id", keyID), - zap.String("network", material.Network), - zap.String("tx_hash", signed.Hash().Hex()), - ) return signed, nil } @@ -221,7 +91,7 @@ func (m *Manager) SignTronTransaction(ctx context.Context, keyID string, tx *tro return nil, merrors.InvalidArgument("vault key manager: transaction raw_data is nil") } - material, err := m.loadKey(ctx, keyID) + material, err := m.keys.LoadKeyMaterial(ctx, keyID) if err != nil { m.logger.Warn("Failed to load key material for TRON signing", zap.String("key_id", keyID), zap.Error(err)) return nil, err @@ -274,64 +144,6 @@ func (m *Manager) SignTronTransaction(ctx context.Context, keyID string, tx *tro return tx, nil } -type keyMaterial struct { - PrivateKey string - PublicKey string - Address string - Network string -} - -func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, error) { - secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/") - secret, err := m.store.Get(ctx, secretPath) - if err != nil { - m.logger.Warn("Failed to read secret", zap.String("path", secretPath), zap.Error(err)) - return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error()) - } - if secret == nil || secret.Data == nil { - m.logger.Warn("Secret not found", zap.String("path", secretPath)) - return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found") - } - - getString := func(key string) (string, error) { - val, ok := secret.Data[key] - if !ok { - m.logger.Warn("Secret missing field", zap.String("path", secretPath), zap.String("field", key)) - return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key) - } - str, ok := val.(string) - if !ok || strings.TrimSpace(str) == "" { - m.logger.Warn("Secret field invalid", zap.String("path", secretPath), zap.String("field", key)) - return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key) - } - return str, nil - } - - privateKey, err := getString("private_key") - if err != nil { - return nil, err - } - publicKey, err := getString("public_key") - if err != nil { - return nil, err - } - address, err := getString("address") - if err != nil { - return nil, err - } - network, err := getString("network") - if err != nil { - return nil, err - } - - return &keyMaterial{ - PrivateKey: privateKey, - PublicKey: publicKey, - Address: address, - Network: network, - }, nil -} - func zeroBytes(data []byte) { for i := range data { data[i] = 0 diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 0228d010..2e724d68 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -58,7 +58,7 @@ func (i *Imp) Start() error { if broker != nil { opts = append(opts, orchestrator.WithPaymentGatewayBroker(broker)) } - svc, err := orchestrator.NewService(logger, repo, opts...) + svc, err := orchestrator.NewService(logger, repo, producer, opts...) i.service = svc return svc, err } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go index d49fb5d4..273da8bd 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go @@ -238,6 +238,18 @@ func (s *svc) recordPaymentCreated(ctx context.Context, payment *agg.Payment, gr }); err != nil { return err } + if err := s.statuses.Publish(ctx, paymentStatusPublishInput{ + Payment: payment, + PreviousState: agg.StateUnspecified, + CurrentState: payment.State, + OccurredAt: s.nowUTC(), + Event: "created", + }); err != nil { + s.logger.Warn("Failed to publish created payment status update", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.Error(err), + ) + } for i := range payment.StepExecutions { if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{ PaymentRef: payment.PaymentRef, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go index f7e9f493..6f1335fb 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go @@ -18,6 +18,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" ) @@ -64,6 +65,7 @@ type Dependencies struct { Query pquery.Service Mapper prmap.Mapper Observer oobs.Observer + Producer msg.Producer RetryPolicy ssched.RetryPolicy Now func() time.Time diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go index f8b54b2d..98ad3b5c 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go @@ -87,6 +87,7 @@ func (s *svc) runRuntime(ctx context.Context, payment *agg.Payment) (*agg.Paymen func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Graph) (*agg.Payment, bool, bool, error) { logger := s.logger expectedVersion := payment.Version + previousAggregateState := payment.State scheduled, err := s.scheduler.Schedule(ssched.Input{ Steps: graph.Steps, @@ -146,6 +147,22 @@ func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Gr zap.Uint64("version", payment.Version), zap.String("state", string(payment.State)), ) + if aggChanged && payment.State != previousAggregateState { + if err := s.statuses.Publish(ctx, paymentStatusPublishInput{ + Payment: payment, + PreviousState: previousAggregateState, + CurrentState: payment.State, + OccurredAt: s.nowUTC(), + Event: "state_changed", + }); err != nil { + logger.Warn("Failed to publish payment status update", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("from_state", string(previousAggregateState)), + zap.String("to_state", string(payment.State)), + zap.Error(err), + ) + } + } return payment, true, len(scheduled.Runnable) == 0, nil } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go index 02c4f68e..ccd3981e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go @@ -46,6 +46,7 @@ type svc struct { query pquery.Service mapper prmap.Mapper observer oobs.Observer + statuses paymentStatusPublisher retryPolicy ssched.RetryPolicy now func() time.Time @@ -106,6 +107,7 @@ func newService(deps Dependencies) (Service, error) { query: query, mapper: firstMapper(deps.Mapper, logger), observer: observer, + statuses: newPaymentStatusPublisher(logger, deps.Producer), retryPolicy: deps.RetryPolicy, now: deps.Now, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go index 8c3e9275..68e8f821 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -3,6 +3,7 @@ package psvc import ( "bytes" "context" + "encoding/json" "errors" "sort" "strings" @@ -20,6 +21,7 @@ import ( quotestorage "github.com/tech/sendico/payments/storage/quote" "github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/merrors" + menv "github.com/tech/sendico/pkg/messaging/envelope" pm "github.com/tech/sendico/pkg/model" paymenttypes "github.com/tech/sendico/pkg/payments/types" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" @@ -76,6 +78,83 @@ func TestExecutePayment_EndToEndSyncSettled(t *testing.T) { assertTimelineHasEvent(t, timeline.Items, "settled") } +func TestExecutePayment_PublishesStatusUpdates(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + env.quotes.Put(newExecutableQuote(env.orgID, "quote-status", "intent-status", buildLedgerRoute())) + + resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-status"), + QuotationRef: "quote-status", + ClientPaymentRef: "client-status", + }) + if err != nil { + t.Fatalf("ExecutePayment returned error: %v", err) + } + if resp.GetPayment() == nil { + t.Fatal("expected payment in response") + } + + type publishedEnvelope struct { + EventID string `json:"event_id"` + Type string `json:"type"` + Data json.RawMessage `json:"data"` + } + type publishedData struct { + PaymentRef string `json:"payment_ref"` + State string `json:"state"` + Event string `json:"event"` + } + + msgs := env.producer.Messages() + if len(msgs) == 0 { + t.Fatal("expected published status updates") + } + + seenCreated := false + seenSettled := false + for i := range msgs { + if got, want := msgs[i].Subject, "payment_orchestrator_updated"; got != want { + t.Fatalf("subject mismatch at %d: got=%q want=%q", i, got, want) + } + + var outer publishedEnvelope + if err := json.Unmarshal(msgs[i].Data, &outer); err != nil { + t.Fatalf("failed to unmarshal published envelope[%d]: %v", i, err) + } + if strings.TrimSpace(outer.EventID) == "" { + t.Fatalf("expected non-empty event_id at %d", i) + } + if got, want := outer.Type, paymentStatusEventType; got != want { + t.Fatalf("event type mismatch at %d: got=%q want=%q", i, got, want) + } + + var inner publishedData + if err := json.Unmarshal(outer.Data, &inner); err != nil { + t.Fatalf("failed to unmarshal published data[%d]: %v", i, err) + } + if got, want := inner.PaymentRef, resp.GetPayment().GetPaymentRef(); got != want { + t.Fatalf("payment_ref mismatch at %d: got=%q want=%q", i, got, want) + } + if inner.Event == "created" && inner.State == string(agg.StateCreated) { + seenCreated = true + } + if inner.State == string(agg.StateSettled) { + seenSettled = true + } + } + + if !seenCreated { + t.Fatal("expected created payment status update") + } + if !seenSettled { + t.Fatal("expected settled payment status update") + } +} + func TestExecutePayment_IdempotencyMismatch(t *testing.T) { env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { step := req.StepExecution @@ -282,6 +361,7 @@ type testEnv struct { repo *memoryRepo quotes *memoryQuoteStore observer oobs.Observer + producer *capturingProducer orgID bson.ObjectID } @@ -306,11 +386,13 @@ func newTestEnv(t *testing.T, handler func(kind string, req sexec.StepRequest) ( Guard: script, }) + producer := &capturingProducer{} svc, err := New(Dependencies{ QuoteStore: quotes, Repository: repo, Executors: registry, Observer: observer, + Producer: producer, RetryPolicy: ssched.RetryPolicy{MaxAttempts: 2}, MaxTicks: 20, }) @@ -322,10 +404,46 @@ func newTestEnv(t *testing.T, handler func(kind string, req sexec.StepRequest) ( repo: repo, quotes: quotes, observer: observer, + producer: producer, orgID: bson.NewObjectID(), } } +type capturedMessage struct { + Subject string + Data []byte +} + +type capturingProducer struct { + mu sync.Mutex + items []capturedMessage +} + +func (p *capturingProducer) SendMessage(envelope menv.Envelope) error { + if envelope == nil { + return nil + } + data, err := envelope.Serialize() + if err != nil { + return err + } + p.mu.Lock() + p.items = append(p.items, capturedMessage{ + Subject: envelope.GetSignature().ToString(), + Data: append([]byte(nil), data...), + }) + p.mu.Unlock() + return nil +} + +func (p *capturingProducer) Messages() []capturedMessage { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]capturedMessage, len(p.items)) + copy(out, p.items) + return out +} + type scriptedExecutors struct { handler func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/status_publish.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/status_publish.go new file mode 100644 index 00000000..34b0670d --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/status_publish.go @@ -0,0 +1,209 @@ +package psvc + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" + me "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + nm "github.com/tech/sendico/pkg/model/notification" + "github.com/tech/sendico/pkg/mservice" + "go.uber.org/zap" +) + +const ( + paymentStatusEventType = "payment.status.updated" + paymentStatusEventSender = "payments.orchestrator.v2" +) + +type paymentStatusPublisher interface { + Publish(ctx context.Context, in paymentStatusPublishInput) error +} + +type paymentStatusPublishInput struct { + Payment *agg.Payment + PreviousState agg.State + CurrentState agg.State + OccurredAt time.Time + Event string +} + +type noopPaymentStatusPublisher struct{} + +func (noopPaymentStatusPublisher) Publish(_ context.Context, _ paymentStatusPublishInput) error { + return nil +} + +type brokerPaymentStatusPublisher struct { + logger mlogger.Logger + producer msg.Producer +} + +type callbackEventEnvelope struct { + EventID string `json:"event_id"` + Type string `json:"type"` + ClientID string `json:"client_id"` + OccurredAt time.Time `json:"occurred_at"` + PublishedAt time.Time `json:"published_at,omitempty"` + Data json.RawMessage `json:"data"` +} + +type paymentStatusEventData struct { + OrganizationRef string `json:"organization_ref"` + PaymentRef string `json:"payment_ref"` + QuotationRef string `json:"quotation_ref"` + ClientPaymentRef string `json:"client_payment_ref,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + State string `json:"state"` + PreviousState string `json:"previous_state,omitempty"` + Version uint64 `json:"version"` + IsTerminal bool `json:"is_terminal"` + Event string `json:"event"` +} + +type rawEnvelope struct { + timestamp time.Time + messageID uuid.UUID + data []byte + sender string + signature model.NotificationEvent +} + +func newPaymentStatusPublisher(logger mlogger.Logger, producer msg.Producer) paymentStatusPublisher { + if producer == nil { + return noopPaymentStatusPublisher{} + } + if logger == nil { + logger = zap.NewNop() + } + return &brokerPaymentStatusPublisher{ + logger: logger.Named("status_publisher"), + producer: producer, + } +} + +func (p *brokerPaymentStatusPublisher) Publish(_ context.Context, in paymentStatusPublishInput) error { + if in.Payment == nil { + return nil + } + payment := in.Payment + + paymentRef := strings.TrimSpace(payment.PaymentRef) + if paymentRef == "" || payment.OrganizationRef.IsZero() { + p.logger.Warn("Skipping payment status publish due to missing identifiers", + zap.String("payment_ref", paymentRef), + zap.String("organization_ref", payment.OrganizationRef.Hex()), + ) + return nil + } + + occurredAt := in.OccurredAt.UTC() + if occurredAt.IsZero() { + occurredAt = time.Now().UTC() + } + eventName := strings.TrimSpace(in.Event) + if eventName == "" { + eventName = "state_changed" + } + + body, err := json.Marshal(paymentStatusEventData{ + OrganizationRef: payment.OrganizationRef.Hex(), + PaymentRef: paymentRef, + QuotationRef: strings.TrimSpace(payment.QuotationRef), + ClientPaymentRef: strings.TrimSpace(payment.ClientPaymentRef), + IdempotencyKey: strings.TrimSpace(payment.IdempotencyKey), + State: string(in.CurrentState), + PreviousState: normalizePreviousState(in.PreviousState, in.CurrentState), + Version: payment.Version, + IsTerminal: isTerminalState(in.CurrentState), + Event: eventName, + }) + if err != nil { + return merrors.InternalWrap(err, "payment status publish: marshal body failed") + } + + message, err := json.Marshal(callbackEventEnvelope{ + EventID: buildPaymentStatusEventID(paymentRef, payment.Version, in.CurrentState), + Type: paymentStatusEventType, + ClientID: payment.OrganizationRef.Hex(), + OccurredAt: occurredAt, + PublishedAt: time.Now().UTC(), + Data: body, + }) + if err != nil { + return merrors.InternalWrap(err, "payment status publish: marshal envelope failed") + } + + signature := model.NewNotification(mservice.PaymentOrchestrator, nm.NAUpdated) + envelope := &rawEnvelope{ + timestamp: occurredAt, + messageID: uuid.New(), + data: message, + sender: paymentStatusEventSender, + signature: signature, + } + + if err := p.producer.SendMessage(envelope); err != nil { + return err + } + return nil +} + +func normalizePreviousState(previous, current agg.State) string { + if previous == current || previous == agg.StateUnspecified { + return "" + } + return string(previous) +} + +func isTerminalState(state agg.State) bool { + switch state { + case agg.StateSettled, agg.StateNeedsAttention, agg.StateFailed: + return true + default: + return false + } +} + +func buildPaymentStatusEventID(paymentRef string, version uint64, state agg.State) string { + return paymentRef + ":" + strconv.FormatUint(version, 10) + ":" + string(state) +} + +func (e *rawEnvelope) Serialize() ([]byte, error) { + return append([]byte(nil), e.data...), nil +} + +func (e *rawEnvelope) GetTimeStamp() time.Time { + return e.timestamp +} + +func (e *rawEnvelope) GetMessageId() uuid.UUID { + return e.messageID +} + +func (e *rawEnvelope) GetData() []byte { + return append([]byte(nil), e.data...) +} + +func (e *rawEnvelope) GetSender() string { + return e.sender +} + +func (e *rawEnvelope) GetSignature() model.NotificationEvent { + return e.signature +} + +func (e *rawEnvelope) Wrap(data []byte) ([]byte, error) { + e.data = append([]byte(nil), data...) + return e.Serialize() +} + +var _ me.Envelope = (*rawEnvelope)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index 7237537e..634f7554 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -23,6 +23,7 @@ type Service struct { repo storage.Repository v2 psvc.Service paymentRepo prepo.Repository + producer msg.Producer ledgerClient ledgerclient.Client mntxClient mntxclient.Client @@ -35,14 +36,15 @@ type Service struct { } // NewService constructs the v2 orchestrator service. -func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) (*Service, error) { +func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) (*Service, error) { if logger == nil { logger = zap.NewNop() } svc := &Service{ - logger: logger.Named("service"), - repo: repo, + logger: logger.Named("service"), + repo: repo, + producer: producer, } for _, opt := range opts { @@ -58,6 +60,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) GatewayInvokeResolver: svc.gatewayInvokeResolver, GatewayRegistry: svc.gatewayRegistry, CardGatewayRoutes: svc.cardGatewayRoutes, + Producer: svc.producer, }) svc.startExternalRuntime() if err != nil { diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go index b633abd9..96aa7912 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go @@ -13,6 +13,7 @@ import ( "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" @@ -30,6 +31,7 @@ type v2RuntimeDeps struct { GatewayInvokeResolver GatewayInvokeResolver GatewayRegistry GatewayRegistry CardGatewayRoutes map[string]CardGatewayRoute + Producer msg.Producer } func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, runtimeDeps v2RuntimeDeps) (psvc.Service, prepo.Repository, error) { @@ -76,6 +78,7 @@ func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, r Query: query, Observer: observer, Executors: executors, + Producer: runtimeDeps.Producer, }) if err != nil { logger.Error("Orchestration v2 disabled: service init failed", zap.Error(err)) diff --git a/api/pkg/go.mod b/api/pkg/go.mod index 50404c84..4d7fef3f 100644 --- a/api/pkg/go.mod +++ b/api/pkg/go.mod @@ -5,8 +5,10 @@ go 1.25.0 require ( github.com/casbin/casbin/v2 v2.135.0 github.com/casbin/mongodb-adapter/v4 v4.3.0 + github.com/ethereum/go-ethereum v1.17.0 github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 + github.com/hashicorp/vault/api v1.22.0 github.com/mattn/go-colorable v0.1.14 github.com/mitchellh/mapstructure v1.5.0 github.com/nats-io/nats.go v1.49.0 @@ -26,29 +28,47 @@ require ( github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/casbin/govaluate v1.10.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/consensys/gnark-crypto v0.18.1 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v27.3.1+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/mock v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/holiman/uint256 v1.3.2 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect @@ -67,10 +87,12 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect @@ -82,7 +104,6 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -91,7 +112,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - golang.org/x/time v0.5.0 // indirect + golang.org/x/time v0.12.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/pkg/go.sum b/api/pkg/go.sum index e12e7e78..2cbd151a 100644 --- a/api/pkg/go.sum +++ b/api/pkg/go.sum @@ -6,8 +6,14 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= 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/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= @@ -20,19 +26,29 @@ github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0l github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +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/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= +github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= +github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 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/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= @@ -41,10 +57,22 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= +github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= +github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes= +github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -53,6 +81,10 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= @@ -66,18 +98,47 @@ 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/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.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= @@ -86,6 +147,10 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -132,6 +197,10 @@ github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82 github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= 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/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= @@ -146,6 +215,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw= +github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= @@ -178,10 +249,10 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuH go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= @@ -190,8 +261,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= -go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= 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/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -254,8 +325,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -269,8 +340,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= @@ -280,6 +351,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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.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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/pkg/model/client.go b/api/pkg/model/client.go index cbc22b3e..d61c63a5 100644 --- a/api/pkg/model/client.go +++ b/api/pkg/model/client.go @@ -11,6 +11,7 @@ type Client struct { ClientID string `bson:"clientId"` ClientName string `bson:"clientName"` ClientSecret string `bson:"clientSecret,omitempty"` + AllowedCIDRs []string `bson:"allowedCIDRs,omitempty"` AllowedScopes []string `bson:"allowedScopes"` RedirectURIs []string `bson:"redirectURIs"` GrantTypes []string `bson:"grantTypes"` diff --git a/api/pkg/vault/kv/module.go b/api/pkg/vault/kv/module.go new file mode 100644 index 00000000..1b8c961c --- /dev/null +++ b/api/pkg/vault/kv/module.go @@ -0,0 +1,34 @@ +package kv + +import ( + "context" + + "github.com/tech/sendico/pkg/mlogger" +) + +// Config describes Vault KV v2 connection settings. +type Config struct { + Address string `mapstructure:"address" yaml:"address"` + TokenEnv string `mapstructure:"token_env" yaml:"token_env"` + Namespace string `mapstructure:"namespace" yaml:"namespace"` + MountPath string `mapstructure:"mount_path" yaml:"mount_path"` +} + +// Client defines KV operations used by services. +type Client interface { + Put(ctx context.Context, secretPath string, payload map[string]interface{}) error + Get(ctx context.Context, secretPath string) (map[string]interface{}, error) + GetString(ctx context.Context, secretPath, field string) (string, error) +} + +// Options configure KV client creation. +type Options struct { + Logger mlogger.Logger + Config Config + Component string +} + +// New creates a Vault KV v2 client. +func New(opts Options) (Client, error) { + return newService(opts) +} diff --git a/api/pkg/vault/kv/service.go b/api/pkg/vault/kv/service.go new file mode 100644 index 00000000..c10b8432 --- /dev/null +++ b/api/pkg/vault/kv/service.go @@ -0,0 +1,151 @@ +package kv + +import ( + "context" + "os" + "strings" + + "github.com/hashicorp/vault/api" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +const defaultComponent = "vault kv" + +type service struct { + logger mlogger.Logger + component string + store *api.KVv2 +} + +func newService(opts Options) (Client, error) { + logger := opts.Logger + if logger == nil { + logger = zap.NewNop() + } + + component := strings.TrimSpace(opts.Component) + if component == "" { + component = defaultComponent + } + + address := strings.TrimSpace(opts.Config.Address) + if address == "" { + logger.Error("Vault address missing") + return nil, merrors.InvalidArgument(component + ": address is required") + } + + tokenEnv := strings.TrimSpace(opts.Config.TokenEnv) + if tokenEnv == "" { + logger.Error("Vault token env missing") + return nil, merrors.InvalidArgument(component + ": token_env is required") + } + + token := strings.TrimSpace(os.Getenv(tokenEnv)) + if token == "" { + logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv)) + return nil, merrors.InvalidArgument(component + ": token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)") + } + + mountPath := strings.Trim(strings.TrimSpace(opts.Config.MountPath), "/") + if mountPath == "" { + logger.Error("Vault mount path missing") + return nil, merrors.InvalidArgument(component + ": mount_path is required") + } + + clientCfg := api.DefaultConfig() + clientCfg.Address = address + + client, err := api.NewClient(clientCfg) + if err != nil { + logger.Error("Failed to create vault client", zap.Error(err)) + return nil, merrors.Internal(component + ": failed to create client: " + err.Error()) + } + client.SetToken(token) + + if ns := strings.TrimSpace(opts.Config.Namespace); ns != "" { + client.SetNamespace(ns) + } + + return &service{ + logger: logger.Named("vault"), + component: component, + store: client.KVv2(mountPath), + }, nil +} + +func (s *service) Put(ctx context.Context, secretPath string, payload map[string]interface{}) error { + if payload == nil { + return merrors.InvalidArgument(s.component+": payload is required", "payload") + } + + normalizedPath, err := normalizePath(secretPath) + if err != nil { + return merrors.InvalidArgumentWrap(err, s.component+": secret path is invalid", "secret_path") + } + + if _, err := s.store.Put(ctx, normalizedPath, payload); err != nil { + s.logger.Warn("Failed to write secret", zap.String("path", normalizedPath), zap.Error(err)) + return merrors.Internal(s.component + ": failed to write secret at " + normalizedPath + ": " + err.Error()) + } + + return nil +} + +func (s *service) Get(ctx context.Context, secretPath string) (map[string]interface{}, error) { + normalizedPath, err := normalizePath(secretPath) + if err != nil { + return nil, merrors.InvalidArgumentWrap(err, s.component+": secret path is invalid", "secret_path") + } + + secret, err := s.store.Get(ctx, normalizedPath) + if err != nil { + s.logger.Warn("Failed to read secret", zap.String("path", normalizedPath), zap.Error(err)) + return nil, merrors.Internal(s.component + ": failed to read secret at " + normalizedPath + ": " + err.Error()) + } + if secret == nil || secret.Data == nil { + return nil, merrors.NoData(s.component + ": secret " + normalizedPath + " not found") + } + + data := make(map[string]interface{}, len(secret.Data)) + for k, v := range secret.Data { + data[k] = v + } + + return data, nil +} + +func (s *service) GetString(ctx context.Context, secretPath, field string) (string, error) { + field = strings.TrimSpace(field) + if field == "" { + return "", merrors.InvalidArgument(s.component+": field is required", "field") + } + + data, err := s.Get(ctx, secretPath) + if err != nil { + return "", err + } + + val, ok := data[field] + if !ok { + return "", merrors.Internal(s.component + ": secret " + strings.Trim(strings.TrimPrefix(secretPath, "/"), "/") + " missing " + field) + } + + str, ok := val.(string) + if !ok || strings.TrimSpace(str) == "" { + return "", merrors.Internal(s.component + ": secret " + strings.Trim(strings.TrimPrefix(secretPath, "/"), "/") + " invalid " + field) + } + + return str, nil +} + +func normalizePath(secretPath string) (string, error) { + normalizedPath := strings.Trim(strings.TrimPrefix(strings.TrimSpace(secretPath), "/"), "/") + if normalizedPath == "" { + return "", merrors.InvalidArgument("secret path is required", "secret_path") + } + return normalizedPath, nil +} + +var _ Client = (*service)(nil) diff --git a/api/pkg/vault/managedkey/module.go b/api/pkg/vault/managedkey/module.go new file mode 100644 index 00000000..c5006171 --- /dev/null +++ b/api/pkg/vault/managedkey/module.go @@ -0,0 +1,54 @@ +package managedkey + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/tech/sendico/pkg/mlogger" +) + +// Config describes how to connect to Vault for managed wallet keys. +type Config struct { + Address string `mapstructure:"address" yaml:"address"` + TokenEnv string `mapstructure:"token_env" yaml:"token_env"` + Namespace string `mapstructure:"namespace" yaml:"namespace"` + MountPath string `mapstructure:"mount_path" yaml:"mount_path"` + KeyPrefix string `mapstructure:"key_prefix" yaml:"key_prefix"` +} + +// ManagedWalletKey captures metadata returned after key provisioning. +type ManagedWalletKey struct { + KeyID string + Address string + PublicKey string +} + +// Material contains key material loaded from Vault. +type Material struct { + PrivateKey string + PublicKey string + Address string + Network string +} + +// Service defines managed key operations shared by gateways. +type Service interface { + CreateManagedWalletKey(ctx context.Context, walletRef, network string) (*ManagedWalletKey, error) + SignEVMTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) + LoadKeyMaterial(ctx context.Context, keyID string) (*Material, error) + BuildKeyID(network, walletRef string) string +} + +// Options configure managed key service creation. +type Options struct { + Logger mlogger.Logger + Config Config + Component string + DefaultKeyPrefix string +} + +// New creates a managed wallet key service backed by Vault KV. +func New(opts Options) (Service, error) { + return newService(opts) +} diff --git a/api/pkg/vault/managedkey/service.go b/api/pkg/vault/managedkey/service.go new file mode 100644 index 00000000..7f1b49d1 --- /dev/null +++ b/api/pkg/vault/managedkey/service.go @@ -0,0 +1,218 @@ +package managedkey + +import ( + "context" + "encoding/hex" + "math/big" + "path" + "strings" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/vault/kv" + "go.uber.org/zap" +) + +const defaultComponent = "vault key manager" + +type service struct { + logger mlogger.Logger + component string + store kv.Client + keyPrefix string +} + +func newService(opts Options) (Service, error) { + logger := opts.Logger + if logger == nil { + logger = zap.NewNop() + } + + component := strings.TrimSpace(opts.Component) + if component == "" { + component = defaultComponent + } + + store, err := kv.New(kv.Options{ + Logger: logger, + Config: kv.Config{ + Address: opts.Config.Address, + TokenEnv: opts.Config.TokenEnv, + Namespace: opts.Config.Namespace, + MountPath: opts.Config.MountPath, + }, + Component: component, + }) + if err != nil { + return nil, err + } + + keyPrefix := strings.Trim(strings.TrimSpace(opts.Config.KeyPrefix), "/") + if keyPrefix == "" { + keyPrefix = strings.Trim(strings.TrimSpace(opts.DefaultKeyPrefix), "/") + } + if keyPrefix == "" { + keyPrefix = "wallets" + } + + return &service{ + logger: logger.Named("vault"), + component: component, + store: store, + keyPrefix: keyPrefix, + }, nil +} + +func (s *service) CreateManagedWalletKey(ctx context.Context, walletRef, network string) (*ManagedWalletKey, error) { + walletRef = strings.TrimSpace(walletRef) + network = strings.TrimSpace(network) + if walletRef == "" { + s.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network)) + return nil, merrors.InvalidArgument(s.component+": walletRef is required", "wallet_ref") + } + if network == "" { + s.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef)) + return nil, merrors.InvalidArgument(s.component+": network is required", "network") + } + + privateKey, err := crypto.GenerateKey() + if err != nil { + s.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) + return nil, merrors.Internal(s.component + ": failed to generate key: " + err.Error()) + } + + privateKeyBytes := crypto.FromECDSA(privateKey) + publicKey := privateKey.PublicKey + publicKeyBytes := crypto.FromECDSAPub(&publicKey) + publicKeyHex := hex.EncodeToString(publicKeyBytes) + address := strings.ToLower(crypto.PubkeyToAddress(publicKey).Hex()) + keyID := s.BuildKeyID(network, walletRef) + + payload := map[string]interface{}{ + "private_key": hex.EncodeToString(privateKeyBytes), + "public_key": hex.EncodeToString(publicKeyBytes), + "address": address, + "network": strings.ToLower(network), + } + if err := s.store.Put(ctx, keyID, payload); err != nil { + s.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) + zeroBytes(privateKeyBytes) + zeroBytes(publicKeyBytes) + return nil, err + } + + zeroBytes(privateKeyBytes) + zeroBytes(publicKeyBytes) + + s.logger.Info("Managed wallet key created", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.String("address", address)) + + return &ManagedWalletKey{ + KeyID: keyID, + Address: address, + PublicKey: publicKeyHex, + }, nil +} + +func (s *service) SignEVMTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { + if strings.TrimSpace(keyID) == "" { + s.logger.Warn("Signing failed: empty key id") + return nil, merrors.InvalidArgument(s.component+": keyID is required", "key_id") + } + if tx == nil { + s.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID)) + return nil, merrors.InvalidArgument(s.component+": transaction is nil", "transaction") + } + if chainID == nil { + s.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID)) + return nil, merrors.InvalidArgument(s.component+": chainID is nil", "chain_id") + } + + material, err := s.LoadKeyMaterial(ctx, keyID) + if err != nil { + s.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err)) + return nil, err + } + + keyBytes, err := hex.DecodeString(material.PrivateKey) + if err != nil { + s.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err)) + return nil, merrors.Internal(s.component + ": invalid key material: " + err.Error()) + } + defer zeroBytes(keyBytes) + + privateKey, err := crypto.ToECDSA(keyBytes) + if err != nil { + s.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err)) + return nil, merrors.Internal(s.component + ": failed to construct private key: " + err.Error()) + } + + signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey) + if err != nil { + s.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err)) + return nil, merrors.Internal(s.component + ": failed to sign transaction: " + err.Error()) + } + + s.logger.Info("Transaction signed with managed key", zap.String("key_id", keyID), zap.String("network", material.Network), zap.String("tx_hash", signed.Hash().Hex())) + + return signed, nil +} + +func (s *service) LoadKeyMaterial(ctx context.Context, keyID string) (*Material, error) { + data, err := s.store.Get(ctx, keyID) + if err != nil { + return nil, err + } + + secretPath := strings.Trim(strings.TrimPrefix(strings.TrimSpace(keyID), "/"), "/") + privateKey, err := fieldAsString(data, "private_key", secretPath, s.component) + if err != nil { + return nil, err + } + publicKey, err := fieldAsString(data, "public_key", secretPath, s.component) + if err != nil { + return nil, err + } + address, err := fieldAsString(data, "address", secretPath, s.component) + if err != nil { + return nil, err + } + network, err := fieldAsString(data, "network", secretPath, s.component) + if err != nil { + return nil, err + } + + return &Material{ + PrivateKey: privateKey, + PublicKey: publicKey, + Address: address, + Network: network, + }, nil +} + +func (s *service) BuildKeyID(network, walletRef string) string { + net := strings.Trim(strings.ToLower(strings.TrimSpace(network)), "/") + ref := strings.Trim(strings.TrimSpace(walletRef), "/") + return path.Join(s.keyPrefix, net, ref) +} + +func fieldAsString(data map[string]interface{}, field, secretPath, component string) (string, error) { + value, ok := data[field] + if !ok { + return "", merrors.Internal(component + ": secret " + secretPath + " missing " + field) + } + str, ok := value.(string) + if !ok || strings.TrimSpace(str) == "" { + return "", merrors.Internal(component + ": secret " + secretPath + " invalid " + field) + } + return str, nil +} + +func zeroBytes(data []byte) { + for i := range data { + data[i] = 0 + } +} + +var _ Service = (*service)(nil) diff --git a/ci/dev/README.md b/ci/dev/README.md index 6db1378c..2ca81dc9 100644 --- a/ci/dev/README.md +++ b/ci/dev/README.md @@ -12,7 +12,7 @@ Docker Compose + Makefile build system for local development. **Services:** - Discovery, Ledger, Billing Fees, Billing Documents, FX Oracle, Payments Orchestrator - Chain Gateway, MNTX Gateway, TGSettle Gateway -- FX Ingestor, Notification, BFF (Server), Frontend +- FX Ingestor, Notification, BFF (Server), Callbacks, Frontend ## Quick Start @@ -60,10 +60,14 @@ make status # Check service status Examples: - Blockchain private keys (Chain Gateway) - External API keys (MNTX, TGSettle) +- Webhook signing secrets (Callbacks) - Production-like secrets Infrastructure (MongoDB, NATS) uses plain `.env.dev` credentials. +Callbacks, Chain, and TRON run Vault Agent sidecars with AppRole. +Set the corresponding `*_VAULT_ROLE_ID` and `*_VAULT_SECRET_ID` values in `.env.dev`. + ## Network All services on `sendico-dev` network. Vault also on `cicd` network to connect to infra Vault if needed. diff --git a/ci/dev/callbacks.dockerfile b/ci/dev/callbacks.dockerfile new file mode 100644 index 00000000..6179c603 --- /dev/null +++ b/ci/dev/callbacks.dockerfile @@ -0,0 +1,39 @@ +# Development Dockerfile for Callbacks Service with Air hot reload + +FROM golang:alpine AS builder + +RUN apk add --no-cache bash git build-base protoc protobuf-dev && \ + go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \ + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \ + go install github.com/air-verse/air@latest + +WORKDIR /src + +COPY api/proto ./api/proto +COPY api/pkg ./api/pkg +COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ +RUN bash ci/scripts/proto/generate.sh + +# Runtime stage for development with Air +FROM golang:alpine + +RUN apk add --no-cache bash git build-base && \ + go install github.com/air-verse/air@latest + +WORKDIR /src + +# Copy generated proto and pkg from builder +COPY --from=builder /src/api/proto ./api/proto +COPY --from=builder /src/api/pkg ./api/pkg + +# Copy vault-aware entrypoint wrapper +COPY api/edge/callbacks/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Source code will be mounted at runtime +WORKDIR /src/api/edge/callbacks + +EXPOSE 9420 + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["air", "-c", ".air.toml", "--", "-config.file", "/app/config.yml", "-debug"] diff --git a/ci/dev/vault-agent/callbacks.hcl b/ci/dev/vault-agent/callbacks.hcl new file mode 100644 index 00000000..2dcc683a --- /dev/null +++ b/ci/dev/vault-agent/callbacks.hcl @@ -0,0 +1,20 @@ +vault { + address = "http://dev-vault:8200" +} + +auto_auth { + method "approle" { + mount_path = "auth/approle" + config = { + role_id_file_path = "/run/vault/role_id" + secret_id_file_path = "/run/vault/secret_id" + } + } + + sink "file" { + config = { + path = "/run/vault/token" + mode = 0600 + } + } +} diff --git a/ci/prod/.env.runtime b/ci/prod/.env.runtime index 268cd7e0..fb8128e7 100644 --- a/ci/prod/.env.runtime +++ b/ci/prod/.env.runtime @@ -182,6 +182,20 @@ BFF_COMPOSE_PROJECT=sendico-bff BFF_SERVICE_NAME=sendico_bff BFF_HTTP_PORT=8080 +# Callbacks service +CALLBACKS_DIR=callbacks +CALLBACKS_COMPOSE_PROJECT=sendico-callbacks +CALLBACKS_SERVICE_NAME=sendico_callbacks +CALLBACKS_METRICS_PORT=9420 +CALLBACKS_VAULT_SECRET_PATH=sendico/edge/callbacks/vault + +# Callbacks Mongo settings +CALLBACKS_MONGO_HOST=sendico_db1 +CALLBACKS_MONGO_PORT=27017 +CALLBACKS_MONGO_DATABASE=callbacks +CALLBACKS_MONGO_AUTH_SOURCE=admin +CALLBACKS_MONGO_REPLICA_SET=sendico-rs + # Chain gateway stack CHAIN_GATEWAY_DIR=chain_gateway CHAIN_GATEWAY_COMPOSE_PROJECT=sendico-chain-gateway diff --git a/ci/prod/compose/callbacks.dockerfile b/ci/prod/compose/callbacks.dockerfile new file mode 100644 index 00000000..c9d816ff --- /dev/null +++ b/ci/prod/compose/callbacks.dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1.7 + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +FROM golang:alpine AS build +ARG APP_VERSION=dev +ARG GIT_REV=unknown +ARG BUILD_BRANCH=unknown +ARG BUILD_DATE=unknown +ARG BUILD_USER=ci +ENV GO111MODULE=on +ENV PATH="/go/bin:${PATH}" +WORKDIR /src +COPY . . +RUN apk add --no-cache bash git build-base protoc protobuf-dev \ + && go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \ + && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest \ + && bash ci/scripts/proto/generate.sh +WORKDIR /src/api/edge/callbacks +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags "\ + -s -w \ + -X github.com/tech/sendico/edge/callbacks/internal/appversion.Version=${APP_VERSION} \ + -X github.com/tech/sendico/edge/callbacks/internal/appversion.Revision=${GIT_REV} \ + -X github.com/tech/sendico/edge/callbacks/internal/appversion.Branch=${BUILD_BRANCH} \ + -X github.com/tech/sendico/edge/callbacks/internal/appversion.BuildUser=${BUILD_USER} \ + -X github.com/tech/sendico/edge/callbacks/internal/appversion.BuildDate=${BUILD_DATE}" \ + -o /out/callbacks . + +FROM alpine:latest AS runtime +RUN apk add --no-cache ca-certificates tzdata wget +WORKDIR /app +COPY api/edge/callbacks/config.yml /app/config.yml +COPY api/edge/callbacks/entrypoint.sh /app/entrypoint.sh +COPY --from=build /out/callbacks /app/callbacks +RUN chmod +x /app/entrypoint.sh +EXPOSE 9420 +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["/app/callbacks","--config.file","/app/config.yml"] diff --git a/ci/prod/compose/callbacks.yml b/ci/prod/compose/callbacks.yml new file mode 100644 index 00000000..d368caca --- /dev/null +++ b/ci/prod/compose/callbacks.yml @@ -0,0 +1,88 @@ +# Compose v2 - Edge Callbacks + +x-common-env: &common-env + env_file: + - ../env/.env.runtime + - ../env/.env.version + +volumes: + callbacks-vault-run: + driver: local + driver_opts: + type: tmpfs + device: tmpfs + o: size=8m,uid=0,gid=0,mode=0700 + +networks: + sendico-net: + external: true + name: sendico-net + +services: + sendico_callbacks: + <<: *common-env + container_name: sendico-callbacks + restart: unless-stopped + image: ${REGISTRY_URL}/edge/callbacks:${APP_V} + pull_policy: always + environment: + CALLBACKS_MONGO_HOST: ${CALLBACKS_MONGO_HOST} + CALLBACKS_MONGO_PORT: ${CALLBACKS_MONGO_PORT} + CALLBACKS_MONGO_DATABASE: ${CALLBACKS_MONGO_DATABASE} + CALLBACKS_MONGO_USER: ${CALLBACKS_MONGO_USER} + CALLBACKS_MONGO_PASSWORD: ${CALLBACKS_MONGO_PASSWORD} + CALLBACKS_MONGO_AUTH_SOURCE: ${CALLBACKS_MONGO_AUTH_SOURCE} + CALLBACKS_MONGO_REPLICA_SET: ${CALLBACKS_MONGO_REPLICA_SET} + NATS_URL: ${NATS_URL} + NATS_HOST: ${NATS_HOST} + NATS_PORT: ${NATS_PORT} + NATS_USER: ${NATS_USER} + NATS_PASSWORD: ${NATS_PASSWORD} + CALLBACKS_METRICS_PORT: ${CALLBACKS_METRICS_PORT} + VAULT_ADDR: ${VAULT_ADDR} + VAULT_TOKEN_FILE: /run/vault/token + command: ["--config.file", "/app/config.yml"] + ports: + - "0.0.0.0:${CALLBACKS_METRICS_PORT}:${CALLBACKS_METRICS_PORT}" + volumes: + - callbacks-vault-run:/run/vault:ro + depends_on: + sendico_callbacks_vault_agent: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL","wget -qO- http://localhost:${CALLBACKS_METRICS_PORT}/health | grep -q '\"status\":\"ok\"'"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - sendico-net + + sendico_callbacks_vault_agent: + <<: *common-env + container_name: sendico-callbacks-vault-agent + restart: unless-stopped + image: hashicorp/vault:latest + pull_policy: always + cap_add: ["IPC_LOCK"] + environment: + VAULT_ADDR: ${VAULT_ADDR} + CALLBACKS_VAULT_ROLE_ID: ${CALLBACKS_VAULT_ROLE_ID} + CALLBACKS_VAULT_SECRET_ID: ${CALLBACKS_VAULT_SECRET_ID} + command: > + sh -lc 'set -euo pipefail; umask 077; + : "${CALLBACKS_VAULT_ROLE_ID:?}"; : "${CALLBACKS_VAULT_SECRET_ID:?}"; + printf "%s" "$CALLBACKS_VAULT_ROLE_ID" > /run/vault/role_id; + printf "%s" "$CALLBACKS_VAULT_SECRET_ID" > /run/vault/secret_id; + unset CALLBACKS_VAULT_ROLE_ID CALLBACKS_VAULT_SECRET_ID; + exec vault agent -config=/etc/vault/agent/callbacks.hcl' + volumes: + - ./vault-agent/callbacks.hcl:/etc/vault/agent/callbacks.hcl:ro + - callbacks-vault-run:/run/vault + healthcheck: + test: ["CMD","test","-s","/run/vault/token"] + interval: 10s + timeout: 5s + retries: 6 + networks: + - sendico-net diff --git a/ci/prod/compose/vault-agent/callbacks.hcl b/ci/prod/compose/vault-agent/callbacks.hcl new file mode 100644 index 00000000..3dbfc804 --- /dev/null +++ b/ci/prod/compose/vault-agent/callbacks.hcl @@ -0,0 +1,20 @@ +vault { + address = "https://vault.sendico.io" +} + +auto_auth { + method "approle" { + mount_path = "auth/approle" + config = { + role_id_file_path = "/run/vault/role_id" + secret_id_file_path = "/run/vault/secret_id" + } + } + + sink "file" { + config = { + path = "/run/vault/token" + mode = 0600 + } + } +} diff --git a/ci/prod/scripts/deploy/callbacks.sh b/ci/prod/scripts/deploy/callbacks.sh new file mode 100755 index 00000000..c270769d --- /dev/null +++ b/ci/prod/scripts/deploy/callbacks.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && set -x +trap 'echo "[deploy-callbacks] error at line $LINENO" >&2' ERR + +: "${REMOTE_BASE:?missing REMOTE_BASE}" +: "${SSH_USER:?missing SSH_USER}" +: "${SSH_HOST:?missing SSH_HOST}" +: "${CALLBACKS_DIR:?missing CALLBACKS_DIR}" +: "${CALLBACKS_COMPOSE_PROJECT:?missing CALLBACKS_COMPOSE_PROJECT}" +: "${CALLBACKS_SERVICE_NAME:?missing CALLBACKS_SERVICE_NAME}" + +REMOTE_DIR="${REMOTE_BASE%/}/${CALLBACKS_DIR}" +REMOTE_TARGET="${SSH_USER}@${SSH_HOST}" +COMPOSE_FILE="callbacks.yml" +SERVICE_NAMES="${CALLBACKS_SERVICE_NAME}" + +REQUIRED_SECRETS=( + CALLBACKS_MONGO_USER + CALLBACKS_MONGO_PASSWORD + CALLBACKS_VAULT_ROLE_ID + CALLBACKS_VAULT_SECRET_ID + NATS_USER + NATS_PASSWORD + NATS_URL +) + +for var in "${REQUIRED_SECRETS[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "missing required secret env: ${var}" >&2 + exit 65 + fi +done + +if [[ ! -s .env.version ]]; then + echo ".env.version is missing; run version step first" >&2 + exit 66 +fi + +b64enc() { + printf '%s' "$1" | base64 | tr -d '\n' +} + +CALLBACKS_MONGO_USER_B64="$(b64enc "${CALLBACKS_MONGO_USER}")" +CALLBACKS_MONGO_PASSWORD_B64="$(b64enc "${CALLBACKS_MONGO_PASSWORD}")" +CALLBACKS_VAULT_ROLE_ID_B64="$(b64enc "${CALLBACKS_VAULT_ROLE_ID}")" +CALLBACKS_VAULT_SECRET_ID_B64="$(b64enc "${CALLBACKS_VAULT_SECRET_ID}")" +NATS_USER_B64="$(b64enc "${NATS_USER}")" +NATS_PASSWORD_B64="$(b64enc "${NATS_PASSWORD}")" +NATS_URL_B64="$(b64enc "${NATS_URL}")" + +SSH_OPTS=( + -i /root/.ssh/id_rsa + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o LogLevel=ERROR + -q +) +if [[ "${DEBUG_DEPLOY:-0}" = "1" ]]; then + SSH_OPTS=("${SSH_OPTS[@]/-q/}" -vv) +fi + +RSYNC_FLAGS=(-az --delete) +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && RSYNC_FLAGS=(-avz --delete) + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "mkdir -p ${REMOTE_DIR}/{compose,env}" + +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/compose/ "$REMOTE_TARGET:${REMOTE_DIR}/compose/" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/.env.runtime "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.runtime" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" .env.version "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.version" + +SERVICES_LINE="${SERVICE_NAMES}" + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \ + REMOTE_DIR="$REMOTE_DIR" \ + COMPOSE_FILE="$COMPOSE_FILE" \ + COMPOSE_PROJECT="$CALLBACKS_COMPOSE_PROJECT" \ + SERVICES_LINE="$SERVICES_LINE" \ + CALLBACKS_MONGO_USER_B64="$CALLBACKS_MONGO_USER_B64" \ + CALLBACKS_MONGO_PASSWORD_B64="$CALLBACKS_MONGO_PASSWORD_B64" \ + CALLBACKS_VAULT_ROLE_ID_B64="$CALLBACKS_VAULT_ROLE_ID_B64" \ + CALLBACKS_VAULT_SECRET_ID_B64="$CALLBACKS_VAULT_SECRET_ID_B64" \ + NATS_USER_B64="$NATS_USER_B64" \ + NATS_PASSWORD_B64="$NATS_PASSWORD_B64" \ + NATS_URL_B64="$NATS_URL_B64" \ + bash -s <<'EOSSH' +set -euo pipefail +cd "${REMOTE_DIR}/compose" +set -a +. ../env/.env.runtime +load_kv_file() { + local file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + if printf '%s' "$line" | grep -Eq '^[[:alpha:]_][[:alnum:]_]*='; then + local key="${line%%=*}" + local value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ -n "$key" ]]; then + export "$key=$value" + fi + fi + done <"$file" +} +load_kv_file ../env/.env.version +set +a + +if base64 -d >/dev/null 2>&1 <<<'AA=='; then + BASE64_DECODE_FLAG='-d' +else + BASE64_DECODE_FLAG='--decode' +fi + +decode_b64() { + val="$1" + if [[ -z "$val" ]]; then + printf '' + return + fi + printf '%s' "$val" | base64 "${BASE64_DECODE_FLAG}" +} + +CALLBACKS_MONGO_USER="$(decode_b64 "$CALLBACKS_MONGO_USER_B64")" +CALLBACKS_MONGO_PASSWORD="$(decode_b64 "$CALLBACKS_MONGO_PASSWORD_B64")" +CALLBACKS_VAULT_ROLE_ID="$(decode_b64 "$CALLBACKS_VAULT_ROLE_ID_B64")" +CALLBACKS_VAULT_SECRET_ID="$(decode_b64 "$CALLBACKS_VAULT_SECRET_ID_B64")" +NATS_USER="$(decode_b64 "$NATS_USER_B64")" +NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")" +NATS_URL="$(decode_b64 "$NATS_URL_B64")" + +export CALLBACKS_MONGO_USER CALLBACKS_MONGO_PASSWORD +export CALLBACKS_VAULT_ROLE_ID CALLBACKS_VAULT_SECRET_ID +export NATS_USER NATS_PASSWORD NATS_URL +COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT" +export COMPOSE_PROJECT_NAME +read -r -a SERVICES <<<"${SERVICES_LINE}" + +pull_cmd=(docker compose -f "$COMPOSE_FILE" pull) +up_cmd=(docker compose -f "$COMPOSE_FILE" up -d --remove-orphans) +ps_cmd=(docker compose -f "$COMPOSE_FILE" ps) +if [[ "${#SERVICES[@]}" -gt 0 ]]; then + pull_cmd+=("${SERVICES[@]}") + up_cmd+=("${SERVICES[@]}") + ps_cmd+=("${SERVICES[@]}") +fi + +"${pull_cmd[@]}" +"${up_cmd[@]}" +"${ps_cmd[@]}" + +date -Is > .last_deploy +logger -t "deploy-${COMPOSE_PROJECT_NAME}" "${COMPOSE_PROJECT_NAME} deployed at $(date -Is) in ${REMOTE_DIR}" +EOSSH diff --git a/ci/scripts/callbacks/build-image.sh b/ci/scripts/callbacks/build-image.sh new file mode 100755 index 00000000..5728bdf3 --- /dev/null +++ b/ci/scripts/callbacks/build-image.sh @@ -0,0 +1,85 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +CALLBACKS_ENV_NAME="${CALLBACKS_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${CALLBACKS_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[callbacks-build] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +REGISTRY_URL="${REGISTRY_URL:?missing REGISTRY_URL}" +APP_V="${APP_V:?missing APP_V}" +CALLBACKS_DOCKERFILE="${CALLBACKS_DOCKERFILE:?missing CALLBACKS_DOCKERFILE}" +CALLBACKS_IMAGE_PATH="${CALLBACKS_IMAGE_PATH:?missing CALLBACKS_IMAGE_PATH}" + +REGISTRY_HOST="${REGISTRY_URL#http://}" +REGISTRY_HOST="${REGISTRY_HOST#https://}" +REGISTRY_USER="$(cat secrets/REGISTRY_USER)" +REGISTRY_PASSWORD="$(cat secrets/REGISTRY_PASSWORD)" +: "${REGISTRY_USER:?missing registry user}" +: "${REGISTRY_PASSWORD:?missing registry password}" + +mkdir -p /kaniko/.docker +AUTH_B64="$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')" +cat </kaniko/.docker/config.json +{ + "auths": { + "https://${REGISTRY_HOST}": { "auth": "${AUTH_B64}" } + } +} +JSON + +BUILD_CONTEXT="${CALLBACKS_BUILD_CONTEXT:-${WOODPECKER_WORKSPACE:-${CI_WORKSPACE:-${PWD:-/workspace}}}}" +if [ ! -d "${BUILD_CONTEXT}" ]; then + BUILD_CONTEXT="/workspace" +fi + +/kaniko/executor \ + --context "${BUILD_CONTEXT}" \ + --dockerfile "${CALLBACKS_DOCKERFILE}" \ + --destination "${REGISTRY_URL}/${CALLBACKS_IMAGE_PATH}:${APP_V}" \ + --build-arg APP_VERSION="${APP_V}" \ + --build-arg GIT_REV="${GIT_REV}" \ + --build-arg BUILD_BRANCH="${BUILD_BRANCH}" \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + --build-arg BUILD_USER="${BUILD_USER}" \ + --single-snapshot diff --git a/ci/scripts/callbacks/deploy.sh b/ci/scripts/callbacks/deploy.sh new file mode 100755 index 00000000..cee375ba --- /dev/null +++ b/ci/scripts/callbacks/deploy.sh @@ -0,0 +1,66 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +. ci/scripts/common/nats_env.sh + +CALLBACKS_ENV_NAME="${CALLBACKS_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${CALLBACKS_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[callbacks-deploy] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +CALLBACKS_MONGO_SECRET_PATH="${CALLBACKS_MONGO_SECRET_PATH:?missing CALLBACKS_MONGO_SECRET_PATH}" +CALLBACKS_VAULT_SECRET_PATH="${CALLBACKS_VAULT_SECRET_PATH:?missing CALLBACKS_VAULT_SECRET_PATH}" + +export CALLBACKS_MONGO_USER="$(./ci/vlt kv_get kv "${CALLBACKS_MONGO_SECRET_PATH}" user)" +export CALLBACKS_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${CALLBACKS_MONGO_SECRET_PATH}" password)" +export CALLBACKS_VAULT_ROLE_ID="$(./ci/vlt kv_get kv "${CALLBACKS_VAULT_SECRET_PATH}" role_id)" +export CALLBACKS_VAULT_SECRET_ID="$(./ci/vlt kv_get kv "${CALLBACKS_VAULT_SECRET_PATH}" secret_id)" +if [ -z "${CALLBACKS_VAULT_ROLE_ID}" ] || [ -z "${CALLBACKS_VAULT_SECRET_ID}" ]; then + echo "[callbacks-deploy] vault approle creds are empty for path ${CALLBACKS_VAULT_SECRET_PATH}" >&2 + exit 1 +fi + +load_nats_env + +bash ci/prod/scripts/bootstrap/network.sh +bash ci/prod/scripts/deploy/callbacks.sh diff --git a/ci/scripts/common/run_backend_tests.sh b/ci/scripts/common/run_backend_tests.sh index 2319046c..b35a8c2c 100755 --- a/ci/scripts/common/run_backend_tests.sh +++ b/ci/scripts/common/run_backend_tests.sh @@ -33,6 +33,12 @@ case "${SERVICE}" in modules=" api/pkg api/edge/bff +" + ;; + callbacks) + modules=" +api/pkg +api/edge/callbacks " ;; billing_documents) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 46b330a1..07773287 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -29,6 +29,12 @@ volumes: type: tmpfs device: tmpfs o: size=8m,uid=0,gid=0,mode=0700 + dev-callbacks-vault-run: + driver: local + driver_opts: + type: tmpfs + device: tmpfs + o: size=8m,uid=0,gid=0,mode=0700 # ============================================================================ # INFRASTRUCTURE SERVICES @@ -843,6 +849,80 @@ services: MONGO_AUTH_SOURCE: admin MONGO_REPLICA_SET: dev-rs + # -------------------------------------------------------------------------- + # Callbacks Vault Agent (sidecar for AppRole authentication) + # -------------------------------------------------------------------------- + dev-callbacks-vault-agent: + <<: *common-env + image: hashicorp/vault:latest + container_name: dev-callbacks-vault-agent + restart: unless-stopped + cap_add: ["IPC_LOCK"] + environment: + VAULT_ADDR: ${VAULT_ADDR} + CALLBACKS_VAULT_ROLE_ID: ${CALLBACKS_VAULT_ROLE_ID} + CALLBACKS_VAULT_SECRET_ID: ${CALLBACKS_VAULT_SECRET_ID} + command: > + sh -c 'set -eu; umask 077; + : "$$CALLBACKS_VAULT_ROLE_ID"; : "$$CALLBACKS_VAULT_SECRET_ID"; + echo "$$CALLBACKS_VAULT_ROLE_ID" > /run/vault/role_id; + echo "$$CALLBACKS_VAULT_SECRET_ID" > /run/vault/secret_id; + unset CALLBACKS_VAULT_ROLE_ID CALLBACKS_VAULT_SECRET_ID; + exec vault agent -config=/etc/vault/agent/callbacks.hcl' + volumes: + - ./ci/dev/vault-agent/callbacks.hcl:/etc/vault/agent/callbacks.hcl:ro + - dev-callbacks-vault-run:/run/vault + depends_on: + dev-vault: { condition: service_healthy } + healthcheck: + test: ["CMD", "test", "-s", "/run/vault/token"] + interval: 10s + timeout: 5s + retries: 6 + networks: + - sendico-dev + + # -------------------------------------------------------------------------- + # Callbacks Service + # -------------------------------------------------------------------------- + dev-callbacks: + <<: *common-env + build: + context: . + dockerfile: ci/dev/callbacks.dockerfile + image: sendico-dev/callbacks:latest + container_name: dev-callbacks + restart: unless-stopped + depends_on: + dev-mongo-init: { condition: service_completed_successfully } + dev-nats: { condition: service_started } + dev-vault: { condition: service_healthy } + dev-callbacks-vault-agent: { condition: service_healthy } + volumes: + - ./api/edge/callbacks:/src/api/edge/callbacks + - ./api/edge/callbacks/config.dev.yml:/app/config.yml:ro + - dev-callbacks-vault-run:/run/vault:ro + ports: + - "9420:9420" + networks: + - sendico-dev + environment: + CALLBACKS_MONGO_HOST: dev-mongo-1 + CALLBACKS_MONGO_PORT: 27017 + CALLBACKS_MONGO_DATABASE: callbacks + CALLBACKS_MONGO_USER: ${MONGO_USER} + CALLBACKS_MONGO_PASSWORD: ${MONGO_PASSWORD} + CALLBACKS_MONGO_AUTH_SOURCE: admin + CALLBACKS_MONGO_REPLICA_SET: dev-rs + NATS_HOST: dev-nats + NATS_PORT: 4222 + NATS_USER: ${NATS_USER} + NATS_PASSWORD: ${NATS_PASSWORD} + NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222 + CALLBACKS_METRICS_PORT: 9420 + VAULT_ADDR: ${VAULT_ADDR} + VAULT_TOKEN_FILE: /run/vault/token + # -------------------------------------------------------------------------- # BFF (Backend for Frontend / Server) Service # -------------------------------------------------------------------------- -- 2.49.1 From 4c3132bbc1b5bdd733cbb25de9fdc328c2df2430 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 28 Feb 2026 10:12:54 +0100 Subject: [PATCH 2/2] versions bump --- api/billing/documents/go.mod | 2 +- api/billing/documents/go.sum | 4 ++-- api/edge/bff/go.mod | 5 ++--- api/edge/bff/go.sum | 14 ++++++++------ api/edge/callbacks/go.mod | 2 +- api/edge/callbacks/go.sum | 4 ++-- api/gateway/tron/go.mod | 4 ++-- api/gateway/tron/go.sum | 11 ++++------- api/pkg/go.mod | 16 ++++++++-------- api/pkg/go.sum | 36 ++++++++++++++++++------------------ 10 files changed, 48 insertions(+), 50 deletions(-) diff --git a/api/billing/documents/go.mod b/api/billing/documents/go.mod index 3d84c5ab..94516a9d 100644 --- a/api/billing/documents/go.mod +++ b/api/billing/documents/go.mod @@ -34,7 +34,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect - github.com/aws/smithy-go v1.24.1 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/casbin/casbin/v2 v2.135.0 // indirect diff --git a/api/billing/documents/go.sum b/api/billing/documents/go.sum index 01ebca6d..be78b778 100644 --- a/api/billing/documents/go.sum +++ b/api/billing/documents/go.sum @@ -40,8 +40,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWA github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= -github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= -github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= diff --git a/api/edge/bff/go.mod b/api/edge/bff/go.mod index d3ae3102..c6323393 100644 --- a/api/edge/bff/go.mod +++ b/api/edge/bff/go.mod @@ -67,7 +67,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect - github.com/aws/smithy-go v1.24.1 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -135,11 +135,10 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.48.0 // indirect diff --git a/api/edge/bff/go.sum b/api/edge/bff/go.sum index d17cbdc0..fac4f003 100644 --- a/api/edge/bff/go.sum +++ b/api/edge/bff/go.sum @@ -42,8 +42,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWA github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= -github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= -github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -59,6 +59,8 @@ github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0l github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +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/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= @@ -119,8 +121,8 @@ 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/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.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= @@ -265,8 +267,8 @@ go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= diff --git a/api/edge/callbacks/go.mod b/api/edge/callbacks/go.mod index 35bebcf4..f8d007ed 100644 --- a/api/edge/callbacks/go.mod +++ b/api/edge/callbacks/go.mod @@ -57,7 +57,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - golang.org/x/time v0.12.0 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/api/edge/callbacks/go.sum b/api/edge/callbacks/go.sum index b860b3c9..d41ab1be 100644 --- a/api/edge/callbacks/go.sum +++ b/api/edge/callbacks/go.sum @@ -234,8 +234,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index 25b45cc0..8576a3cf 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -9,7 +9,7 @@ replace github.com/tech/sendico/gateway/common => ../common require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 github.com/ethereum/go-ethereum v1.17.0 - github.com/fbsobreira/gotron-sdk v0.24.1 + github.com/fbsobreira/gotron-sdk v0.24.2 github.com/mitchellh/mapstructure v1.5.0 github.com/prometheus/client_golang v1.23.2 github.com/shengdoushi/base58 v1.0.0 @@ -42,6 +42,7 @@ require ( github.com/deckarep/golang-set v1.8.0 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect + github.com/fbsobreira/go-bip39 v1.2.0 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -80,7 +81,6 @@ require ( github.com/supranational/blst v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect - github.com/tyler-smith/go-bip39 v1.1.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum index ac4d8360..84f6a8a8 100644 --- a/api/gateway/tron/go.sum +++ b/api/gateway/tron/go.sum @@ -84,8 +84,10 @@ github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kR github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/fbsobreira/gotron-sdk v0.24.1 h1:YxvF26zyXNkho1GxywQeq/gRi70aQ6sbWYop6OTWL7E= -github.com/fbsobreira/gotron-sdk v0.24.1/go.mod h1:6E0ac5F3fsVlw+HgfZRAUWl2AkIVuOKvYYtDp7pqbYw= +github.com/fbsobreira/go-bip39 v1.2.0 h1:zp3VDGrQeGu8/iPB5wsHVSaOwQhBSLR71CE3nJVz4mY= +github.com/fbsobreira/go-bip39 v1.2.0/go.mod h1:PRuO9kYh4Kn+tRALmXYtbizPeD8G2qm8FTVgxDaiXTM= +github.com/fbsobreira/gotron-sdk v0.24.2 h1:E2kXEhn+b49D2eaBiv3x9FOHfzi3jcfBbqUFFGJmDxI= +github.com/fbsobreira/gotron-sdk v0.24.2/go.mod h1:3YuSi4qc3lJ48uooeTaEawXJvuZQ/wZ/rQQo+X/PPxA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= @@ -285,8 +287,6 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= -github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= -github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= @@ -327,7 +327,6 @@ go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= @@ -335,7 +334,6 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -347,7 +345,6 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/api/pkg/go.mod b/api/pkg/go.mod index 4d7fef3f..fcb75f09 100644 --- a/api/pkg/go.mod +++ b/api/pkg/go.mod @@ -28,25 +28,25 @@ require ( github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bits-and-blooms/bitset v1.20.0 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/casbin/govaluate v1.10.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/consensys/gnark-crypto v0.18.1 // indirect + github.com/consensys/gnark-crypto v0.19.2 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v27.3.1+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -92,7 +92,7 @@ require ( github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect + github.com/supranational/blst v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect @@ -112,7 +112,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - golang.org/x/time v0.12.0 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/pkg/go.sum b/api/pkg/go.sum index 2cbd151a..85cd3d70 100644 --- a/api/pkg/go.sum +++ b/api/pkg/go.sum @@ -6,14 +6,14 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc h1:1stW1OipdBj8Me+nj26SzT8yil7OYve0r3cWobzk1JQ= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= 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/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= -github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= @@ -30,25 +30,25 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x 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/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= -github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= +github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80= +github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= -github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= -github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 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/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= -github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= @@ -59,8 +59,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= -github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= -github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= +github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= +github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes= github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -215,8 +215,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw= -github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= +github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= @@ -325,8 +325,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -- 2.49.1