From 413740af2ed7011d7d19924eb5d24be163b3cb6f Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Fri, 21 Aug 2026 04:52:25 +0200 Subject: [PATCH] text change --- deploy/README.md | 40 ++++++++++ deploy/catcrafts-credits.timer | 27 ++++--- shared/interfaces/Catcrafts.Shared-Views.cppm | 2 +- tools/register-bunq-webhook.sh | 78 +++++++++++++++++++ 4 files changed, 134 insertions(+), 13 deletions(-) create mode 100755 tools/register-bunq-webhook.sh diff --git a/deploy/README.md b/deploy/README.md index 2808ba6..a5f1ccd 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -351,6 +351,46 @@ BUNQ_API_KEY=… TRANSFER_IBAN=NL.. \ # -> pulled 2 new credit(s) into /var/lib/catcrafts/orders.jsonl.transfer-credits.jsonl ``` +#### How settlement actually reaches the shop + +Two paths, and the split is deliberate. + +**Primary: the bunq webhook.** bunq pushes a `MUTATION` notification when money +lands; the backend decodes the payment and appends it to the credits file, and +the reconciler settles from there. Normally seconds end to end. Registered with +`tools/register-bunq-webhook.sh` from a machine bunq's key permits (`--show` +lists what is registered, with the secret redacted). **That call REPLACES the +whole filter set**, so look before overwriting. + +**bunq does not sign these notifications** — verified against doc.bunq.com: no +HMAC, no server signature, and certificate pinning authenticates us to bunq +rather than bunq to us. So the endpoint's authentication is three transport-level +controls, all of which have to stay in place: + +1. **Source-IP allowlist** for `185.40.108.0/22` in the Caddyfile. This is the + strong one: forging requires completing a TLS handshake from inside that + range. bunq warns the range may change; if it does, callbacks 403 and + settlement stops — it fails closed. +2. **A secret path.** Caddy matches only the non-secret prefix `/hooks/bunq/*`, + so the secret lives solely in `BUNQ_WEBHOOK_PATH` in `payments.env` and not + in the web server config. The backend compares it in constant time, and a + non-POST to it falls through to an ordinary 404 rather than a distinctive + 405 — a different answer there would confirm a guessed path. +3. **`log_skip` on that prefix.** Without it the secret would be written to the + access log and then ingested into the GoAccess database and shown in its + top-URLs panel, i.e. leaked to anyone who opens `/analytics/`. + +The handler deliberately does the least it can: decode one payment, append it, +answer 200. It makes no settlement decision, so a forged callback can at worst +manufacture a credit line — it cannot bypass the reference match or the +covering-amount rule, and nothing ships without a human either way. + +**Backstop: the hourly pull.** Not optional. bunq retries a callback about six +times and then drops it permanently, so a payment arriving while the backend +restarts — every deploy is a restart — is lost to the webhook with no error +anywhere. `deploy/catcrafts-credits.timer` runs `pull-and-ship-credits.sh` +hourly to turn that from a missing donation into an hour of delay. + **The key is IP-restricted, and that decides the shape.** Verified the hard way on 2026-08-20: the bunq API key permits one address (the owner's home connection), and **bunq enforces that on every request, not only at device diff --git a/deploy/catcrafts-credits.timer b/deploy/catcrafts-credits.timer index aed6f12..77920d3 100644 --- a/deploy/catcrafts-credits.timer +++ b/deploy/catcrafts-credits.timer @@ -1,23 +1,26 @@ -# Every five minutes. Two things set the floor and the ceiling: +# Hourly, because this is now the BACKSTOP rather than the primary path. # -# * bunq rate-limits reads to roughly 3 GET per 3 seconds per method, so one -# call per five minutes is nowhere near it. The cost of going faster is -# nothing technical; it is just noise. -# * a donor watching the order page wants confirmation while they still care. -# Five minutes here plus the reconciler's own 60 s cadence means a fresh -# order confirms within about six minutes of the money landing. +# Since the bunq webhook landed, a payment normally settles within seconds: bunq +# pushes it, the handler writes the credit, the reconciler picks it up. This +# timer exists for the case the webhook cannot cover — bunq retries a callback +# roughly six times over a few minutes and then DROPS IT FOREVER, so a payment +# that arrives while the backend is restarting (every deploy is a restart) is +# lost to the webhook with no error anywhere. An hourly pull turns that from a +# silently missing donation into an hour of delay. +# +# It was five minutes while the pull WAS the settlement path. Hourly is the +# right cadence for a safety net, and it keeps the noise down. # # Persistent=true so a laptop that was asleep pulls once on waking instead of -# silently skipping every window it missed — which is exactly when a donation -# would otherwise sit unacknowledged overnight. +# silently skipping every window it missed. [Unit] -Description=Pull bunq credits for catcrafts.net every 5 minutes +Description=Backstop pull of bunq credits for catcrafts.net (hourly) [Timer] OnBootSec=2min -OnUnitActiveSec=5min +OnUnitActiveSec=1h Persistent=true -AccuracySec=30s +AccuracySec=5min [Install] WantedBy=timers.target diff --git a/shared/interfaces/Catcrafts.Shared-Views.cppm b/shared/interfaces/Catcrafts.Shared-Views.cppm index ebeb8c6..f33041c 100644 --- a/shared/interfaces/Catcrafts.Shared-Views.cppm +++ b/shared/interfaces/Catcrafts.Shared-Views.cppm @@ -768,7 +768,7 @@ SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError, ? Format( R"()", + R"(Bank transfer
Regular IBAN bank transfer.
)", Attr("value", std::string(Form::kPayBank)), wantsCrypto ? SafeHtml{} : Raw(" checked")) : SafeHtml{}; diff --git a/tools/register-bunq-webhook.sh b/tools/register-bunq-webhook.sh new file mode 100755 index 0000000..5e506b7 --- /dev/null +++ b/tools/register-bunq-webhook.sh @@ -0,0 +1,78 @@ +#!/bin/sh +# Tell bunq where to push payment notifications. +# +# Run from a machine whose address the bunq API key permits — the web host is +# not one, which is the whole reason webhooks exist in this setup. The session +# it uses comes from the context file that tools/pull-and-ship-credits.sh +# created, so run that at least once first. +# +# tools/register-bunq-webhook.sh 'https://catcrafts.net/hooks/bunq/' +# tools/register-bunq-webhook.sh --show # list what is registered +# +# THIS REPLACES THE WHOLE FILTER SET. bunq treats the POST as "here is the +# complete list", so a call that omits an existing filter deletes it. Every +# filter wanted has to be in the one request, which is why --show exists: look +# before overwriting. +# +# The target URL is never echoed: it is a shared secret, and this script's +# output tends to end up pasted into issues and chat logs. +set -eu + +cd "$(dirname "$0")/.." + +CTX="${CREDITS_LOCAL:-$HOME/.cache/catcrafts}/orders.jsonl.transfer-credits.jsonl.bunq-context.json" +[ -f "$CTX" ] || { echo "$0: no bunq context at $CTX — run tools/pull-and-ship-credits.sh first" >&2; exit 1; } + +SESSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["session_token"])' "$CTX") +USERID=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["user_id"])' "$CTX") +[ -n "$SESSION" ] && [ "$USERID" != "0" ] || { echo "$0: context has no session — run the pull once" >&2; exit 1; } + +API="https://api.bunq.com/v1/user/$USERID/notification-filter-url" +HDRS="-H Content-Type:application/json -H X-Bunq-Client-Authentication:$SESSION -H User-Agent:catcrafts.net-register/1.0" + +if [ "${1:-}" = "--show" ]; then + # Redact the secret out of the response before it reaches a terminal. + # shellcheck disable=SC2086 + curl -s $HDRS "$API" | python3 -c ' +import json,sys,re +try: d=json.load(sys.stdin) +except Exception: print("could not parse bunq response"); sys.exit(1) +for item in d.get("Response",[]): + f=item.get("NotificationFilterUrl") or {} + t=f.get("notification_target","") + print(" category=%-22s target=%s" % (f.get("category","?"), re.sub(r"(/hooks/bunq/).*", r"\1", t))) +' + exit 0 +fi + +TARGET="${1:-}" +case "$TARGET" in + https://*) ;; + *) echo "$0: pass the full https:// callback URL (bunq refuses plain http)" >&2; exit 2 ;; +esac + +# MUTATION alone: it fires on every balance change and carries the Payment +# object, which is exactly what the handler decodes. Adding PAYMENT as well +# would deliver most events twice, and while the handler deduplicates on the +# payment id, doubling the traffic to buy nothing is not a trade. +BODY=$(python3 -c ' +import json,sys +# No notification_delivery_method: bunq rejects it as "Superfluous" on this +# endpoint, because /notification-filter-url already means URL delivery. +print(json.dumps({"notification_filters":[ + {"notification_target":sys.argv[1], + "category":"MUTATION"}]})) +' "$TARGET") + +# shellcheck disable=SC2086 +OUT=$(printf '%s' "$BODY" | curl -s -w '\n%{http_code}' $HDRS -X POST --data-binary @- "$API") +CODE=$(printf '%s' "$OUT" | tail -1) +if [ "$CODE" = 200 ]; then + echo "$0: registered MUTATION callbacks (target not shown)" + echo "$0: verify with $0 --show" +else + # The body can echo the target back, so redact before printing an error. + printf '%s\n' "$OUT" | sed 's#\(/hooks/bunq/\)[^"]*#\1#g' >&2 + echo "$0: bunq refused the registration (HTTP $CODE)" >&2 + exit 1 +fi