Connect the local SXCHNGE and Sendico stands #53
No files matched your search
@@ -54,6 +54,9 @@ package-notifications-interface: generate-notifications
|
|||||||
|
|
||||||
# Local product stack. Releases are resolved afresh, then pinned by digest.
|
# Local product stack. Releases are resolved afresh, then pinned by digest.
|
||||||
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
|
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
|
||||||
|
ifneq ($(wildcard .local/dev/sendico/settings.env),)
|
||||||
|
COMPOSE += -f infra/local/sendico/compose.yml
|
||||||
|
endif
|
||||||
.PHONY: build up down logs ps test-dev
|
.PHONY: build up down logs ps test-dev
|
||||||
build:
|
build:
|
||||||
sh infra/local/tools.sh build
|
sh infra/local/tools.sh build
|
||||||
@@ -92,3 +95,7 @@ email:
|
|||||||
|
|
||||||
test-dev-tooling:
|
test-dev-tooling:
|
||||||
sh infra/local/tools.sh test
|
sh infra/local/tools.sh test
|
||||||
|
|
||||||
|
.PHONY: sendico
|
||||||
|
sendico:
|
||||||
|
sh infra/local/tools.sh sendico
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Local development
|
# Local development
|
||||||
|
|
||||||
|
For the connected Sendico stand, see [local Sendico integration](local-sendico.md).
|
||||||
|
|
||||||
Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
|
Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
|
||||||
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`.
|
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Local Sendico integration
|
||||||
|
|
||||||
|
Task [#52](https://git.sendico.io/tech/sxchnge/issues/52), branch `local-stand-52`,
|
||||||
|
based on `payments-50` / [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51).
|
||||||
|
The local Sendico checkout uses `payments-2896` /
|
||||||
|
[PR #2897](https://git.sendico.io/tech/sendico/pulls/2897). Merge the prerequisites
|
||||||
|
before this infrastructure PR. No PR is merged by local startup.
|
||||||
|
|
||||||
|
## Running the configured stands
|
||||||
|
|
||||||
|
Start Sendico first from its checkout using its documented Make workflow. Its
|
||||||
|
Product API must respond at `http://localhost:8080/api/v1/health`. Then, here:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make build
|
||||||
|
make up
|
||||||
|
make test-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [SXCHNGE](https://localhost:5126),
|
||||||
|
[Sendico Product](http://localhost:3000), and
|
||||||
|
[Sendico Backoffice](http://localhost:4000).
|
||||||
|
SXCHNGE's Caddy CA is exported to `.local/dev/ca.pem`; trust that public CA in
|
||||||
|
the browser/OS if necessary. Bootstrap does not modify the system trust store.
|
||||||
|
|
||||||
|
The optional integration is selected by ignored `.local/dev/sendico/settings.env`.
|
||||||
|
Make and the containerized bootstrap both include
|
||||||
|
`infra/local/sendico/compose.yml` whenever that file exists. Standalone SXCHNGE
|
||||||
|
still uses six services. The connected stack adds one `sendico` session worker.
|
||||||
|
`make ps`, `make logs`, and `make down` include it automatically.
|
||||||
|
|
||||||
|
## Identity and configuration
|
||||||
|
|
||||||
|
The configured local stand has a dedicated `SXCHNGE Local` organization, active
|
||||||
|
integration account and account-bound confidential API client. The account is
|
||||||
|
an organization member with organization read/update, ledger read, payment
|
||||||
|
create/read, and recipient create/read/update permissions. Customer subjects
|
||||||
|
remain independent from that account. No pooled balances, customer funds or
|
||||||
|
payout destinations are seeded by this setup. Existing stand data is retained.
|
||||||
|
This identity was created through a one-time local administrative bootstrap;
|
||||||
|
runtime traffic and session creation use only the public HTTP API.
|
||||||
|
|
||||||
|
For another stand, an operator must first provision the organization, account,
|
||||||
|
client and grants in Sendico. Resolve actual IDs from that stand; do not copy
|
||||||
|
another environment's references. Initialize SXCHNGE's Vault using `make up`
|
||||||
|
before configuring the integration. Supply this JSON privately on stdin:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"organization_ref": "<organization object ID>",
|
||||||
|
"source_account_ref": "<integration account object ID>",
|
||||||
|
"client_id": "<confidential client ID>",
|
||||||
|
"device_id": "sxchnge-local-bff",
|
||||||
|
"login": "<integration login>",
|
||||||
|
"password": "<integration password>",
|
||||||
|
"client_secret": "<confidential client secret>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Save operator input outside tracked files, with mode 0600. Never echo it.
|
||||||
|
make sendico < /private/path/sendico-input.json
|
||||||
|
make up
|
||||||
|
make test-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`make sendico` validates references and identifiers, stores only login/password/
|
||||||
|
client_secret at Vault `kv/services/bff/sendico`, and writes the non-secret client,
|
||||||
|
device, organization and source references into ignored local settings. The BFF
|
||||||
|
runtime is generated from `infra/local/bff/runtime.yml` with the existing
|
||||||
|
`api.sendico` configuration. Re-run configuration after changing that base YAML.
|
||||||
|
Remove the temporary input file once credentials have been stored in Vault.
|
||||||
|
No Sendico configuration parameters or application transport rules change.
|
||||||
|
|
||||||
|
## Connection and renewal
|
||||||
|
|
||||||
|
The session worker shares BFF's network namespace and binds a private loopback
|
||||||
|
TCP bridge at `127.0.0.1:18081`, forwarding to
|
||||||
|
`host.docker.internal:8080`. This uses the local stand's existing public HTTP API
|
||||||
|
port; it requires a local Docker engine with `host.docker.internal` support, as
|
||||||
|
provided by Docker Desktop. The bridge publishes no new host port. The adapter's
|
||||||
|
existing HTTPS requirement and loopback-only HTTP exception remain intact.
|
||||||
|
|
||||||
|
The worker authenticates to Vault with its own AppRole, reads exactly
|
||||||
|
`services/bff/sendico`, and revokes its five-minute Vault token after reading.
|
||||||
|
It cannot read Mongo credentials, JWT signing keys, NATS auth or administrator
|
||||||
|
material. Its initial login uses Sendico's public `/accounts/login/api` grant,
|
||||||
|
validates the access token's source account, full-session status and expiry, and
|
||||||
|
atomically publishes the token in a volume mounted read-only by BFF. Credentials
|
||||||
|
and response bodies stay in private tmpfs files and never appear in logs or
|
||||||
|
command arguments. Flutter receives no platform credentials.
|
||||||
|
|
||||||
|
Successful login repeats every 20 minutes before the one-hour access token
|
||||||
|
expires. A failure retains the prior token, logs one generic actionable message,
|
||||||
|
and retries after five minutes. The worker becomes unhealthy after 25 minutes
|
||||||
|
without renewal or when the upstream health endpoint fails; the adapter
|
||||||
|
separately rejects expired or mismatched tokens.
|
||||||
|
BFF waits for a recent token at startup. Following credential changes, restart
|
||||||
|
the worker with `make up` followed by the Compose restart command below, or wait
|
||||||
|
for its next renewal. Never change the integration account on an existing
|
||||||
|
financial environment without reviewing resource and idempotency bindings.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --project-directory . -f infra/local/compose.yml \
|
||||||
|
-f infra/local/sendico/compose.yml --env-file .local/dev/images.env restart sendico
|
||||||
|
```
|
||||||
|
|
||||||
|
`make test-dev` checks seven healthy services, verified browser TLS, the Vault
|
||||||
|
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
||||||
|
authenticated Sendico currency and ledger reads through the same loopback
|
||||||
|
connection and token used by BFF. It sends no email and executes no payment.
|
||||||
|
An empty wallet is expected until dedicated subject-bound currency ledgers and
|
||||||
|
saved methods are provisioned as described in [payment provisioning](payments.md).
|
||||||
|
|
||||||
|
## Recovery
|
||||||
|
|
||||||
|
The existing stand had application processes stopped inside still-running Air
|
||||||
|
containers after a NATS disconnect and Mongo replica-state interruption. Restore
|
||||||
|
discovery first, then Treasury and the other service announcers. Wait until each
|
||||||
|
stage is registered before restarting dependent services: billing fees and
|
||||||
|
Ledger, then Limits/gateways/quotation/receiving routes, then payment orchestrator,
|
||||||
|
compliance assessment and Product BFF. The Sendico Makefile provides its ordered
|
||||||
|
backend startup workflow. Inspect current logs and health instead of assuming a
|
||||||
|
running development container has a live application process. Do not delete
|
||||||
|
volumes or reset financial data to recover service registration.
|
||||||
|
|
||||||
|
If an old Caddy leaf certificate expires while the stack is stopped, restart
|
||||||
|
frontend after startup so Caddy issues a fresh certificate. Preserve `caddy-data`
|
||||||
|
and the established CA. A subsequent verified TLS check must succeed.
|
||||||
|
|
||||||
|
## Acceptance and operator UX review
|
||||||
|
|
||||||
|
Validated on 2026-10-16: `make build`, `make up`, `make test-dev-tooling`,
|
||||||
|
`make test-dev-infra`, `make test-dev`, and `git diff --check` passed. Both product
|
||||||
|
sites, Sendico Backoffice and both API health endpoints returned HTTP 200;
|
||||||
|
SXCHNGE's browser endpoint was checked with its exported CA. All Sendico services
|
||||||
|
with health checks were healthy. Forced worker restart issued a different valid
|
||||||
|
same-account token and preserved successful authenticated currency/ledger reads.
|
||||||
|
The existing notification sender edit remains outside this task's commit.
|
||||||
|
|
||||||
|
The setup journey starts with the operator's Make invocation and completes when
|
||||||
|
Compose confirms service readiness; `make test-dev` additionally confirms the
|
||||||
|
authenticated integration. Existing command exit codes and Compose progress /
|
||||||
|
health states distinguish configuration, dependency, authentication and readiness
|
||||||
|
failures. A renewed-session log records credential recovery, while stale renewal
|
||||||
|
or upstream outage changes container health. Each new operator invocation is a
|
||||||
|
new attempt; worker retries remain within that process's lifetime. Command wall
|
||||||
|
time measures setup duration. Interrupted commands are incomplete attempts, not
|
||||||
|
successful launches. These are local operator signals, with no retained analytics
|
||||||
|
or cross-invocation conversion/abandonment reporting added.
|
||||||
|
|
||||||
|
The UX review covered missing bootstrap material, malformed operator input,
|
||||||
|
authentication failure, retained retry context, stale TLS recovery, successful
|
||||||
|
startup and confirmed API access. Commands reuse existing Make/Compose patterns,
|
||||||
|
errors give a recovery action, and success follows readiness. No secret values
|
||||||
|
appear in terminal feedback. Product layouts, focus and screen-reader behavior
|
||||||
|
are unchanged; browser interaction and funded payment settlement are not claimed
|
||||||
|
by HTTP readiness checks. No email or payment was sent during validation.
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
ARG DOCKER_IMAGE
|
ARG DOCKER_IMAGE
|
||||||
FROM ${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
|
||||||
@@ -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'
|
{ cat "$WORK/admin.js"; cat infra/local/mongo/mongo-client.js; } | mongo >/dev/null || fail 'Browser client provisioning failed'
|
||||||
}
|
}
|
||||||
provision_agents() {
|
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"
|
directory="$STATE/agents/$service"
|
||||||
mkdir -p "$directory"; chmod 755 "$directory"
|
mkdir -p "$directory"; chmod 755 "$directory"
|
||||||
case "$service" in
|
case "$service" in
|
||||||
bff) paths='services/bff/jwt';;
|
bff) paths='services/bff/jwt';;
|
||||||
notifications) paths='infrastructure/nats/auth services/notifications/email/resend';;
|
notifications) paths='infrastructure/nats/auth services/notifications/email/resend';;
|
||||||
nats) paths=infrastructure/nats/auth;;
|
nats) paths=infrastructure/nats/auth;;
|
||||||
|
sendico) paths=services/bff/sendico;;
|
||||||
esac
|
esac
|
||||||
: > "$WORK/policy"
|
: > "$WORK/policy"
|
||||||
for path in $paths; do printf 'path "kv/data/%s" { capabilities = ["read"] }\n' "$path" >> "$WORK/policy"; done
|
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 --rawfile policy "$WORK/policy" '{policy:$policy}' | vault "sys/policies/acl/$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
|
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
|
vault "auth/approle/role/$service/role-id" | jq -r '.data.role_id' | write "$directory/role-id" 444
|
||||||
if [ ! -f "$directory/secret-id" ]; then
|
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
|
printf '{}' | vault "auth/approle/role/$service/secret-id" POST | jq -r '.data.secret_id' | write "$directory/secret-id" 444
|
||||||
fi
|
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
|
cat "infra/local/vault/$service.hcl" | write "$directory/config.hcl" 444
|
||||||
if compose ps --status running --services "$service" | grep -qx "$service"; then
|
if compose ps --status running --services "$service" | grep -qx "$service"; then
|
||||||
compose up -d --no-deps --force-recreate --wait --wait-timeout 120 "$service"
|
compose up -d --no-deps --force-recreate --wait --wait-timeout 120 "$service"
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ trap cleanup EXIT
|
|||||||
trap 'exit 130' INT
|
trap 'exit 130' INT
|
||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
fail() { echo "$*" >&2; exit 1; }
|
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() {
|
write() {
|
||||||
target=$1; mode=${2:-600}
|
target=$1; mode=${2:-600}
|
||||||
mkdir -p "$(dirname "$target")"
|
mkdir -p "$(dirname "$target")"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ case "${1:-}" in
|
|||||||
images) . infra/local/scripts/images.sh; images;;
|
images) . infra/local/scripts/images.sh; images;;
|
||||||
up) . infra/local/scripts/bootstrap.sh; up;;
|
up) . infra/local/scripts/bootstrap.sh; up;;
|
||||||
email) . infra/local/scripts/bootstrap.sh; email;;
|
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;;
|
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;;
|
rotation) . infra/local/scripts/verify.sh; . infra/local/scripts/rotation.sh; existing; rotation;;
|
||||||
test) sh infra/local/scripts/test.sh;;
|
test) sh infra/local/scripts/test.sh;;
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
}
|
||||||
@@ -4,4 +4,12 @@ umask 077
|
|||||||
if [ "$SXCHNGE_SERVICE" = notifications ]; then
|
if [ "$SXCHNGE_SERVICE" = notifications ]; then
|
||||||
exec service -config.file=/etc/sxchnge/notifications.yml
|
exec service -config.file=/etc/sxchnge/notifications.yml
|
||||||
fi
|
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
|
exec service -config.file=/etc/sxchnge/bff.yml
|
||||||
@@ -4,6 +4,7 @@ set -o pipefail
|
|||||||
# Shell syntax and static analysis cover the actual container shell scripts.
|
# 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 -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 -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/common.sh
|
||||||
. infra/local/scripts/images.sh
|
. infra/local/scripts/images.sh
|
||||||
. infra/local/scripts/bootstrap.sh
|
. infra/local/scripts/bootstrap.sh
|
||||||
@@ -93,3 +94,23 @@ retire_secrets
|
|||||||
retire_secrets
|
retire_secrets
|
||||||
jq -e '.=={"infrastructure/nats/auth":true}' "$WORK/retirement" >/dev/null
|
jq -e '.=={"infrastructure/nats/auth":true}' "$WORK/retirement" >/dev/null
|
||||||
echo 'PASS: obsolete caller secrets retired idempotently; NATS authentication preserved'
|
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'
|
||||||
@@ -2,11 +2,16 @@ policies() (
|
|||||||
root_token=$VAULT_TOKEN
|
root_token=$VAULT_TOKEN
|
||||||
identity=''
|
identity=''
|
||||||
trap 'if [ -n "$identity" ]; then printf "{}" | VAULT_TOKEN=$identity vault auth/token/revoke-self POST >/dev/null; fi' EXIT
|
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"
|
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)
|
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')
|
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'
|
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')
|
VAULT_TOKEN=$(printf '%s' "$login" | jq -er '.auth.client_token')
|
||||||
printf '{}' | vault auth/token/revoke-self POST >/dev/null
|
printf '{}' | vault auth/token/revoke-self POST >/dev/null
|
||||||
VAULT_TOKEN=$root_token
|
VAULT_TOKEN=$root_token
|
||||||
@@ -44,12 +49,18 @@ infrastructure() {
|
|||||||
# Resolve through Docker DNS while verifying the certificate for localhost.
|
# 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 "$@"; }
|
https() { curl -sS --max-time 5 --cacert "$STATE/ca.pem" --connect-to localhost:5126:frontend:8443 "$@"; }
|
||||||
product() {
|
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/ >/dev/null
|
||||||
https -f https://localhost:5126/api/v1/health >/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() {
|
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'
|
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
|
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"
|
[ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old"
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user