Merge pull request 'cb-582' (#583) from cb-582 into main
Some checks failed
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline failed
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful

Reviewed-on: #583
This commit was merged in pull request #583.
This commit is contained in:
2026-02-28 09:17:37 +00:00
76 changed files with 5231 additions and 467 deletions

90
.woodpecker/callbacks.yml Normal file
View File

@@ -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

View File

@@ -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)"

View File

@@ -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="<callbacks-role-id>" \
secret_id="<callbacks-secret-id>"
```
Store webhook signing secrets (example path consumed by `secret_ref`):
```bash
vault kv put kv/callbacks/client-a/webhook secret="super-secret"
```

View File

@@ -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

View File

@@ -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=

View File

@@ -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

View File

@@ -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=

View File

@@ -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

3
api/edge/callbacks/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
app
.gocache
tmp

View File

@@ -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

View File

@@ -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

View File

@@ -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 "$@"

64
api/edge/callbacks/go.mod Normal file
View File

@@ -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.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
)

256
api/edge/callbacks/go.sum Normal file
View File

@@ -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.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=
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=

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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())
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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(),
}
}

View File

@@ -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{}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -9,8 +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/hashicorp/vault/api v1.22.0
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
@@ -43,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
@@ -59,6 +59,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
@@ -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

View File

@@ -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=

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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-20260225065256-91dd007ecddc // indirect
github.com/beorn7/perks v1.0.1 // 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.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.5.0 // indirect
github.com/davecgh/go-spew v1.1.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.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
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 // 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.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -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-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.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=
@@ -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.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.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.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=
@@ -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.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=
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 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=
@@ -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.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=
@@ -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=

View File

@@ -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"`

View File

@@ -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)
}

151
api/pkg/vault/kv/service.go Normal file
View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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.

View File

@@ -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"]

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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"]

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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 <<JSON >/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

66
ci/scripts/callbacks/deploy.sh Executable file
View File

@@ -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

View File

@@ -33,6 +33,12 @@ case "${SERVICE}" in
modules="
api/pkg
api/edge/bff
"
;;
callbacks)
modules="
api/pkg
api/edge/callbacks
"
;;
billing_documents)

View File

@@ -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
# --------------------------------------------------------------------------