Connect local SXCHNGE to the Sendico stand

This commit was merged in pull request #53.
This commit is contained in:
Stephan D committed 2026-10-16 10:19:59 +02:00
1 parent 34d8ef18d1
commit a5689876ff
14 files changed
+336 -9

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
ARG DOCKER_IMAGE
FROM ${DOCKER_IMAGE}
RUN apk add --no-cache curl jq git openssl util-linux shellcheck
RUN apk add --no-cache curl jq git openssl util-linux shellcheck socat
+11 -4
View File
@@ -54,24 +54,31 @@ provision_mongo() {
{ cat "$WORK/admin.js"; cat infra/local/mongo/mongo-client.js; } | mongo >/dev/null || fail 'Browser client provisioning failed'
}
provision_agents() {
for service in bff notifications nats; do
services='bff notifications nats'
if [ -f "$STATE/sendico/settings.env" ]; then services="$services sendico"; fi
for service in $services; do
directory="$STATE/agents/$service"
mkdir -p "$directory"; chmod 755 "$directory"
case "$service" in
bff) paths='services/bff/jwt';;
notifications) paths='infrastructure/nats/auth services/notifications/email/resend';;
nats) paths=infrastructure/nats/auth;;
sendico) paths=services/bff/sendico;;
esac
: > "$WORK/policy"
for path in $paths; do printf 'path "kv/data/%s" { capabilities = ["read"] }\n' "$path" >> "$WORK/policy"; done
if [ "$service" != nats ]; then printf 'path "database/creds/%s" { capabilities = ["read"] }\n' "$service" >> "$WORK/policy"; fi
case "$service" in bff|notifications) printf 'path "database/creds/%s" { capabilities = ["read"] }\n' "$service" >> "$WORK/policy";; esac
jq -n --rawfile policy "$WORK/policy" '{policy:$policy}' | vault "sys/policies/acl/$service" POST >/dev/null
jq -n --arg service "$service" '{token_policies:[$service],token_period:"1h",secret_id_ttl:"0",secret_id_num_uses:0}' | vault "auth/approle/role/$service" POST >/dev/null
if [ "$service" = sendico ]; then
printf '{"token_policies":["sendico"],"token_ttl":"5m","token_max_ttl":"5m","secret_id_ttl":"0","secret_id_num_uses":0}' | vault "auth/approle/role/$service" POST >/dev/null
else
jq -n --arg service "$service" '{token_policies:[$service],token_period:"1h",secret_id_ttl:"0",secret_id_num_uses:0}' | vault "auth/approle/role/$service" POST >/dev/null
fi
vault "auth/approle/role/$service/role-id" | jq -r '.data.role_id' | write "$directory/role-id" 444
if [ ! -f "$directory/secret-id" ]; then
printf '{}' | vault "auth/approle/role/$service/secret-id" POST | jq -r '.data.secret_id' | write "$directory/secret-id" 444
fi
if ! cmp -s "infra/local/vault/$service.hcl" "$directory/config.hcl"; then
if [ "$service" != sendico ] && ! cmp -s "infra/local/vault/$service.hcl" "$directory/config.hcl"; then
cat "infra/local/vault/$service.hcl" | write "$directory/config.hcl" 444
if compose ps --status running --services "$service" | grep -qx "$service"; then
compose up -d --no-deps --force-recreate --wait --wait-timeout 120 "$service"
+7 -1
View File
@@ -14,7 +14,13 @@ trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
fail() { echo "$*" >&2; exit 1; }
compose() { docker compose --project-directory . -f infra/local/compose.yml --env-file "$STATE/images.env" "$@"; }
compose() {
if [ -f "$STATE/sendico/settings.env" ]; then
docker compose --project-directory . -f infra/local/compose.yml -f infra/local/sendico/compose.yml --env-file "$STATE/images.env" "$@"
else
docker compose --project-directory . -f infra/local/compose.yml --env-file "$STATE/images.env" "$@"
fi
}
write() {
target=$1; mode=${2:-600}
mkdir -p "$(dirname "$target")"
+1
View File
@@ -7,6 +7,7 @@ case "${1:-}" in
images) . infra/local/scripts/images.sh; images;;
up) . infra/local/scripts/bootstrap.sh; up;;
email) . infra/local/scripts/bootstrap.sh; email;;
sendico) . infra/local/scripts/bootstrap.sh; . infra/local/scripts/sendico.sh; configure_sendico;;
verify|verify-infra) . infra/local/scripts/verify.sh; existing; infrastructure; if [ "$1" = verify ]; then product; fi;;
rotation) . infra/local/scripts/verify.sh; . infra/local/scripts/rotation.sh; existing; rotation;;
test) sh infra/local/scripts/test.sh;;
+20
View File
@@ -0,0 +1,20 @@
# Operator input is JSON on stdin. Only credential values are stored in Vault.
configure_sendico() {
existing
cat > "$WORK/input"
jq -e '
(.organization_ref|test("^[0-9a-f]{24}$") and .!="000000000000000000000000") and
(.source_account_ref|test("^[0-9a-f]{24}$") and .!="000000000000000000000000") and
([.client_id,.device_id,.login,.password,.client_secret] | all(.[]; type=="string" and test("\\S"))) and
([.client_id,.device_id] | all(.[]; test("^[A-Za-z0-9._-]+$")))
' "$WORK/input" >/dev/null || fail 'Invalid Sendico operator input'
jq '{login,password,client_secret}' "$WORK/input" | put services/bff/sendico
mkdir -p "$STATE/sendico"
jq -r '"SENDICO_ORGANIZATION_REF="+.organization_ref+"\nSENDICO_SOURCE_ACCOUNT_REF="+.source_account_ref+"\nSENDICO_CLIENT_ID="+.client_id+"\nSENDICO_DEVICE_ID="+.device_id' "$WORK/input" | write "$STATE/sendico/settings.env"
{
cat infra/local/bff/runtime.yml
jq -r '"\n sendico:\n api_url: http://127.0.0.1:18081/api/v1\n organization_ref: "+.organization_ref+"\n source_account_ref: "+.source_account_ref+"\n token_file: /run/sendico/access-token"' "$WORK/input"
} | write "$STATE/sendico/runtime.yml" 644
provision_agents
echo 'Local Sendico connection configured. Run make up.'
}
+8
View File
@@ -4,4 +4,12 @@ umask 077
if [ "$SXCHNGE_SERVICE" = notifications ]; then
exec service -config.file=/etc/sxchnge/notifications.yml
fi
if [ "${SXCHNGE_SENDICO_LOCAL:-}" = 1 ]; then
attempt=0
until [ -s /run/sendico/access-token ] && [ "$(($(date +%s) - $(stat -c %Y /run/sendico/access-token)))" -lt 1500 ]; do
attempt=$((attempt+1))
[ "$attempt" -lt 120 ] || { echo 'Local Sendico session did not become ready' >&2; exit 1; }
sleep 2
done
fi
exec service -config.file=/etc/sxchnge/bff.yml
+21
View File
@@ -4,6 +4,7 @@ set -o pipefail
# Shell syntax and static analysis cover the actual container shell scripts.
shellcheck -s ash -x -S warning infra/local/scripts/dispatch.sh infra/local/scripts/test.sh
shellcheck -x -S warning infra/local/tools.sh infra/local/scripts/start-service.sh infra/local/mongo/start.sh infra/local/nats/start.sh infra/local/vault/start.sh
shellcheck -s ash -x -S warning infra/local/sendico/session.sh infra/local/sendico/check.sh
. infra/local/scripts/common.sh
. infra/local/scripts/images.sh
. infra/local/scripts/bootstrap.sh
@@ -93,3 +94,23 @@ retire_secrets
retire_secrets
jq -e '.=={"infrastructure/nats/auth":true}' "$WORK/retirement" >/dev/null
echo 'PASS: obsolete caller secrets retired idempotently; NATS authentication preserved'
# Local integration configuration keeps credentials out of runtime settings and
# rejects malformed operator inputs before mutating Vault or service identities.
. infra/local/scripts/sendico.sh
STATE="$WORK/sendico-state"; mkdir "$STATE"
existing() { :; }
put() { cat > "$WORK/sendico-credentials"; }
provision_agents() { touch "$WORK/sendico-agents"; }
printf '{}' > "$WORK/sendico-invalid"
if (configure_sendico < "$WORK/sendico-invalid") >/dev/null 2>&1; then fail 'Invalid Sendico configuration accepted'; fi
[ ! -e "$WORK/sendico-credentials" ] || fail 'Invalid Sendico input mutated credentials'
cat > "$WORK/sendico-input" <<'JSON'
{"organization_ref":"000000000000000000000001","source_account_ref":"000000000000000000000002","client_id":"fixture.client","device_id":"fixture-device","login":"fixture@example.test","password":"fixture-password","client_secret":"fixture-secret"}
JSON
configure_sendico < "$WORK/sendico-input" >/dev/null
jq -e 'keys==["client_secret","login","password"]' "$WORK/sendico-credentials" >/dev/null
[ -f "$WORK/sendico-agents" ] || fail 'Sendico identity was not provisioned'
if grep -Eq 'fixture-secret|fixture-password|fixture@example' "$STATE/sendico/settings.env" "$STATE/sendico/runtime.yml"; then fail 'Credential leaked into runtime configuration'; fi
grep -q 'source_account_ref: 000000000000000000000002' "$STATE/sendico/runtime.yml"
grep -q 'api_url: http://127.0.0.1:18081/api/v1' "$STATE/sendico/runtime.yml"
echo 'PASS: Sendico operator input validation, secret separation and local runtime configuration'
+14 -3
View File
@@ -2,11 +2,16 @@ policies() (
root_token=$VAULT_TOKEN
identity=''
trap 'if [ -n "$identity" ]; then printf "{}" | VAULT_TOKEN=$identity vault auth/token/revoke-self POST >/dev/null; fi' EXIT
for service in bff notifications nats; do
services='bff notifications nats'
if [ -f "$STATE/sendico/settings.env" ]; then services="$services sendico"; fi
for service in $services; do
directory="$STATE/agents/$service"
login=$(jq -n --rawfile role "$directory/role-id" --rawfile secret "$directory/secret-id" '{role_id:($role|rtrimstr("\n")),secret_id:($secret|rtrimstr("\n"))}' | vault auth/approle/login POST)
identity=$(printf '%s' "$login" | jq -er '.auth.client_token')
printf '%s' "$login" | jq '{token:.auth.client_token,paths:["kv/data/bootstrap/mongo","kv/data/infrastructure/mongo/bootstrap","sys/init","kv/data/bff","kv/data/caller","kv/data/services/notifications/callers/bff","kv/data/email","kv/data/nats","kv/data/tls"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Service can access bootstrap credentials'
if [ "$service" = sendico ]; then
printf '%s' "$login" | jq '{token:.auth.client_token,paths:["database/creds/bff","database/creds/notifications","kv/data/services/bff/jwt","kv/data/infrastructure/nats/auth"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Sendico session identity has unrelated secret access'
fi
VAULT_TOKEN=$(printf '%s' "$login" | jq -er '.auth.client_token')
printf '{}' | vault auth/token/revoke-self POST >/dev/null
VAULT_TOKEN=$root_token
@@ -44,12 +49,18 @@ infrastructure() {
# Resolve through Docker DNS while verifying the certificate for localhost.
https() { curl -sS --max-time 5 --cacert "$STATE/ca.pem" --connect-to localhost:5126:frontend:8443 "$@"; }
product() {
compose ps --format json | jq -se 'length==6 and all(.[];.Health=="healthy")' >/dev/null || fail 'Expected six healthy services'
expected=6
if [ -f "$STATE/sendico/settings.env" ]; then expected=7; fi
compose ps --format json | jq -se --argjson expected "$expected" 'length==$expected and all(.[];.Health=="healthy")' >/dev/null || fail "Expected $expected healthy services"
https -f https://localhost:5126/ >/dev/null
https -f https://localhost:5126/api/v1/health >/dev/null
echo 'PASS: six healthy services, frontend and proxied BFF use verified TLS; no email sent'
if [ "$expected" = 7 ]; then compose exec -T sendico sh /etc/sendico/check.sh; fi
echo "PASS: $expected healthy services, frontend and proxied BFF use verified TLS; no email sent"
}
layout() {
if [ -f "$STATE/sendico/settings.env" ]; then
kv services/bff/sendico | jq -e 'keys==["client_secret","login","password"]' >/dev/null || fail 'Sendico secret contains non-secret configuration'
fi
kv services/notifications/email/resend | jq -e 'keys==["api_key"]' >/dev/null || fail 'Non-secret email configuration remains in Vault'
for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do
[ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
umask 077
check=$(mktemp -d /private/check.XXXXXX)
trap 'rm -rf "$check"' EXIT
printf 'Authorization: Bearer %s\n' "$(cat /run/sendico/access-token)" > "$check/header"
for path in "payments/fx/currencies/$SENDICO_ORGANIZATION_REF" "ledger_accounts/$SENDICO_ORGANIZATION_REF"; do
curl -fsS --max-time 15 -H "@$check/header" "http://127.0.0.1:18081/api/v1/$path" > "$check/response" 2>/dev/null || { echo 'Local Sendico authenticated API check failed' >&2; exit 1; }
jq -e '.status=="success"' "$check/response" >/dev/null
done
echo 'PASS: local Sendico authenticated currency and ledger APIs are accessible'
+30
View File
@@ -0,0 +1,30 @@
# Enabled by make sendico; settings contain no credentials.
services:
bff:
environment:
SXCHNGE_SENDICO_LOCAL: '1'
volumes:
- ./.local/dev/sendico/runtime.yml:/etc/sxchnge/bff.yml:ro
- sendico-session:/run/sendico:ro
sendico:
image: sxchnge-local/tools:dev
restart: unless-stopped
# Keep the adapter's loopback-only HTTP rule; forward to the public stand API.
network_mode: service:bff
entrypoint: [/bin/sh, /etc/sendico/session.sh]
env_file: ./.local/dev/sendico/settings.env
volumes:
- ./infra/local/sendico/session.sh:/etc/sendico/session.sh:ro
- ./infra/local/sendico/check.sh:/etc/sendico/check.sh:ro
- ./.local/dev/agents/sendico:/etc/agent:ro
- sendico-session:/run/sendico
tmpfs: [/private:mode=0700]
healthcheck:
test: [CMD-SHELL, 'test -f /private/ready && test $$(($$(date +%s) - $$(stat -c %Y /private/ready))) -lt 1500 && curl -fsS --max-time 2 http://127.0.0.1:18081/api/v1/health >/dev/null']
interval: 10s
timeout: 3s
retries: 30
volumes:
sendico-session:
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
set -eu
umask 077
mkdir -p /private /run/sendico
chmod 755 /run/sendico
cd /private
socat TCP4-LISTEN:18081,bind=127.0.0.1,reuseaddr,fork TCP4:host.docker.internal:8080 &
bridge=$!
trap 'kill "$bridge" 2>/dev/null || true' EXIT
trap 'exit 0' INT TERM
session() {
jq -n --rawfile role /etc/agent/role-id --rawfile secret /etc/agent/secret-id \
'{role_id:($role|rtrimstr("\n")),secret_id:($secret|rtrimstr("\n"))}' > login.json
curl -fsS --max-time 15 -H 'Content-Type: application/json' --data-binary @login.json \
http://vault:8200/v1/auth/approle/login > vault.json 2>/dev/null || return 1
jq -er '"X-Vault-Token: "+.auth.client_token' vault.json > vault.header || return 1
curl -fsS --max-time 15 -H @vault.header http://vault:8200/v1/kv/data/services/bff/sendico > credentials.json 2>/dev/null || return 1
jq -e --arg client "$SENDICO_CLIENT_ID" --arg device "$SENDICO_DEVICE_ID" \
'.data.data | {login:{login,password},clientSecret:.client_secret,clientId:$client,deviceId:$device}' credentials.json > request.json || return 1
curl -fsS --max-time 15 -H @vault.header -X POST http://vault:8200/v1/auth/token/revoke-self >/dev/null 2>&1 || return 1
rm -f vault.header vault.json credentials.json login.json
curl -fsS --max-time 20 -H 'Content-Type: application/json' --data-binary @request.json \
http://127.0.0.1:18081/api/v1/accounts/login/api > response.json 2>/dev/null || return 1
rm -f request.json
jq -er '.data.accessToken.token' response.json > token 2>/dev/null || return 1
jq -e --arg source "$SENDICO_SOURCE_ACCOUNT_REF" \
'.data.accessToken.token | split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson | .id==$source and .pending!=true and .exp>(now+1500)' response.json >/dev/null 2>&1 || return 1
cp token /run/sendico/access-token.new
chmod 444 /run/sendico/access-token.new
mv /run/sendico/access-token.new /run/sendico/access-token
rm -f token response.json
touch ready
}
while :; do
if session; then
echo 'Local Sendico session ready; next renewal in 20 minutes.'
sleep 1200 & wait $!
else
echo 'Local Sendico session unavailable; check stand health and integration credentials. Retrying in 5 minutes.' >&2
rm -f request.json response.json token vault.header vault.json credentials.json login.json
sleep 300 & wait $!
fi
done