This commit is contained in:
parent
94a995d814
commit
aaa7a8ce99
10 changed files with 543 additions and 8 deletions
94
tools/pull-and-ship-credits.sh
Executable file
94
tools/pull-and-ship-credits.sh
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/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"
|
||||
Loading…
Reference in a new issue