catcrafts.net/tools/bunq-callback.sh
Jorijn van der Graaf e68d2c245c
All checks were successful
Deploy / build-deploy (push) Successful in 2m20s
finacial page
2026-08-14 02:50:58 +02:00

227 lines
10 KiB
Shell
Executable file

#!/bin/sh
# Register the /financials mutation callback with bunq.
#
# RUN THIS FROM THE MACHINE THE KEY BELONGS TO — your own, not the server.
# That is the whole point: a bunq API key can initiate payments and has no
# read-only scope, so it never goes on the internet-facing box. This script
# uses it once, here, to tell bunq "push mutations to that URL". Afterwards
# bunq talks to the server and the key stays home.
#
# tools/bunq-callback.sh list # accounts + current filters
# tools/bunq-callback.sh set <account-id> <url> # install the filter
#
# The key comes from BUNQ_KEY in the repo-root .env (gitignored). Session
# state — the RSA keypair, the installation token, whether the device was
# registered — persists in ./bunq-state.json (gitignored, 0600), so re-runs
# reuse the registration instead of making a new one every time.
#
# PERMITTED IPS: the device registration binds the key to the addresses that
# may USE it. This defaults to this machine's current public address, NOT the
# server's — bunq delivers callbacks outbound to an HTTPS URL, which has
# nothing to do with this list, so whitelisting the server would grant
# bank-API access to the box most exposed to attack and buy nothing. Override
# with BUNQ_PERMITTED_IP if your address has moved.
#
# Note your home address is probably dynamic: when the ISP rotates it, API
# calls from here start failing with a permission error. Re-running is not
# enough — a device registration is per-key and cannot be re-pointed — so the
# recovery is to add the new address to the existing device via the bunq app,
# or to accept "*" and rely on the key secret alone. The callback itself keeps
# working throughout; only your ability to run this script from here breaks.
set -eu
API_HOST="${BUNQ_API_HOST:-api.bunq.com}"
STATE="${BUNQ_STATE:-bunq-state.json}"
UA='catcrafts.net-tools/1.0 (+https://catcrafts.net)'
[ -f .env ] || { echo "bunq: no .env in $(pwd) — run from the repo root" >&2; exit 1; }
# shellcheck disable=SC1091
BUNQ_KEY=$(sed -n 's/^BUNQ_KEY=//p' .env | head -n1 | tr -d '"'"'"'')
[ -n "$BUNQ_KEY" ] || { echo "bunq: BUNQ_KEY is empty in .env" >&2; exit 1; }
for bin in openssl curl python3; do
command -v "$bin" >/dev/null || { echo "bunq: $bin is required" >&2; exit 1; }
done
# ── state ─────────────────────────────────────────────────────────────
# One JSON file, 0600: it holds a private key.
state_get() {
[ -f "$STATE" ] || { echo ""; return; }
python3 -c 'import json,sys
try: print(json.load(open(sys.argv[1])).get(sys.argv[2],"") or "")
except Exception: print("")' "$STATE" "$1"
}
state_set() {
python3 -c 'import json,os,sys
p=sys.argv[1]
try: d=json.load(open(p))
except Exception: d={}
d[sys.argv[2]]=sys.argv[3]
fd=os.open(p,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
with os.fdopen(fd,"w") as f: json.dump(d,f)' "$STATE" "$1" "$2"
chmod 600 "$STATE"
}
json_str() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
json_get() {
# json_get <dotted-ish key path through the bunq Response envelope>
python3 -c 'import json,sys
def walk(o,k):
if isinstance(o,dict):
if k in o: yield o[k]
for v in o.values(): yield from walk(v,k)
elif isinstance(o,list):
for v in o: yield from walk(v,k)
try: d=json.load(sys.stdin)
except Exception: sys.exit(1)
for hit in walk(d,sys.argv[1]):
print(hit if not isinstance(hit,(dict,list)) else json.dumps(hit)); break' "$1"
}
# ── one signed API call ───────────────────────────────────────────────
# bunq stopped REQUIRING body signatures in 2019, but signing costs nothing
# and a signed request is valid whether or not the server checks.
api() { # api <METHOD> <path> <body|""> <auth-token|"">
_m="$1"; _p="$2"; _b="$3"; _t="${4:-}"
set -- -sS -X "$_m" \
-H "user-agent: $UA" -H 'cache-control: no-cache' \
-H "x-bunq-client-request-id: $(openssl rand -hex 8)" \
-H 'x-bunq-geolocation: 0 0 0 0 000' \
-H 'x-bunq-language: en_US' -H 'x-bunq-region: nl_NL'
if [ -n "$_b" ]; then
_sig=$(printf '%s' "$_b" | openssl dgst -sha256 -sign "$KEYFILE" | openssl base64 -A)
set -- "$@" -H 'content-type: application/json' \
-H "x-bunq-client-signature: $_sig" --data-binary "$_b"
fi
[ -n "$_t" ] && set -- "$@" -H "x-bunq-client-authentication: $_t"
curl "$@" "https://$API_HOST$_p"
}
die_on_error() { # reads a response, prints it and exits if it carries an Error
_r="$1"
if printf '%s' "$_r" | grep -q '"Error"'; then
echo "bunq refused the call:" >&2
printf '%s\n' "$_r" | python3 -m json.tool >&2 2>/dev/null || printf '%s\n' "$_r" >&2
exit 1
fi
}
# ── handshake: installation -> device-server -> session ───────────────
KEYFILE="${BUNQ_KEYFILE:-bunq-client-key.pem}"
if [ ! -f "$KEYFILE" ]; then
echo "bunq: generating a client keypair -> $KEYFILE"
openssl genrsa -out "$KEYFILE" 2048 2>/dev/null
chmod 600 "$KEYFILE"
fi
INSTALL_TOKEN=$(state_get installation_token)
if [ -z "$INSTALL_TOKEN" ]; then
echo "bunq: registering the installation (once, ever)"
_pub=$(openssl rsa -in "$KEYFILE" -pubout 2>/dev/null | json_str)
_resp=$(api POST /v1/installation "{\"client_public_key\":$_pub}" "")
die_on_error "$_resp"
INSTALL_TOKEN=$(printf '%s' "$_resp" | json_get token)
[ -n "$INSTALL_TOKEN" ] || { echo "bunq: no installation token in the response" >&2; exit 1; }
state_set installation_token "$INSTALL_TOKEN"
# bunq's own public key arrives here. Keep it: it is what
# BUNQ_CALLBACK_PUBKEY on the server verifies callback signatures against.
printf '%s' "$_resp" | json_get server_public_key > bunq-server-public-key.pem || true
[ -s bunq-server-public-key.pem ] \
&& echo "bunq: saved bunq-server-public-key.pem (for BUNQ_CALLBACK_PUBKEY)"
fi
if [ "$(state_get device_registered)" != "yes" ]; then
PERMITTED_IP="${BUNQ_PERMITTED_IP:-$(curl -sS --max-time 10 https://ifconfig.me)}"
[ -n "$PERMITTED_IP" ] || { echo "bunq: could not determine this machine's IP" >&2; exit 1; }
echo "bunq: binding the key to $PERMITTED_IP (this machine only — NOT the server)"
printf 'bunq: this is permanent for this key. Continue? [y/N] '
read -r _yn; [ "$_yn" = y ] || [ "$_yn" = Y ] || { echo "aborted"; exit 1; }
_resp=$(api POST /v1/device-server \
"$(python3 -c 'import json,sys
print(json.dumps({"description":"catcrafts.net financials callback registrar",
"secret":sys.argv[1],"permitted_ips":[sys.argv[2]]}))' \
"$BUNQ_KEY" "$PERMITTED_IP")" "$INSTALL_TOKEN")
die_on_error "$_resp"
state_set device_registered yes
fi
# Sessions expire, so this one is not cached.
_resp=$(api POST /v1/session-server \
"$(python3 -c 'import json,sys; print(json.dumps({"secret":sys.argv[1]}))' "$BUNQ_KEY")" \
"$INSTALL_TOKEN")
die_on_error "$_resp"
SESSION=$(printf '%s' "$_resp" | json_get token)
# NOT a naive search for "id": the session response opens with an Id object of
# its own, and taking that one silently addresses every later call to the
# wrong user. The user is whichever of these three the account type produces.
USER_ID=$(printf '%s' "$_resp" | python3 -c 'import json,sys
d=json.load(sys.stdin)
for item in d.get("Response",[]):
for k in ("UserPerson","UserCompany","UserApiKey"):
u=item.get(k)
if isinstance(u,dict) and "id" in u:
print(u["id"]); sys.exit(0)')
[ -n "$SESSION" ] && [ -n "$USER_ID" ] \
|| { echo "bunq: could not open a session" >&2; exit 1; }
# ── commands ──────────────────────────────────────────────────────────
case "${1:-list}" in
list)
echo "bunq: user $USER_ID"
echo
echo "accounts (the donations one goes in donation_accounts in the rules file):"
api GET "/v1/user/$USER_ID/monetary-account?count=50" "" "$SESSION" | python3 -c 'import json,sys
d=json.load(sys.stdin)
for item in d.get("Response",[]):
for kind,acc in item.items():
if not isinstance(acc,dict) or "id" not in acc: continue
iban=next((a.get("value") for a in acc.get("alias",[]) if a.get("type")=="IBAN"),"")
bal=(acc.get("balance") or {}).get("value","?")
aid=acc.get("id"); st=acc.get("status",""); desc=acc.get("description","")
print(f" id={aid:<10} {st:<8} {kind:<22} {desc} {iban} balance {bal}")'
echo
echo "current MUTATION filters:"
api GET "/v1/user/$USER_ID/notification-filter-url" "" "$SESSION" | python3 -m json.tool
;;
set)
ACCOUNT="${2:?usage: tools/bunq-callback.sh set <account-id> <callback-url>}"
URL="${3:?usage: tools/bunq-callback.sh set <account-id> <callback-url>}"
case "$URL" in
https://*) ;;
*) echo "bunq: the callback URL must be https" >&2; exit 1 ;;
esac
# Refuse to point bunq at an endpoint that is not answering yet: a filter
# whose target keeps failing is a filter bunq may disable, and a mutation
# delivered into a 404 is simply lost until the weekly reconciliation.
echo "bunq: checking the endpoint is live before registering it"
_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST \
-H 'content-type: application/json' -d '{}' "$URL" || echo 000)
if [ "$_code" != 200 ]; then
echo "bunq: $URL answered $_code, not 200." >&2
echo " Deploy the financials build and set BUNQ_CALLBACK_SECRET first." >&2
echo " (A wrong secret answers 404 by design — check the secret too.)" >&2
exit 1
fi
echo "bunq: installing the MUTATION filter on account $ACCOUNT"
# POST REPLACES the whole filter set for this account, so this is also how
# you change or clear one.
_resp=$(api POST "/v1/user/$USER_ID/monetary-account/$ACCOUNT/notification-filter-url" \
"$(python3 -c 'import json,sys
print(json.dumps({"notification_filters":[
{"category":"MUTATION","notification_target":sys.argv[1]}]}))' "$URL")" "$SESSION")
die_on_error "$_resp"
printf '%s\n' "$_resp" | python3 -m json.tool
echo
echo "bunq: done. Send yourself €0.01 and watch:"
echo " ssh hetzner journalctl -u catcrafts-server -f"
;;
*)
echo "usage: tools/bunq-callback.sh [list | set <account-id> <callback-url>]" >&2
exit 2
;;
esac