callbacks service draft
This commit is contained in:
46
api/edge/callbacks/.air.toml
Normal file
46
api/edge/callbacks/.air.toml
Normal 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
3
api/edge/callbacks/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
app
|
||||
.gocache
|
||||
tmp
|
||||
64
api/edge/callbacks/config.dev.yml
Normal file
64
api/edge/callbacks/config.dev.yml
Normal 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
|
||||
63
api/edge/callbacks/config.yml
Normal file
63
api/edge/callbacks/config.yml
Normal 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
|
||||
15
api/edge/callbacks/entrypoint.sh
Executable file
15
api/edge/callbacks/entrypoint.sh
Executable 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
64
api/edge/callbacks/go.mod
Normal 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.12.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/grpc v1.79.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
256
api/edge/callbacks/go.sum
Normal file
256
api/edge/callbacks/go.sum
Normal 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.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
28
api/edge/callbacks/internal/appversion/version.go
Normal file
28
api/edge/callbacks/internal/appversion/version.go
Normal 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)
|
||||
}
|
||||
182
api/edge/callbacks/internal/config/module.go
Normal file
182
api/edge/callbacks/internal/config/module.go
Normal 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
|
||||
}
|
||||
162
api/edge/callbacks/internal/config/service.go
Normal file
162
api/edge/callbacks/internal/config/service.go
Normal 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
|
||||
}
|
||||
27
api/edge/callbacks/internal/delivery/classifier.go
Normal file
27
api/edge/callbacks/internal/delivery/classifier.go
Normal 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
|
||||
}
|
||||
48
api/edge/callbacks/internal/delivery/module.go
Normal file
48
api/edge/callbacks/internal/delivery/module.go
Normal 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)
|
||||
}
|
||||
263
api/edge/callbacks/internal/delivery/service.go
Normal file
263
api/edge/callbacks/internal/delivery/service.go
Normal 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()
|
||||
}
|
||||
33
api/edge/callbacks/internal/events/module.go
Normal file
33
api/edge/callbacks/internal/events/module.go
Normal 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"`
|
||||
}
|
||||
86
api/edge/callbacks/internal/events/service.go
Normal file
86
api/edge/callbacks/internal/events/service.go
Normal 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
|
||||
}
|
||||
51
api/edge/callbacks/internal/ingest/module.go
Normal file
51
api/edge/callbacks/internal/ingest/module.go
Normal 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)
|
||||
}
|
||||
204
api/edge/callbacks/internal/ingest/service.go
Normal file
204
api/edge/callbacks/internal/ingest/service.go
Normal 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
|
||||
}
|
||||
}
|
||||
36
api/edge/callbacks/internal/ops/module.go
Normal file
36
api/edge/callbacks/internal/ops/module.go
Normal 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)
|
||||
}
|
||||
119
api/edge/callbacks/internal/ops/server.go
Normal file
119
api/edge/callbacks/internal/ops/server.go
Normal 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")
|
||||
}
|
||||
}
|
||||
75
api/edge/callbacks/internal/ops/service.go
Normal file
75
api/edge/callbacks/internal/ops/service.go
Normal 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())
|
||||
}
|
||||
8
api/edge/callbacks/internal/retry/module.go
Normal file
8
api/edge/callbacks/internal/retry/module.go
Normal 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
|
||||
}
|
||||
59
api/edge/callbacks/internal/retry/service.go
Normal file
59
api/edge/callbacks/internal/retry/service.go
Normal 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)
|
||||
}
|
||||
33
api/edge/callbacks/internal/secrets/module.go
Normal file
33
api/edge/callbacks/internal/secrets/module.go
Normal 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)
|
||||
}
|
||||
224
api/edge/callbacks/internal/secrets/service.go
Normal file
224
api/edge/callbacks/internal/secrets/service.go
Normal 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()
|
||||
}
|
||||
16
api/edge/callbacks/internal/security/module.go
Normal file
16
api/edge/callbacks/internal/security/module.go
Normal 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
|
||||
}
|
||||
163
api/edge/callbacks/internal/security/service.go
Normal file
163
api/edge/callbacks/internal/security/service.go
Normal 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
|
||||
}
|
||||
271
api/edge/callbacks/internal/server/internal/serverimp.go
Normal file
271
api/edge/callbacks/internal/server/internal/serverimp.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
37
api/edge/callbacks/internal/server/internal/types.go
Normal file
37
api/edge/callbacks/internal/server/internal/types.go
Normal 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{}
|
||||
}
|
||||
11
api/edge/callbacks/internal/server/server.go
Normal file
11
api/edge/callbacks/internal/server/server.go
Normal 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)
|
||||
}
|
||||
36
api/edge/callbacks/internal/signing/module.go
Normal file
36
api/edge/callbacks/internal/signing/module.go
Normal 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)
|
||||
}
|
||||
80
api/edge/callbacks/internal/signing/service.go
Normal file
80
api/edge/callbacks/internal/signing/service.go
Normal 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")
|
||||
}
|
||||
}
|
||||
99
api/edge/callbacks/internal/storage/module.go
Normal file
99
api/edge/callbacks/internal/storage/module.go
Normal 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)
|
||||
}
|
||||
513
api/edge/callbacks/internal/storage/service.go
Normal file
513
api/edge/callbacks/internal/storage/service.go
Normal 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
|
||||
}
|
||||
17
api/edge/callbacks/internal/subscriptions/module.go
Normal file
17
api/edge/callbacks/internal/subscriptions/module.go
Normal 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
|
||||
}
|
||||
38
api/edge/callbacks/internal/subscriptions/service.go
Normal file
38
api/edge/callbacks/internal/subscriptions/service.go
Normal 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
|
||||
}
|
||||
17
api/edge/callbacks/main.go
Normal file
17
api/edge/callbacks/main.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,7 +10,6 @@ require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||
github.com/ethereum/go-ethereum v1.17.0
|
||||
github.com/fbsobreira/gotron-sdk v0.24.1
|
||||
github.com/hashicorp/vault/api v1.22.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shengdoushi/base58 v1.0.0
|
||||
@@ -59,6 +58,7 @@ require (
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
|
||||
github.com/hashicorp/vault/api v1.22.0 // indirect
|
||||
github.com/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -5,8 +5,10 @@ go 1.25.0
|
||||
require (
|
||||
github.com/casbin/casbin/v2 v2.135.0
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0
|
||||
github.com/ethereum/go-ethereum v1.17.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/vault/api v1.22.0
|
||||
github.com/mattn/go-colorable v0.1.14
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/nats-io/nats.go v1.49.0
|
||||
@@ -26,29 +28,47 @@ require (
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.20.0 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/consensys/gnark-crypto v0.18.1 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v27.3.1+incompatible // indirect
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
|
||||
github.com/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
@@ -67,10 +87,12 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.0 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
@@ -82,7 +104,6 @@ require (
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
@@ -91,7 +112,7 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -6,8 +6,14 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU=
|
||||
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
@@ -20,19 +26,29 @@ github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0l
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI=
|
||||
github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg=
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||
@@ -41,10 +57,22 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
|
||||
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs=
|
||||
github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes=
|
||||
github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -53,6 +81,10 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
@@ -66,18 +98,47 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
|
||||
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
|
||||
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||
github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0=
|
||||
github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM=
|
||||
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
|
||||
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
@@ -86,6 +147,10 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
@@ -132,6 +197,10 @@ github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82
|
||||
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
|
||||
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
@@ -146,6 +215,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw=
|
||||
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||
@@ -178,10 +249,10 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuH
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
@@ -190,8 +261,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
@@ -254,8 +325,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
@@ -269,8 +340,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
@@ -280,6 +351,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -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"`
|
||||
|
||||
34
api/pkg/vault/kv/module.go
Normal file
34
api/pkg/vault/kv/module.go
Normal 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
151
api/pkg/vault/kv/service.go
Normal 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)
|
||||
54
api/pkg/vault/managedkey/module.go
Normal file
54
api/pkg/vault/managedkey/module.go
Normal 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)
|
||||
}
|
||||
218
api/pkg/vault/managedkey/service.go
Normal file
218
api/pkg/vault/managedkey/service.go
Normal 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)
|
||||
Reference in New Issue
Block a user