94 lines
4.4 KiB
Shell
94 lines
4.4 KiB
Shell
|
|
#!/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"
|