monitoring: QR form + qrencode/zbarimg roundtrip (www.qr)

Harvest taler:// and payto:// (plus app-store links) from landings
and mint JSON; validate shape; re-encode PNG and decode with zbarimg.
This commit is contained in:
Hernâni Marques 2026-07-17 18:43:04 +02:00
parent 026f3ac04c
commit f7856525f3
No known key found for this signature in database
5 changed files with 529 additions and 2 deletions

View file

@ -65,6 +65,9 @@ Each check prints **global** and **grouped** ids:
- **OK** green · **INFO** blue/cyan · **WARN** yellow (never red)
- **ERROR** red · **BLOCKER** magenta · **PROG** cyan bar
- Progress: auto estimate per phase, or `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1`
- **QR (urls):** harvest `taler://` / `payto://` (+ app store `data-qr-url`) from landings and
`demo-withdraw.json` / `auto-account.json` → validate form → `qrencode` PNG → `zbarimg`
decode must match. Requires packages **`qrencode`** + **`zbar-tools`**.
## Commands

View file

@ -15,7 +15,7 @@ Every check line has a **global** run number and a **grouped** id:
| Area | Phase script | Groups (examples) |
|------|--------------|-------------------|
| **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` |
| **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` `qr` |
| **inside** | `check_inside.sh` | `ssh` `bank` `exchange` `merchant` `caddy` `load` |
| **versions** | `check_versions.sh` | `outside` `inside` `compare` |
| **sanity** | `check_sanity.sh` | `bank` `exchange` `merchant` |
@ -54,6 +54,9 @@ Numbering follows **executed** checks (early skip may shift later NN inside the
| **www.merchant-** | `/config` currency + currencies alt_unit_names; listed exchanges alt; webui/intro; **`/terms`**, **`/privacy`** |
| **www.paivana-** | local GOA paywall front (redirect to template) |
| **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; cross-links local |
| **www.qr-** | QR payloads: harvest `taler://` / `payto://` / app `data-qr-url` from landings + mint JSON; **form** check; **qrencode → zbarimg** exact roundtrip; optional static QR images. Needs `qrencode` + `zbar-tools`. Skip: `QR_CHECK=0` |
**QR rule:** `taler://withdraw/HOST/taler-integration/UUID` (no default `:443`/`:80`); `taler://pay/` / `pay-template/`; `payto://…` shape OK; decoded PNG must equal payload. Form errors on withdraw/pay/payto are **ERROR** on local/GOA.
**Legal docs rule:** HTTP 200, non-empty body, not plain `not configured`, not merchant API JSON `code:21`. Local stack may require content needle (terms/privacy/FADP/GOA…).

View file

@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""Collect + validate QR-related payloads for taler-monitoring (www.qr-*).
Sources (CLI paths / URLs handled by caller writing local files):
- landing HTML (href, data-qr-url, data-qr-taler, raw text)
- demo-withdraw.json / auto-account.json (taler_withdraw_uri / qr_payload)
- optional: already-decoded lines from zbarimg (kind=decoded)
Stdout TSV: source\\tkind\\turi\\tstatus\\tdetail
kind: withdraw | pay | pay-template | refund | payto | https | other | empty
status: ok | bad
Exit 0 always (caller tallies ok/bad lines). stderr for hard parse errors only.
"""
from __future__ import annotations
import json
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import unquote, urlparse
# Schemes we care about for wallet / settlement QRs
TALER_RE = re.compile(r"taler://[^\s\"'<>]+", re.I)
PAYTO_RE = re.compile(r"payto://[^\s\"'<>]+", re.I)
HTTPS_RE = re.compile(r"https://[^\s\"'<>]+", re.I)
WITHDRAW_RE = re.compile(
r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)/?$"
)
# order pay / template — host may include path segments after
PAY_RE = re.compile(r"^taler://pay/([^/?#]+)/", re.I)
PAY_TEMPLATE_RE = re.compile(r"^taler://pay-template/([^/?#]+)/", re.I)
REFUND_RE = re.compile(r"^taler://refund/", re.I)
PAYTO_OK_RE = re.compile(r"^payto://[a-z0-9][a-z0-9+.-]*/", re.I)
def looks_like_real_uri(uri: str) -> bool:
"""Drop JS regex fragments, docs ellipses, incomplete placeholders."""
if not uri or len(uri) < 12:
return False
if any(c in uri for c in "[]{}<>\n\r\t"):
return False
if "" in uri or "..." in uri:
return False
# incomplete withdraw stubs in docs
if re.match(r"^taler://withdraw/?$", uri, re.I):
return False
if re.match(r"^taler://pay/?$", uri, re.I):
return False
# trailing junk from HTML/JS
if uri.endswith((")", "(", "\\", "^", "*", "+", "?")):
return False
if re.search(r"[\\^$*+?]", uri):
return False
return True
def strip_default_port_host(host: str) -> str | None:
if not host or host.startswith(":"):
return None
if ":" in host:
h, _, p = host.rpartition(":")
if not h or not p.isdigit():
return None
if p in ("443", "80"):
return None # must be stripped for wallets
return host
def classify_and_validate(uri: str) -> tuple[str, str, str]:
"""Return (kind, status, detail)."""
uri = (uri or "").strip()
if not uri:
return "empty", "bad", "empty"
# strip accidental trailing punctuation from HTML scrape
uri = uri.rstrip(").,;]")
low = uri.lower()
if low.startswith("taler://withdraw/"):
m = WITHDRAW_RE.match(uri)
if not m:
return "withdraw", "bad", "shape need taler://withdraw/HOST/taler-integration/UUID"
host = m.group(1)
if strip_default_port_host(host) is None:
return "withdraw", "bad", "host/port invalid or default :443/:80 not stripped"
return "withdraw", "ok", f"host={host} id={m.group(2)[:12]}"
if low.startswith("taler://pay-template/"):
m = PAY_TEMPLATE_RE.match(uri)
if not m:
return "pay-template", "bad", "need taler://pay-template/HOST/…"
host = m.group(1)
if strip_default_port_host(host) is None:
return "pay-template", "bad", "host/port invalid or default port not stripped"
return "pay-template", "ok", f"host={host}"
if low.startswith("taler://pay/"):
m = PAY_RE.match(uri)
if not m:
return "pay", "bad", "need taler://pay/HOST/…"
host = m.group(1)
if strip_default_port_host(host) is None:
return "pay", "bad", "host/port invalid or default port not stripped"
if "instances" not in uri and "/orders/" not in uri:
# still often valid private order URIs
return "pay", "ok", f"host={host}"
return "pay", "ok", f"host={host}"
if low.startswith("taler://refund/"):
if not REFUND_RE.match(uri):
return "refund", "bad", "bad refund URI"
return "refund", "ok", "refund"
if low.startswith("taler://"):
return "other", "ok", "other taler:// (accepted)"
if low.startswith("payto://"):
if not PAYTO_OK_RE.match(uri):
return "payto", "bad", "need payto://method/…"
# wallet pay QR must not be payto for GOA demo-withdraw (checked elsewhere)
return "payto", "ok", uri.split("?", 1)[0][:72]
if low.startswith("https://") or low.startswith("http://"):
p = urlparse(uri)
if p.scheme not in ("http", "https") or not p.netloc:
return "https", "bad", "bad URL"
return "https", "ok", p.netloc + (p.path or "/")[:40]
return "other", "bad", "unsupported scheme"
class LinkHarvester(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.uris: list[tuple[str, str]] = [] # (source_hint, uri)
def handle_starttag(self, tag, attrs):
ad = {k.lower(): (v or "") for k, v in attrs}
for key in ("href", "src", "data-qr-url", "data-qr-taler", "data-uri", "data-payto"):
if key in ad and ad[key]:
self.uris.append((f"attr:{key}", ad[key].strip()))
for k, v in ad.items():
if k.startswith("data-") and v and (
"taler://" in v.lower() or "payto://" in v.lower() or k.endswith("qr-url")
):
self.uris.append((f"attr:{k}", v.strip()))
def handle_data(self, data):
if not data or "://" not in data:
return
for m in TALER_RE.finditer(data):
self.uris.append(("text", m.group(0)))
for m in PAYTO_RE.finditer(data):
self.uris.append(("text", m.group(0)))
def harvest_html(path: Path, source: str) -> list[tuple[str, str, str]]:
raw = path.read_text(encoding="utf-8", errors="replace")
h = LinkHarvester()
try:
h.feed(raw)
except Exception:
pass
out: list[tuple[str, str, str]] = []
for hint, u in h.uris:
lu = u.lower()
if lu.startswith(("taler://", "payto://", "https://", "http://")):
if looks_like_real_uri(u):
out.append((f"{source}:{hint}", u, "html"))
# raster image paths that look like QR assets (not .js)
elif re.search(r"\.(png|jpe?g|gif)(\?|$)", lu) and "qr" in lu:
out.append((f"{source}:imgpath", u, "imgpath"))
# Prefer attribute/text harvest; light regex only for complete-looking URIs in pre/code
for m in TALER_RE.finditer(raw):
u = m.group(0).rstrip(").,;]'\"")
if looks_like_real_uri(u) and (
WITHDRAW_RE.match(u)
or PAY_RE.match(u)
or PAY_TEMPLATE_RE.match(u)
or u.lower().startswith("taler://refund/")
):
out.append((f"{source}:re", u, "html"))
for m in PAYTO_RE.finditer(raw):
u = m.group(0).rstrip(").,;]'\"")
if looks_like_real_uri(u) and PAYTO_OK_RE.match(u):
out.append((f"{source}:re", u, "html"))
return out
def harvest_json(path: Path, source: str) -> list[tuple[str, str, str]]:
try:
d = json.loads(path.read_text(encoding="utf-8", errors="replace"))
except Exception as e:
return [(source, "", f"json-error:{e}")]
out: list[tuple[str, str, str]] = []
if not isinstance(d, dict):
return out
for key in (
"taler_withdraw_uri",
"qr_payload",
"taler_pay_uri",
"payto_uri",
"payto",
"uri",
):
v = d.get(key)
if isinstance(v, str) and v.strip():
out.append((f"{source}:{key}", v.strip(), "json"))
# nested
for k, v in d.items():
if isinstance(v, str) and (
v.startswith("taler://") or v.startswith("payto://")
):
out.append((f"{source}:{k}", v.strip(), "json"))
return out
def main(argv: list[str]) -> int:
# args: pairs of source_label path
if len(argv) < 3 or len(argv) % 2 == 0:
print(
"usage: check_qr_payloads.py LABEL path [LABEL path ...]",
file=sys.stderr,
)
return 2
seen: set[str] = set()
rows: list[tuple[str, str, str, str, str]] = []
i = 1
while i < len(argv):
label, path_s = argv[i], argv[i + 1]
i += 2
path = Path(path_s)
if not path.is_file():
rows.append((label, "empty", "", "bad", f"missing file {path_s}"))
continue
if path.suffix.lower() in (".html", ".htm") or "html" in path.name:
items = harvest_html(path, label)
elif path.suffix.lower() == ".json" or path.name.endswith(".json"):
items = harvest_json(path, label)
else:
# plain text: one URI per line or raw
text = path.read_text(encoding="utf-8", errors="replace").strip()
items = []
for line in text.splitlines():
line = line.strip()
if line:
items.append((label, line, "text"))
if not items and text:
items.append((label, text, "text"))
for src, uri, _origin in items:
if not uri:
continue
# skip pure fragment / relative without scheme for validate
if uri.startswith("#") or uri == "/":
continue
lu = uri.lower()
if lu.startswith("/") or "imgpath" in src:
continue
if not looks_like_real_uri(uri):
continue
# https: app-store / wallet / explicit data-qr-* only
if lu.startswith("http"):
if "data-qr" not in src and not any(
x in lu
for x in (
"play.google",
"apps.apple",
"f-droid",
"wallet.taler",
"taler.net/wallet",
)
):
continue
if uri in seen:
continue
seen.add(uri)
kind, status, detail = classify_and_validate(uri)
rows.append((src, kind, uri, status, detail))
for src, kind, uri, status, detail in rows:
# TSV — uri may contain tabs rarely; replace
safe = uri.replace("\t", " ").replace("\n", " ")
print(f"{src}\t{kind}\t{safe}\t{status}\t{detail}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

View file

@ -1193,4 +1193,232 @@ else
warn "landing merchant shop assets" "${_ma}/2 present (soft)"
fi
# ---------------------------------------------------------------------------
# QR payloads — form + encode/decode roundtrip (qrencode + zbarimg)
# Collect taler:// + payto:// (+ app-store https from data-qr-url) from landings
# and bank mint JSON; re-encode PNG; decode; require exact payload match.
# Env: QR_CHECK=0 to skip; QR_ECC=M (default) for qrencode -l
# ---------------------------------------------------------------------------
if [ "${QR_CHECK:-1}" = "1" ] && [ "${CHECK_LANDING:-1}" = "1" ]; then
set_group qr
section "www · QR payloads (form + encode/decode · taler:// payto://)"
QR_ECC="${QR_ECC:-M}"
qr_dir="$tmp/qr-check"
mkdir -p "$qr_dir"
_qr_tools=1
if ! command -v qrencode >/dev/null 2>&1; then
warn "qr tools" "qrencode missing — form checks only (apt install qrencode)"
_qr_tools=0
fi
if ! command -v zbarimg >/dev/null 2>&1; then
warn "qr tools" "zbarimg missing — form checks only (apt install zbar-tools)"
_qr_tools=0
fi
if [ "$_qr_tools" = "1" ]; then
ok "qr tools" "qrencode + zbarimg"
fi
# Fetch sources (HTML landings + mint JSON when present)
_qr_args=()
for pair in \
"bank-intro|$BANK_PUBLIC/intro/" \
"merchant-intro|$MERCHANT_PUBLIC/intro/" \
"exchange-intro|$EXCHANGE_PUBLIC/intro/"
do
_qn="${pair%%|*}"
_qu="${pair#*|}"
_qf="$qr_dir/${_qn}.html"
_qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000)
if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then
_qr_args+=("$_qn" "$_qf")
fi
done
for pair in \
"demo-withdraw|$BANK_PUBLIC/intro/demo-withdraw.json" \
"auto-account|$BANK_PUBLIC/intro/auto-account.json"
do
_qn="${pair%%|*}"
_qu="${pair#*|}"
_qf="$qr_dir/${_qn}.json"
_qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000)
if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then
_qr_args+=("$_qn" "$_qf")
fi
done
if [ "${#_qr_args[@]}" -eq 0 ]; then
warn "qr sources" "no landing HTML / mint JSON fetched — skip payload checks"
else
_qr_tsv="$qr_dir/payloads.tsv"
if ! python3 "$ROOT/check_qr_payloads.py" "${_qr_args[@]}" >"$_qr_tsv" 2>"$qr_dir/harvest.err"; then
warn "qr harvest" "collector failed · $(tr '\n' ' ' <"$qr_dir/harvest.err" | head -c 160)"
fi
_qr_n=0
_qr_form_ok=0
_qr_form_bad=0
_qr_rt_ok=0
_qr_rt_bad=0
_qr_taler=0
_qr_payto=0
_qr_bad_sample=""
_qr_i=0
while IFS=$'\t' read -r src kind uri status detail || [ -n "${src:-}" ]; do
[ -n "${src:-}" ] || continue
_qr_n=$((_qr_n + 1))
case "$kind" in
withdraw|pay|pay-template|refund|other) _qr_taler=$((_qr_taler + 1)) ;;
payto) _qr_payto=$((_qr_payto + 1)) ;;
esac
if [ "$status" = "ok" ]; then
_qr_form_ok=$((_qr_form_ok + 1))
else
_qr_form_bad=$((_qr_form_bad + 1))
_qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }form/${kind} ${uri:0:56}"
# taler:// and payto:// form errors are hard on local/GOA
case "$kind" in
withdraw|pay|pay-template|payto)
if [ "${LOCAL_STACK:-1}" = "1" ] || [ "${EXPECT_CURRENCY:-}" = "GOA" ]; then
fail "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
else
warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
fi
;;
*)
warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}"
;;
esac
continue
fi
# Encode → PNG → zbarimg (exact match)
if [ "$_qr_tools" = "1" ] && [ -n "$uri" ]; then
_qr_i=$((_qr_i + 1))
_png="$qr_dir/p-${_qr_i}.png"
# ECC: H for wallet-style long URIs, L for long https store links, else QR_ECC
_ecc="$QR_ECC"
case "$kind" in
https) _ecc=L ;;
withdraw|pay|pay-template) _ecc=M ;;
payto) _ecc=M ;;
esac
if ! qrencode -l "$_ecc" -s 4 -m 2 -o "$_png" "$uri" 2>/dev/null; then
_qr_rt_bad=$((_qr_rt_bad + 1))
fail "qr encode ${kind}" "qrencode failed · ${uri:0:72}"
continue
fi
_got=$(zbarimg --raw -q "$_png" 2>/dev/null | head -n1 | tr -d '\r' || true)
if [ "$_got" = "$uri" ]; then
_qr_rt_ok=$((_qr_rt_ok + 1))
else
_qr_rt_bad=$((_qr_rt_bad + 1))
_qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }decode/${kind}"
fail "qr decode ${kind}" "zbar mismatch · want ${uri:0:48}… · got ${_got:0:48}"
fi
fi
done <"$_qr_tsv"
# Download any obvious static QR images from bank intro and decode (soft)
if [ "$_qr_tools" = "1" ] && [ -f "$qr_dir/bank-intro.html" ]; then
python3 - "$qr_dir/bank-intro.html" "$BANK_PUBLIC" "$qr_dir" <<'PY' >"$qr_dir/img-urls.txt" 2>/dev/null || true
import re, sys
from urllib.parse import urljoin
html = open(sys.argv[1], encoding="utf-8", errors="replace").read()
base = sys.argv[2].rstrip("/") + "/"
out = sys.argv[3]
seen = set()
for m in re.finditer(r'''(?:src|href)=["']([^"']+\.(?:png|jpe?g|gif|svg))["']''', html, re.I):
u = m.group(1)
if "qr" not in u.lower() and "withdraw" not in u.lower():
continue
if u.startswith("data:"):
continue
absu = urljoin(base + "intro/", u)
if absu in seen:
continue
seen.add(absu)
print(absu)
PY
_img_n=0
_img_ok=0
while read -r _img_url; do
[ -n "$_img_url" ] || continue
_img_n=$((_img_n + 1))
_imgf="$qr_dir/img-${_img_n}.bin"
_ic=$(http_body "$_img_url" "$_imgf" 2>/dev/null || echo 000)
if [ "$_ic" != "200" ] || [ ! -s "$_imgf" ]; then
warn "qr image fetch" "HTTP ${_ic} · ${_img_url}"
continue
fi
# zbarimg needs image format; skip non-raster
if ! file "$_imgf" 2>/dev/null | grep -qiE 'PNG|JPEG|PBM|PGM|PPM|GIF'; then
info "qr image skip" "not raster · ${_img_url}"
continue
fi
_dec=$(zbarimg --raw -q "$_imgf" 2>/dev/null | head -n1 | tr -d '\r' || true)
if [ -z "$_dec" ]; then
warn "qr image decode" "no barcode · ${_img_url}"
continue
fi
_img_ok=$((_img_ok + 1))
# validate decoded payload form
printf '%s\n' "$_dec" >"$qr_dir/img-decoded.txt"
_line=$(python3 "$ROOT/check_qr_payloads.py" "img" "$qr_dir/img-decoded.txt" 2>/dev/null | head -1 || true)
if [ -n "$_line" ]; then
_st=$(printf '%s' "$_line" | cut -f4)
_kd=$(printf '%s' "$_line" | cut -f2)
if [ "$_st" = "ok" ]; then
ok "qr image ${_kd}" "${_dec:0:64}"
else
case "$_kd" in
withdraw|pay|pay-template|payto)
fail "qr image ${_kd}" "bad form · ${_dec:0:72}"
;;
*)
warn "qr image ${_kd}" "bad form · ${_dec:0:72}"
;;
esac
fi
else
ok "qr image decode" "${_dec:0:64}"
fi
done <"$qr_dir/img-urls.txt"
if [ "$_img_n" -gt 0 ]; then
info "qr images" "fetched ${_img_n} · decoded ${_img_ok}"
fi
fi
# Summaries
if [ "$_qr_n" -eq 0 ]; then
warn "qr payloads" "none found in landings/mint JSON"
else
if [ "$_qr_form_bad" -eq 0 ]; then
ok "qr form" "${_qr_form_ok}/${_qr_n} ok · taler=${_qr_taler} payto=${_qr_payto}"
else
if [ "${LOCAL_STACK:-1}" = "1" ]; then
fail "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad${_qr_bad_sample:+ · $_qr_bad_sample}"
else
warn "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad"
fi
fi
if [ "$_qr_tools" = "1" ]; then
if [ "$_qr_rt_bad" -eq 0 ] && [ "$_qr_rt_ok" -gt 0 ]; then
ok "qr encode/decode" "${_qr_rt_ok} roundtrips (ecc default ${QR_ECC})"
elif [ "$_qr_rt_ok" -eq 0 ] && [ "$_qr_rt_bad" -eq 0 ]; then
info "qr encode/decode" "no payloads to encode"
else
fail "qr encode/decode" "${_qr_rt_ok} ok · ${_qr_rt_bad} fail"
fi
fi
fi
fi
else
if [ "${QR_CHECK:-1}" != "1" ]; then
info "qr checks" "skipped (QR_CHECK=0)"
fi
fi
summary

View file

@ -59,6 +59,8 @@ Env (same meaning):
NO_COLOR=1 / CLICOLOR=0 disable green/yellow/red tags (default: coloured)
SKIP_SSH=1 NO_COLOR=1
PERF_WARN_MS PERF_FAIL_MS (urls latency; default 8000 / 20000)
QR_CHECK=0 skip QR form + qrencode/zbarimg (urls phase)
QR_ECC=M qrencode ECC level (default M)
KOOPA_SSH KOOPA_SSH_FALLBACKS (default koopa → koopa-external)
METRICS_LOAD=0 skip host/container RAM/CPU probes (e2e/ladder/inside)
EOF
@ -224,7 +226,7 @@ if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then
_pt=0
for p in "${PHASES[@]}"; do
case "$p" in
urls) _pt=$((_pt + 55)) ;;
urls) _pt=$((_pt + 70)) ;;
inside) _pt=$((_pt + 25)) ;;
versions) _pt=$((_pt + 20)) ;;
sanity) _pt=$((_pt + 30)) ;;