diff --git a/Makefile b/Makefile index 756e50f..f5aa3fb 100644 --- a/Makefile +++ b/Makefile @@ -54,6 +54,9 @@ package-notifications-interface: generate-notifications # 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 +ifneq ($(wildcard .local/dev/sendico/settings.env),) +COMPOSE += -f infra/local/sendico/compose.yml +endif .PHONY: build up down logs ps test-dev build: sh infra/local/tools.sh build @@ -92,3 +95,7 @@ email: test-dev-tooling: sh infra/local/tools.sh test + +.PHONY: sendico +sendico: + sh infra/local/tools.sh sendico diff --git a/docs/local-development.md b/docs/local-development.md index 9b9c8b6..eb57cb4 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -1,5 +1,7 @@ # Local development +For the connected Sendico stand, see [local Sendico integration](local-sendico.md). + Task [#42](https://git.sendico.io/tech/sxchnge/issues/42), [PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`. diff --git a/docs/local-sendico.md b/docs/local-sendico.md new file mode 100644 index 0000000..d1f42b5 --- /dev/null +++ b/docs/local-sendico.md @@ -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": "", + "source_account_ref": "", + "client_id": "", + "device_id": "sxchnge-local-bff", + "login": "", + "password": "", + "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. diff --git a/infra/images/tools.Dockerfile b/infra/images/tools.Dockerfile index 39e2ea9..3e59be6 100644 --- a/infra/images/tools.Dockerfile +++ b/infra/images/tools.Dockerfile @@ -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 diff --git a/infra/local/scripts/bootstrap.sh b/infra/local/scripts/bootstrap.sh index 3fcee0d..9e07d40 100644 --- a/infra/local/scripts/bootstrap.sh +++ b/infra/local/scripts/bootstrap.sh @@ -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" diff --git a/infra/local/scripts/common.sh b/infra/local/scripts/common.sh index b147db0..c719756 100644 --- a/infra/local/scripts/common.sh +++ b/infra/local/scripts/common.sh @@ -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")" diff --git a/infra/local/scripts/dispatch.sh b/infra/local/scripts/dispatch.sh index 1adbd42..32d20cb 100644 --- a/infra/local/scripts/dispatch.sh +++ b/infra/local/scripts/dispatch.sh @@ -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;; diff --git a/infra/local/scripts/sendico.sh b/infra/local/scripts/sendico.sh new file mode 100644 index 0000000..d5fe3e6 --- /dev/null +++ b/infra/local/scripts/sendico.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.' +} diff --git a/infra/local/scripts/start-service.sh b/infra/local/scripts/start-service.sh index fb1e6bd..9d391ba 100755 --- a/infra/local/scripts/start-service.sh +++ b/infra/local/scripts/start-service.sh @@ -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 diff --git a/infra/local/scripts/test.sh b/infra/local/scripts/test.sh index ddcede0..0601b48 100644 --- a/infra/local/scripts/test.sh +++ b/infra/local/scripts/test.sh @@ -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' diff --git a/infra/local/scripts/verify.sh b/infra/local/scripts/verify.sh index 7aaf702..ebb43f0 100644 --- a/infra/local/scripts/verify.sh +++ b/infra/local/scripts/verify.sh @@ -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" diff --git a/infra/local/sendico/check.sh b/infra/local/sendico/check.sh new file mode 100644 index 0000000..ce91ac0 --- /dev/null +++ b/infra/local/sendico/check.sh @@ -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' diff --git a/infra/local/sendico/compose.yml b/infra/local/sendico/compose.yml new file mode 100644 index 0000000..9f96ad8 --- /dev/null +++ b/infra/local/sendico/compose.yml @@ -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: diff --git a/infra/local/sendico/session.sh b/infra/local/sendico/session.sh new file mode 100644 index 0000000..1dd34ed --- /dev/null +++ b/infra/local/sendico/session.sh @@ -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