#!/bin/sh # Read the bank from a machine bunq's API key actually permits, and hand the # result to the server that cannot read it itself. # # WHY THIS EXISTS, so nobody "simplifies" it back: # # The bunq API key is IP-restricted at the KEY level, and bunq enforces that on # EVERY call — not only at device registration. Registering the server's IP in # the device's permitted_ips does NOT help; that was tried on 2026-08-20 and # every read from the server still came back "Incorrect API key or IP address". # So the only two options are to widen the key's allowlist in the bunq app, or # to call from an address it already permits. This script is the second. # # It also happens to be the shape the project's key policy wants: a bunq key # can INITIATE PAYMENTS (bunq has no read-only scope), so the public web host # should never hold one. Here it never does — the server only ever reads a file # of incoming credits. # # tools/pull-and-ship-credits.sh # # Environment (BUNQ_KEY is read from the repo-root .env, which is gitignored): # CREDITS_HOST ssh destination (default: hetzner) # CREDITS_REMOTE the file the rail reads (default: the production path) # CREDITS_LOCAL local accumulating copy (default: ~/.cache/catcrafts) # # BUNQ_PERMITTED_IPS is read from .env if set. Leaving it unset registers the # device with "*", which sounds worse than it is HERE: bunq already enforces the # key's own IP allowlist on every call, so "*" on the device is overridden by # the stricter thing. Pinning it as well is defence in depth, at the price of # breaking on a DHCP change — and a residential address does change. Note the # registration happens ONCE, so switching later means deleting the context file # and spending two more of the day's setup calls. set -eu cd "$(dirname "$0")/.." HOST="${CREDITS_HOST:-hetzner}" REMOTE="${CREDITS_REMOTE:-/var/lib/catcrafts/orders.jsonl.transfer-credits.jsonl}" LOCAL_DIR="${CREDITS_LOCAL:-$HOME/.cache/catcrafts}" LOCAL="$LOCAL_DIR/orders.jsonl" [ -f .env ] || { echo "$0: no .env — the bunq key lives there" >&2; exit 1; } # shellcheck disable=SC1091 set -a; . ./.env; set +a # The .env still calls it BUNQ_KEY, from the previous integration. Accept both # rather than making someone rename a working secret. KEY="${BUNQ_API_KEY:-${BUNQ_KEY:-}}" [ -n "$KEY" ] && [ -n "${TRANSFER_IBAN:-}" ] || { echo "$0: need BUNQ_KEY (or BUNQ_API_KEY) and TRANSFER_IBAN in .env" >&2 exit 1 } # Exactly one server binary, or fail loudly: a variant directory embeds a # config hash, so two matches means picking one would be a coin flip. matches=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Server-*' 2>/dev/null | sort) count=$(printf '%s\n' "$matches" | grep -c . || true) [ "$count" = 1 ] || { echo "$0: expected one Catcrafts.Server-* under bin/, found $count" >&2; exit 1; } BIN="$matches/catcrafts-server" mkdir -p "$LOCAL_DIR" chmod 700 "$LOCAL_DIR" # Pull. Appends only what is new, deduplicated on bunq's own payment id, so # running this every few minutes over an overlapping window cannot double-count # a payment into settling an order twice. BUNQ_API_KEY="$KEY" "$BIN" --pull-credits --orders "$LOCAL" >/dev/null CREDITS="$LOCAL.transfer-credits.jsonl" [ -f "$CREDITS" ] || { echo "$0: nothing pulled, not shipping" >&2; exit 1; } # This file is the business's bank statement in miniature, and the context file # beside it holds a private key. The directory is 0700 already; narrow the files # too rather than inheriting whatever umask happened to apply. chmod 600 "$CREDITS" "$LOCAL.transfer-credits.jsonl.bunq-context.json" 2>/dev/null || true # Ship INCOMING credits only. Outgoing lines are supplier payments and card # spending: the matcher ignores negative amounts anyway, so sending them would # put the business's outgoing payment history on a public-facing host for no # settlement benefit at all. TMP=$(mktemp); trap 'rm -f "$TMP"' EXIT python3 - "$CREDITS" >"$TMP" <<'PY' import json, sys for line in open(sys.argv[1]): line = line.strip() if line and json.loads(line)["amount_minor"] > 0: print(line) PY # Refuse to ship an empty file over a good one: a parse failure upstream must # not blank the evidence the server settles from. [ -s "$TMP" ] || { echo "$0: no incoming credits — refusing to overwrite the remote" >&2; exit 1; } ssh "$HOST" "cat > $REMOTE && chown catcrafts:catcrafts $REMOTE && chmod 600 $REMOTE" < "$TMP" echo "$0: shipped $(wc -l < "$TMP") incoming credit(s) to $HOST:$REMOTE"