From 332319b76ab2caa6d77d12e06cd8d73ce94eb589 Mon Sep 17 00:00:00 2001 From: vmtest lane Date: Tue, 6 Oct 2026 23:18:03 +0200 Subject: [PATCH] tollgate-clientd: laptop auto-top-up client + cloud-lab test lanes Squashed rebase of the eight commits from #425 onto current main (8655e33). CHANGELOG entries under [Unreleased]. --- .gitignore | 1 + CHANGELOG.md | 43 + scripts/tollgate-clientd.py | 949 ++++++++++++++++++ tests/clientd/test_tollgate_clientd.py | 587 +++++++++++ tests/cloud-lab/Dockerfile.client-ns | 12 + tests/cloud-lab/README.md | 2 + .../configs/upstream-bytes-config.json | 51 + tests/cloud-lab/docker-compose.yml | 55 + tests/cloud-lab/fake-ndsctl-filecounters.sh | 38 + tests/cloud-lab/run-clientd-scenarios.sh | 261 +++++ tests/cloud-lab/test_clientd_autotopup.py | 139 +++ 11 files changed, 2138 insertions(+) create mode 100755 scripts/tollgate-clientd.py create mode 100644 tests/clientd/test_tollgate_clientd.py create mode 100644 tests/cloud-lab/Dockerfile.client-ns create mode 100644 tests/cloud-lab/configs/upstream-bytes-config.json create mode 100755 tests/cloud-lab/fake-ndsctl-filecounters.sh create mode 100755 tests/cloud-lab/run-clientd-scenarios.sh create mode 100644 tests/cloud-lab/test_clientd_autotopup.py diff --git a/.gitignore b/.gitignore index 9238edf99..01de792d5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ src/cmd/tollgate-cli/tollgate-cli **/tollwallet_db/* tests/*.bin tests/__pycache__/ +__pycache__/ tests/.env # Per-checkout cloud-lab compose project isolation (.env.example is the template) tests/cloud-lab/.env diff --git a/CHANGELOG.md b/CHANGELOG.md index 6affb4cf7..5299387fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,49 @@ and [Semantic Versioning](https://semver.org/). > `v0.4.0` tag. ## [Unreleased] +### Added + +- **`tollgate-clientd` scenario battery.** New + `tests/cloud-lab/run-clientd-scenarios.sh` (compose profile + `clientd`): S2 bytes-metric lifecycle against a data-metered upstream + with live file-driven ndsctl counters (renewal before exhaustion, gate + close, re-payment), S3 a real nutshell wallet paying live (image + `Dockerfile.client-ns`, wallet persisted via `CASHU_DIR`), S4 mint + outage mid-session (logged backoff, recovery after restart), and S5 + keyset rotation + swap fees (auto-skipped unless the rotation mints + are defined in the compose). Hard-won notes baked in: ms sessions in + the lab expire lazily (expiry recorded, not asserted); fee-charging + mints credit `floor((amount − fee)/price)` steps; fresh + `pip install cashu` needs `marshmallow<4` pinned (environs + incompatibility with marshmallow 4.x). ([#425](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/425)) + +- **`tollgate-clientd` laptop client.** New + `scripts/tollgate-clientd.py`: detects a TollGate on the default + gateway, registers with the captive portal (nodogsplash workaround), + shows the remaining bytes/seconds live, and auto-tops-up with ecash + from a local wallet — cdk-cli or nutshell, auto-detected — before the + allotment runs out. One-shot `--status`/`--json` output for status + bars, `--waybar` module JSON (text/tooltip/class/percentage) for + waybar-style bars, `--dry-run`, `--list-offers`, and a `--selftest` + that runs the full discovery/payment/renewal loop against a built-in + mock TollGate. The cdk-cli adapter supports both the modern + (`--v3 --amount`) and legacy (stdin) CLIs and expands short keyset IDs + for cdk-mintd compatibility. Money-path guards: payment tokens are + written to disk (0600, `--state-dir`, default + `~/.local/state/tollgate-clientd`) before the POST and reused on + retry instead of minting a fresh one; router notice `code` tags are + read and terminal codes (`payment-error-below-swap-fee`, + `-invalid-token`, `-token-spent`) stop the daemon with a non-zero + exit instead of feeding the backoff loop; the payment POST timeout is + configurable (`--payment-timeout`, default 30 s — routers may swap + synchronously); and `--max-blind-payments` (default 3) bounds the + runaway where payments are accepted but `/usage` never reflects them + (#422), stopping the daemon non-zero with the unacknowledged token's + location. Tested by a new unit suite + (`tests/clientd/`, 39 tests over in-process mock TollGates) and a + cloud-lab e2e lane (`tests/cloud-lab/test_clientd_autotopup.py`) + that pays the real backend and observes threshold renewal. ([#425](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/425)) + ### Fixed diff --git a/scripts/tollgate-clientd.py b/scripts/tollgate-clientd.py new file mode 100755 index 000000000..774427dd5 --- /dev/null +++ b/scripts/tollgate-clientd.py @@ -0,0 +1,949 @@ +#!/usr/bin/env python3 +"""tollgate-clientd — keep a laptop alive behind a TollGate. + +A client-side companion for TollGate routers: detects a TollGate on the +default gateway, registers this machine with the captive portal, then +continuously shows how many bytes or seconds of connectivity remain and +automatically tops up with ecash from a local Cashu wallet before the +current allotment runs out. + +Wallets supported (auto-detected, or forced with --wallet): + * cdk-cli — https://github.com/cashubtc/cdk (crates/cdk-cli) + * nutshell — `pip install cashu "marshmallow<4"` — the pin is required: + marshmallow 4.x breaks cashu's environs dependency today + +Wire protocol spoken (all against the TollGate gateway, port 2121): + GET / -> kind 10021 advertisement (metric, step_size, price_per_step) + GET /usage -> "used/allotment" ("-1/-1" when no session) + POST /?mac=MAC -> body: raw Cashu token; reply: kind 1022 session event + (kind 21023 notice event on rejection) + +Examples: + tollgate-clientd # daemon: auto top-up + live status line + tollgate-clientd --status # print current status once, exit + tollgate-clientd --status --json # one-shot JSON (waybar/script feeds) + tollgate-clientd --steps 5 # buy 5 steps per top-up + tollgate-clientd --renew-below 40MB # custom renewal threshold + tollgate-clientd --dry-run # show status, never pay + tollgate-clientd --list-offers # just print the gateway's pricing + tollgate-clientd --selftest # run against a built-in mock TollGate +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request + +PORT = 2121 +HTTP_TIMEOUT = 5.0 +PAYMENT_TIMEOUT = 30.0 # a router may swap synchronously — don't + # abandon a live payment at 5 s (#423) +POLL_INTERVAL = 2.0 # seconds between /usage polls +PAYMENT_THROTTLE = 5.0 # minimum seconds between payment attempts +PAYMENT_BACKOFF_MAX = 60.0 # max seconds of failure backoff +PORTAL_PORT = 80 # hit once per payment so nodogsplash registers us +DEFAULT_MAX_BLIND_PAYMENTS = 3 + +DEFAULT_RENEW_BELOW_BYTES = 20 * 1024 * 1024 # 20 MiB +DEFAULT_RENEW_BELOW_MS = 30_000 # 30 s + +# Router notice codes that retrying can never fix. Paying again with the +# same amount is futile (below-swap-fee), the token is already gone +# (spent/invalid), the keyset it was minted under is retired +# (keyset-expired: rotation makes re-POSTing the same note pointless +# forever), or the router itself could not decide the outcome +# (outcome-unknown: the POST may have been credited, and re-sending it +# is exactly the double-spend the router's message forbids). +TERMINAL_PAYMENT_CODES = { + "payment-error-below-swap-fee", + "payment-error-invalid-token", + "payment-error-token-spent", + "payment-error-keyset-expired", + "payment-outcome-unknown", +} + +RECV_BUF = 1 << 20 + + +class ClientError(Exception): + pass + + +class TerminalPaymentError(ClientError): + """The router rejected the payment with a code retrying cannot fix.""" + + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +class BlindPaymentCapExceeded(ClientError): + """Payments succeed but /usage never reflects them: stop paying.""" + + +def default_state_dir() -> str: + """Where unacknowledged payment tokens are preserved (XDG state).""" + xdg = os.environ.get("XDG_STATE_HOME") + base = xdg if xdg else os.path.expanduser("~/.local/state") + return os.path.join(base, "tollgate-clientd") + + +# --------------------------------------------------------------------------- +# Platform helpers: default gateway, interface MAC +# --------------------------------------------------------------------------- + +def _run(cmd: list[str]) -> str: + out = subprocess.run(cmd, capture_output=True, text=True, check=False) + if out.returncode != 0: + raise ClientError(f"{' '.join(cmd)} failed: {out.stderr.strip()}") + return out.stdout + + +def find_gateway() -> tuple[str, str]: + """Return (gateway_ip, source_interface) of the default route.""" + if shutil.which("ip"): + for line in _run(["ip", "route", "show", "default"]).splitlines(): + m = re.search(r"default via (\S+).*dev (\S+)", line) + if m: + return m.group(1), m.group(2) + raise ClientError("no default route found (are you connected?)") + # macOS + out = _run(["route", "-n", "get", "default"]) + gw = re.search(r"gateway:\s*(\S+)", out) + iface = re.search(r"interface:\s*(\S+)", out) + if gw and iface: + return gw.group(1), iface.group(1) + raise ClientError("no default route found (are you connected?)") + + +def find_mac(interface: str) -> str: + """Return the MAC address of the interface heading to the gateway.""" + sysfs = f"/sys/class/net/{interface}/address" + if os.path.exists(sysfs): + with open(sysfs) as f: + mac = f.read().strip() + if mac: + return mac + # macOS + out = _run(["ifconfig", interface]) + m = re.search(r"ether\s+([0-9a-fA-F:]{17})", out) + if m: + return m.group(1).lower() + raise ClientError(f"could not determine MAC of interface {interface}") + + +# --------------------------------------------------------------------------- +# HTTP helpers (stdlib only) +# --------------------------------------------------------------------------- + +def http_get(url: str, timeout: float = HTTP_TIMEOUT) -> str: + with urllib.request.urlopen(url, timeout=timeout) as resp: + return resp.read(RECV_BUF).decode("utf-8", "replace") + + +def http_post(url: str, body: str, content_type: str = "text/plain", + timeout: float = HTTP_TIMEOUT) -> tuple[int, str]: + req = urllib.request.Request( + url, data=body.encode("utf-8"), method="POST", + headers={"Content-Type": content_type}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, resp.read(RECV_BUF).decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read(RECV_BUF).decode("utf-8", "replace") + + +# --------------------------------------------------------------------------- +# TollGate wire protocol +# --------------------------------------------------------------------------- + +class Offer: + def __init__(self, price: int, unit: str, mint_url: str, min_steps: int): + self.price = price # sats per step + self.unit = unit # e.g. "sat" + self.mint_url = mint_url + self.min_steps = min_steps + + +class Advertisement: + def __init__(self, pubkey: str, metric: str, step_size: int, + offers: list[Offer]): + self.pubkey = pubkey + self.metric = metric # "bytes" | "milliseconds" + self.step_size = step_size + self.offers = offers + + def default_offer(self, mint_url: str | None = None) -> Offer: + if mint_url: + for o in self.offers: + if o.mint_url.rstrip("/") == mint_url.rstrip("/"): + return o + raise ClientError(f"mint {mint_url} not accepted by this TollGate") + if self.offers: + return self.offers[0] + raise ClientError("TollGate advertises no mints") + + +def parse_advertisement(text: str) -> Advertisement: + """Parse the kind 10021 discovery event served at GET /.""" + try: + ev = json.loads(text) + except json.JSONDecodeError as e: + raise ClientError(f"gateway is not a TollGate (bad advertisement: {e})") + if ev.get("kind") != 10021: + raise ClientError("gateway is not a TollGate (no advertisement)") + metric, step_size, offers = "bytes", 0, [] + for tag in ev.get("tags", []): + name = tag[0] + if name == "metric" and len(tag) >= 2: + metric = tag[1] + elif name == "step_size" and len(tag) >= 2: + step_size = int(tag[1]) + elif name == "price_per_step" and len(tag) >= 5: + offers.append(Offer(int(tag[2]), tag[3], tag[4], + int(tag[5]) if len(tag) >= 6 else 0)) + return Advertisement(ev.get("pubkey", ""), metric, step_size, offers) + + +def _base(gateway: str) -> str: + """Base URL of the TollGate API on a gateway (host, or host:port for tests).""" + if ":" in gateway: + return f"http://{gateway}" + return f"http://{gateway}:{PORT}" + + +def register_with_portal(gateway: str) -> None: + """Best-effort GET on port 80 so nodogsplash registers our MAC + (router issue #88 workaround — the session must authorize a MAC the + portal has actually seen).""" + try: + http_get(f"http://{gateway}:{PORTAL_PORT}/", timeout=3.0) + except Exception: # noqa: BLE001, S110 — best effort by design + pass + + +def get_usage(gateway: str) -> tuple[int, int] | None: + """Return (used, allotment) or None when there is no session.""" + body = http_get(f"{_base(gateway)}/usage").strip() + m = re.fullmatch(r"(-?\d+)/(-?\d+)", body) + if not m: + raise ClientError(f"unexpected /usage response: {body!r}") + used, allotment = int(m.group(1)), int(m.group(2)) + if used < 0 or allotment < 0: + return None + return used, allotment + + +def pay(gateway: str, mac: str, token: str, timeout: float = PAYMENT_TIMEOUT) -> dict: + """POST a raw Cashu token; return the session event on success.""" + status, body = http_post(f"{_base(gateway)}/?mac={mac}", token, timeout=timeout) + try: + ev = json.loads(body) + except json.JSONDecodeError: + raise ClientError(f"payment reply not JSON (HTTP {status}): {body[:200]!r}") + if status == 200 and ev.get("kind") == 1022: + return ev + # kind 21023 notice (or anything else) — surface the router's message + # and its coded reason; terminal codes must stop the daemon instead + # of feeding the retry loop (#423). + message = ev.get("content") or "" + code = None + for tag in ev.get("tags", []): + if tag[0] == "message": + message = tag[1] if len(tag) > 1 else message + elif tag[0] == "code" and len(tag) > 1: + code = tag[1] + if code in TERMINAL_PAYMENT_CODES: + raise TerminalPaymentError( + code, f"payment rejected (HTTP {status}, terminal code {code}): " + f"{message or body[:200]}") + raise ClientError(f"payment rejected (HTTP {status}): {message or body[:200]}") + + +# --------------------------------------------------------------------------- +# Wallet adapters — create a Cashu token worth `amount_sats` at `mint_url` +# --------------------------------------------------------------------------- + +def _extract_token(stdout: str) -> str: + for line in reversed(stdout.strip().splitlines()): + line = line.strip() + if line.startswith("cashu"): + return line + raise ClientError(f"no token in wallet output: {stdout.strip()[-200:]!r}") + + +def _expand_keyset_ids(token: str, mint_url: str) -> str: + """Rewrite short 8-byte keyset IDs in a V3 token to full 33-byte IDs. + + cdk-cli stores proofs with truncated keyset IDs; cdk-mintd's swap + endpoint only accepts full IDs. Mirrors the proven workaround in + tests/cloud-lab/conftest.py. Best effort: any failure returns the + token unchanged.""" + import base64 + if not token.startswith("cashuA"): + return token + try: + payload = token[6:] + payload += "=" * (4 - len(payload) % 4) + data = json.loads(base64.urlsafe_b64decode(payload)) + keysets = json.loads(http_get(f"{mint_url}/v1/keysets")).get("keysets", []) + short_to_full = {ks["id"][:16]: ks["id"] for ks in keysets + if len(ks["id"]) == 66} + for entry in data.get("token", []): + for proof in entry.get("proofs", []): + pid = proof.get("id", "") + if len(pid) == 16 and pid in short_to_full: + proof["id"] = short_to_full[pid] + return "cashuA" + base64.urlsafe_b64encode( + json.dumps(data).encode()).decode() + except Exception as e: # noqa: BLE001 — expansion is best effort by design + # But not silent: cdk-mintd rejects short keyset IDs outright, so a + # token that skips expansion is very likely to be refused with a + # misleading error at pay time. Say why the token went out bare. + print(f"warning: keyset-ID expansion failed ({e}); sending the " + f"token with its original keyset IDs — cdk-mintd mints will " + f"reject it", file=sys.stderr) + return token + + +_CDK_AMOUNT_FLAG: bool | None = None + + +def _cdk_cli_has_amount_flag() -> bool: + global _CDK_AMOUNT_FLAG + if _CDK_AMOUNT_FLAG is None: + proc = subprocess.run(["cdk-cli", "send", "--help"], capture_output=True, + text=True, check=False) + _CDK_AMOUNT_FLAG = "--amount" in (proc.stdout + proc.stderr) + return _CDK_AMOUNT_FLAG + + +def wallet_send_cdk_cli(mint_url: str, amount_sats: int, + wallet_dir: str | None) -> str: + cmd = ["cdk-cli"] + if wallet_dir: + cmd += ["-w", wallet_dir] + cmd += ["send", "--mint-url", mint_url] + stdin = None + if _cdk_cli_has_amount_flag(): + # modern cdk-cli: explicit amount, V3 token (cdk-mintd-friendly) + cmd += ["--v3", "--amount", str(amount_sats)] + else: + # legacy cdk-cli (as used by the hardware e2e fleet): amount via stdin + stdin = f"{amount_sats}\n" + proc = subprocess.run(cmd, input=stdin, capture_output=True, + text=True, check=False) + if proc.returncode != 0: + raise ClientError(f"cdk-cli send failed: {proc.stderr.strip() or proc.stdout.strip()}") + return _expand_keyset_ids(_extract_token(proc.stdout), mint_url) + + +def wallet_send_nutshell(mint_url: str, amount_sats: int, + wallet_dir: str | None) -> str: + # No --mint flag exists: mint selection is the global --host option and + # must precede the subcommand; --yes keeps it non-interactive. + cmd = ["cashu"] + if wallet_dir: + cmd += ["--wallet", wallet_dir] + cmd += ["--host", mint_url, "--yes", "send", str(amount_sats)] + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + if proc.returncode != 0: + raise ClientError(f"cashu send failed: {proc.stderr.strip() or proc.stdout.strip()}") + return _extract_token(proc.stdout) + + +def resolve_wallet(name: str): + if name == "cdk-cli": + if not shutil.which("cdk-cli"): + raise ClientError("cdk-cli not found in PATH") + return "cdk-cli", wallet_send_cdk_cli + if name == "nutshell": + if not shutil.which("cashu"): + raise ClientError("nutshell (`cashu`) not found in PATH") + return "nutshell", wallet_send_nutshell + if name == "auto": + for candidate, sender in (("cdk-cli", wallet_send_cdk_cli), + ("nutshell", wallet_send_nutshell)): + if shutil.which(candidate): + return candidate, sender + raise ClientError("no wallet found: install cdk-cli or nutshell (pip install cashu)") + raise ClientError(f"unknown wallet: {name}") + + +# --------------------------------------------------------------------------- +# Formatting +# --------------------------------------------------------------------------- + +def human_bytes(n: float) -> str: + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024 or unit == "TB": + return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B" + n /= 1024 + return f"{n:.1f} TB" + + +def human_ms(ms: float) -> str: + s = int(ms // 1000) + h, rem = divmod(s, 3600) + m, sec = divmod(rem, 60) + return f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}" + + +def format_remaining(metric: str, remaining: int) -> str: + if metric == "milliseconds": + return f"{human_ms(remaining)} left" + return f"{human_bytes(remaining)} left" + + +def parse_renew_below(value: str, metric: str) -> int: + """Accept '20MB', '512KB', '30s', '1m', or a plain byte/ms count.""" + v = value.strip().lower() + m = re.fullmatch(r"([0-9]*\.?[0-9]+)\s*(b|kb|kib|mb|mib|gb|gib|s|m|h|ms)?", v) + if not m: + raise ClientError(f"bad --renew-below value: {value!r}") + amount = float(m.group(1)) + unit = m.group(2) or "" + if metric == "milliseconds": + return int(amount * {"ms": 1, "s": 1000, "m": 60_000, "h": 3_600_000}.get(unit, 1)) + return int(amount * {"b": 1, "kb": 1024, "kib": 1024, "mb": 1024**2, + "mib": 1024**2, "gb": 1024**3, "gib": 1024**3}.get(unit, 1)) + + +# --------------------------------------------------------------------------- +# Daemon +# --------------------------------------------------------------------------- + +class ClientDaemon: + def __init__(self, args, wallet_name: str, wallet_sender): + self.args = args + self.wallet_name = wallet_name + self.wallet_sender = wallet_sender + self.gateway, self.iface = (args.gateway, args.iface or "") if args.gateway \ + else find_gateway() + if not self.iface and shutil.which("ip"): + # derive interface from the route to the gateway + m = re.search(r"dev (\S+)", _run(["ip", "route", "get", self.gateway])) + self.iface = m.group(1) if m else "" + self.mac = args.mac or find_mac(self.iface) + self.ad = parse_advertisement(http_get(f"{_base(self.gateway)}/")) + self.offer = self.ad.default_offer(args.mint) + self.renew_below = parse_renew_below(args.renew_below, self.ad.metric) \ + if args.renew_below else None + self.steps = max(args.steps, self.offer.min_steps) + self.last_payment = 0.0 + self.backoff = PAYMENT_THROTTLE + self.state_dir = args.state_dir or default_state_dir() + self.max_blind_payments = getattr( + args, "max_blind_payments", DEFAULT_MAX_BLIND_PAYMENTS) + self.payment_timeout = getattr( + args, "payment_timeout", PAYMENT_TIMEOUT) + # A payment that succeeds while /usage keeps reporting no session + # cannot be distinguished from one that vanished — count those and + # stop after --max-blind-payments instead of re-paying forever (#422). + self.blind_payments = 0 + # Allotment of the CURRENT session only (None between sessions): + # the server creates a fresh session after expiry (#541), so an + # all-time high-water mark would never be exceeded again and + # healthy post-expiry renewals would trip the blind cap. + self.last_allotment: int | None = None + self._session_start: str | None = None + + def amount_sats(self) -> int: + return self.steps * self.offer.price + + def _pending_path(self) -> str: + digest = hashlib.sha1( + f"{self.offer.mint_url}|{self.amount_sats()}".encode()).hexdigest()[:12] + return os.path.join(self.state_dir, f"pending-{digest}.token") + + def _load_pending_token(self) -> str | None: + try: + with open(self._pending_path()) as f: + return f.read().strip() or None + except OSError: + return None + + def _save_pending_token(self, token: str) -> None: + os.makedirs(self.state_dir, mode=0o700, exist_ok=True) + path = self._pending_path() + with open(path, "w") as f: + f.write(token + "\n") + os.chmod(path, 0o600) + + def _clear_pending_token(self) -> None: + try: + os.unlink(self._pending_path()) + except OSError: + pass + + def top_up(self) -> str: + """Pay for self.steps; returns a short status message.""" + now = time.monotonic() + if now - self.last_payment < PAYMENT_THROTTLE: + return "throttled" + self.last_payment = now + register_with_portal(self.gateway) + # A token minted for a previous attempt that never got acknowledged + # is still money: reuse it instead of minting (and paying for) + # another one (#423). + token = self._load_pending_token() + reused = token is not None + if not reused: + token = self.wallet_sender(self.offer.mint_url, self.amount_sats(), + self.args.wallet_dir) + self._save_pending_token(token) + try: + ev = pay(self.gateway, self.mac, token, + timeout=self.payment_timeout) + except TerminalPaymentError as e: + if e.code == "payment-error-token-spent": + self._clear_pending_token() + elif e.code == "payment-outcome-unknown": + # The POST may have been credited: keep the token on disk + # for reconciliation with the operator, but under a name + # the reuse path will never pick up again. + path = self._pending_path() + try: + os.rename(path, path + ".outcome-unknown") + except OSError: + pass + raise + self._clear_pending_token() + self.backoff = PAYMENT_THROTTLE + allotment = "" + start_time = "" + for tag in ev.get("tags", []): + if tag[0] == "allotment" and len(tag) > 1: + allotment = tag[1] + elif tag[0] == "start-time" and len(tag) > 1: + start_time = tag[1] + # A new start-time means the server opened a fresh session (#541): + # re-arm the per-session comparison so the next /usage observation + # of this session counts as proof of credit again. + if start_time and start_time != self._session_start: + self._session_start = start_time + self.last_allotment = None + # The router's 1022 event is NOT proof of credit for this client: + # in the #422 failure mode payments are accepted while /usage + # stays -1/-1 forever. Only run()'s /usage observations reset the + # counter; a payment that nothing observed counts as blind. + self.blind_payments += 1 + if self.blind_payments >= self.max_blind_payments: + raise BlindPaymentCapExceeded( + f"{self.blind_payments} payment(s) accepted by the gateway " + f"without /usage ever reflecting them — the router cannot " + f"map this client to a session (its /usage stays -1/-1; " + f"see #422). Not paying again. These payments were " + f"ACCEPTED by the gateway, so the sats are in its wallet " + f"— reconcile with the gateway operator, not with local " + f"files. (Only a rejected attempt leaves a recoverable " + f"token under {self.state_dir}.)") + origin = "reused pending" if reused else "minted" + msg = (f"paid {self.amount_sats()} sats ({origin} token, " + f"allotment now {allotment or '?'})") + if allotment.isdigit(): + credited_steps = int(allotment) // self.ad.step_size + if credited_steps < self.steps: + msg += (f" [warning: {credited_steps} of {self.steps} " + f"paid-for step(s) credited — the mint's swap fee " + f"reduced the purchase; buy more per top-up]") + return msg + + def status(self) -> dict: + usage = get_usage(self.gateway) + remaining = None if usage is None else usage[1] - usage[0] + return { + "gateway": self.gateway, + "interface": self.iface, + "mac": self.mac, + "metric": self.ad.metric, + "step_size": self.ad.step_size, + "mint": self.offer.mint_url, + "price_per_step": self.offer.price, + "wallet": self.wallet_name, + "session_active": usage is not None, + "usage": usage[0] if usage else None, + "allotment": usage[1] if usage else None, + "remaining": remaining, + } + + def needs_top_up(self, remaining: int | None) -> bool: + threshold = self.renew_below + if threshold is None: + threshold = (DEFAULT_RENEW_BELOW_MS if self.ad.metric == "milliseconds" + else DEFAULT_RENEW_BELOW_BYTES) + return remaining is None or remaining <= threshold + + def observe(self, st: dict) -> None: + """Feed one /usage observation into the blind-payment tracker. + + Growth within the current session proves the gateway credits + this client — clear the blind counter. A missing last_allotment + means a session not yet observed (fresh after expiry, #541): the + first observation counts as proof the same way. Observing no + session arms the reset for whatever session the server opens + next.""" + if st["allotment"] is not None: + if (self.last_allotment is None + or st["allotment"] > self.last_allotment): + self.blind_payments = 0 + self.last_allotment = st["allotment"] + else: + self.last_allotment = None + + def run(self) -> None: + intro = (f"TollGate {self.gateway} | {self.ad.metric} | " + f"{self.steps} step(s) = {self.amount_sats()} sats via " + f"{self.wallet_name} @ {self.offer.mint_url}") + print(intro, file=sys.stderr) + last_line = "" + while True: + try: + st = self.status() + self.observe(st) + if st["remaining"] is None: + line = f"[TollGate {self.gateway}] no session — needs payment" + else: + line = (f"[TollGate {self.gateway}] " + f"{format_remaining(self.ad.metric, st['remaining'])} " + f"({st['usage']}/{st['allotment']})") + if self.needs_top_up(st["remaining"]): + if self.args.dry_run: + line += " [dry-run: would top up]" + else: + line += " [topping up...]" + if line != last_line: + emit(line) + last_line = line + if (self.needs_top_up(st["remaining"]) and not self.args.dry_run + and time.monotonic() - self.last_payment >= self.backoff): + try: + msg = self.top_up() + print(f" -> {msg}", file=sys.stderr) + except (TerminalPaymentError, BlindPaymentCapExceeded) as e: + print(f"FATAL: {e}", file=sys.stderr) + print("stopping — retrying cannot fix this; " + "see the message above.", file=sys.stderr) + raise + except (ClientError, OSError) as e: + self.backoff = min(self.backoff * 2, PAYMENT_BACKOFF_MAX) + self.last_payment = time.monotonic() + print(f" !! top-up failed: {e} " + f"(retrying in {self.backoff:.0f}s)", file=sys.stderr) + except (TerminalPaymentError, BlindPaymentCapExceeded): + raise + except (ClientError, urllib.error.URLError, OSError) as e: + print(f"waiting: {e}", file=sys.stderr) + time.sleep(self.args.interval) + continue + time.sleep(self.args.interval) + + +def emit(line: str) -> None: + """Refresh a single status line on a TTY; plain lines when piped.""" + if sys.stdout.isatty(): + sys.stdout.write("\r\033[K" + line) + sys.stdout.flush() + else: + print(line, flush=True) + + +# --------------------------------------------------------------------------- +# One-shot modes +# --------------------------------------------------------------------------- + +def print_status(daemon: ClientDaemon, as_json: bool) -> None: + st = daemon.status() + if as_json: + print(json.dumps(st)) + elif st["remaining"] is None: + print(f"TollGate {st['gateway']}: no session (would pay " + f"{daemon.amount_sats()} sats for {daemon.steps} step(s))") + else: + print(f"TollGate {st['gateway']}: {format_remaining(st['metric'], st['remaining'])} " + f"(usage {st['usage']}/{st['allotment']})") + + +def print_waybar(daemon: ClientDaemon) -> None: + """One-shot waybar module JSON: re-run this via a custom/tollgate module + with "interval": 5 and "return-type": "json".""" + st = daemon.status() + threshold = daemon.renew_below + if threshold is None: + threshold = (DEFAULT_RENEW_BELOW_MS if st["metric"] == "milliseconds" + else DEFAULT_RENEW_BELOW_BYTES) + if st["remaining"] is None: + text, module_class, pct = "no session", "critical", 0 + else: + text = format_remaining(st["metric"], st["remaining"]).replace(" left", "") + pct = int(100 * st["remaining"] / st["allotment"]) if st["allotment"] else 0 + module_class = "warning" if st["remaining"] <= 2 * threshold else "good" + print(json.dumps({ + "text": f"TG {text}", + "tooltip": (f"TollGate {st['gateway']} ({st['metric']})\n" + f"usage {st['usage']}/{st['allotment']}\n" + f"mint {st['mint']}\nwallet {st['wallet']}"), + "class": module_class, + "percentage": pct, + })) + + +def print_offers(daemon: ClientDaemon) -> None: + ad = daemon.ad + print(f"gateway {daemon.gateway} metric={ad.metric} step_size={ad.step_size}") + for o in ad.offers: + print(f" {o.price} {o.unit}/step mint={o.mint_url} min_steps={o.min_steps}") + + +# --------------------------------------------------------------------------- +# Selftest: mock TollGate + stub wallet, exercises the full loop in-process +# --------------------------------------------------------------------------- + +def selftest() -> int: + import shutil + import tempfile + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + + state = {"usage": 0, "allotment": 0, "payments": [], "polls": 0, + "session_open": False, "start_time": 0} + STEP = 21 * 1024 * 1024 # 21 MiB per step, matching e2e defaults + PRICE = 1 + + class MockTollGate(BaseHTTPRequestHandler): + def log_message(self, *a): # silence + pass + + def do_GET(self): + if self.path == "/": + adv = {"kind": 10021, "pubkey": "00" * 32, "tags": [ + ["metric", "bytes"], ["step_size", str(STEP)], + ["price_per_step", "cashu", str(PRICE), "sat", + "https://mockmint.example", "1"], + ], "content": ""} + body = json.dumps(adv).encode() + elif self.path == "/usage": + state["polls"] += 1 + if state["payments"]: + state["usage"] += 8 * 1024 * 1024 # burn 8 MiB per poll + if state["usage"] >= state["allotment"] and state["payments"]: + body = b"-1/-1" # session exhausted + state["session_open"] = False + else: + body = f"{state['usage']}/{state['allotment']}".encode() + else: + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + token = self.rfile.read(length).decode() + mac = self.path.split("mac=")[-1] + assert token.startswith("cashu"), f"not a token: {token[:40]!r}" + assert re.fullmatch(r"[0-9a-f:]{17}", mac), f"bad mac: {mac!r}" + state["payments"].append({"token": token, "mac": mac}) + amount_sats = int(token.split("MOCK")[-1]) + if state["session_open"]: + state["allotment"] += (amount_sats // PRICE) * STEP + else: + # Mirrors the server's #541 semantics: a payment with no + # live session opens a fresh one at the paid amount. + state["allotment"] = (amount_sats // PRICE) * STEP + state["usage"] = 0 + state["session_open"] = True + state["start_time"] += 1 + state["usage"] = 0 + ev = json.dumps({"kind": 1022, "tags": [ + ["allotment", str(state["allotment"])], + ["start-time", str(state["start_time"])], + ["metric", "bytes"]], "content": ""}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(ev) + + server = HTTPServer(("127.0.0.1", 0), MockTollGate) + threading.Thread(target=server.serve_forever, daemon=True).start() + port = server.server_address[1] + + def stub_wallet(mint_url, amount_sats, wallet_dir): + assert mint_url == "https://mockmint.example" + return f"cashuB64MOCK{amount_sats}" + + class Args: + gateway = f"127.0.0.1:{port}" # _base() passes host:port through + iface = "lo0" + mac = "02:00:00:00:00:02" + mint = None + renew_below = "8MB" # renewal threshold above the 8MiB/poll burn rate + steps = 4 + wallet_dir = None + dry_run = False + interval = 0.05 + state_dir = tempfile.mkdtemp(prefix="clientd-selftest-") + + failures = [] + try: + d = ClientDaemon(Args(), "stub", stub_wallet) + assert d.ad.metric == "bytes" and d.ad.step_size == STEP + assert d.offer.price == PRICE and d.amount_sats() == 4 + # initial state: no session -> needs payment + assert d.needs_top_up(None), "no-session should require payment" + d.top_up() + time.sleep(0.2) + st = d.status() + assert st["session_active"], "session should be active after payment" + # one /usage poll happened since payment: 8 MiB of the 4 steps burned + assert st["remaining"] == 4 * STEP - 8 * 1024 * 1024, \ + f"unexpected remaining {st['remaining']}" + # burn down until renewal fires + deadline = time.monotonic() + 30 + while len(state["payments"]) < 2 and time.monotonic() < deadline: + st = d.status() + d.observe(st) # run()'s loop feeds every poll through observe() + if d.needs_top_up(st["remaining"]): + d.top_up() + time.sleep(0.05) + if len(state["payments"]) < 2: + failures.append(f"renewal never fired (payments={len(state['payments'])})") + if state["payments"][0]["mac"] != "02:00:00:00:00:02": + failures.append("payment did not carry the client MAC") + # burn past exhaustion so the session ends, then pay again: under + # the server's fresh-session semantics (#541) the re-payment must + # NOT trip the blind cap — the new session's first /usage + # observation resets it (the pre-fix high-water mark broke here). + deadline = time.monotonic() + 30 + while True: + st = d.status() + d.observe(st) + if st["allotment"] is None or time.monotonic() > deadline: + break + try: + d.last_payment = 0.0 # skip the inter-payment throttle + d.top_up() + st = d.status() + assert st["allotment"] is not None and st["allotment"] > 0, \ + f"post-expiry payment did not open a session: {st}" + except BlindPaymentCapExceeded as e: + failures.append(f"post-expiry re-payment tripped the blind cap: {e}") + # display formatting sanity + assert "MB left" in format_remaining("bytes", 5 * 1024 * 1024) + assert "1:30" == human_ms(90_000) + except AssertionError as e: + failures.append(f"assertion: {e}") + except Exception as e: # noqa: BLE001 + failures.append(f"exception: {e!r}") + finally: + server.shutdown() + + if failures: + print("SELFTEST FAIL:") + for f in failures: + print(f" - {f}") + return 1 + print(f"SELFTEST PASS: {len(state['payments'])} payments, " + f"{state['polls']} usage polls, renewal + status verified") + return 0 + + +# --------------------------------------------------------------------------- + +def main() -> int: + ap = argparse.ArgumentParser( + prog="tollgate-clientd", + description="Laptop client for TollGate: shows remaining access and " + "auto-tops-up with ecash (cdk-cli or nutshell).") + ap.add_argument("--gateway", help="TollGate IP (default: default-route gateway)") + ap.add_argument("--iface", help="network interface toward the gateway") + ap.add_argument("--mac", + help="MAC to send with the payment (default: auto-detect). " + "Informational since the router resolves clients " + "from the socket (#548); kept for lab fixtures " + "that key sessions by MAC.") + ap.add_argument("--wallet", choices=["auto", "cdk-cli", "nutshell"], + default="auto", help="wallet to create tokens with") + ap.add_argument("--wallet-dir", + help="cdk-cli wallet dir (-w) or nutshell wallet name " + "(--wallet)") + ap.add_argument("--mint", help="mint URL to pay with (default: first advertised)") + ap.add_argument("--steps", type=int, default=1, + help="steps to buy per top-up (raised to the mint minimum)") + ap.add_argument("--renew-below", + help="renewal threshold, e.g. '20MB' or '30s' " + "(default: 20MB / 30s)") + ap.add_argument("--interval", type=float, default=POLL_INTERVAL, + help="seconds between usage polls") + ap.add_argument("--payment-timeout", type=float, default=PAYMENT_TIMEOUT, + help=f"seconds to wait for a payment POST " + f"(default: {PAYMENT_TIMEOUT:.0f} — routers may " + f"swap synchronously)") + ap.add_argument("--max-blind-payments", type=int, + default=DEFAULT_MAX_BLIND_PAYMENTS, + help="stop (non-zero exit) after this many payments the " + "gateway accepted without /usage ever reflecting " + "them (default: 3)") + ap.add_argument("--state-dir", + help="where unacknowledged payment tokens are preserved " + "(default: ~/.local/state/tollgate-clientd)") + ap.add_argument("--status", action="store_true", + help="print current status once and exit") + ap.add_argument("--json", action="store_true", + help="with --status: emit JSON") + ap.add_argument("--waybar", action="store_true", + help="print waybar module JSON once and exit " + "(for a custom/tollgate status-bar module)") + ap.add_argument("--dry-run", action="store_true", + help="show status but never pay") + ap.add_argument("--list-offers", action="store_true", + help="print the gateway's pricing and exit") + ap.add_argument("--selftest", action="store_true", + help="run against a built-in mock TollGate (no hardware)") + args = ap.parse_args() + + if args.selftest: + return selftest() + + try: + name, sender = resolve_wallet(args.wallet) + d = ClientDaemon(args, name, sender) + except ClientError as e: + print(f"error: {e}", file=sys.stderr) + return 1 + + if args.list_offers: + print_offers(d) + return 0 + if args.waybar: + print_waybar(d) + return 0 + if args.status: + print_status(d, args.json) + return 0 + + try: + d.run() + except KeyboardInterrupt: + print(file=sys.stderr) + return 0 + except (TerminalPaymentError, BlindPaymentCapExceeded) as e: + print(f"error: {e}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/clientd/test_tollgate_clientd.py b/tests/clientd/test_tollgate_clientd.py new file mode 100644 index 000000000..d0b1f491a --- /dev/null +++ b/tests/clientd/test_tollgate_clientd.py @@ -0,0 +1,587 @@ +"""Unit tests for scripts/tollgate-clientd.py. + +Run: python3 -m pytest tests/clientd/ -v + +No hardware, no wallets, no docker: HTTP boundaries are served by in-process +mocks; subprocess boundaries are stubbed. The cloud-lab e2e +(tests/cloud-lab/test_clientd_autotopup.py) covers the real stack. +""" + +import importlib.util +import json +import subprocess +import sys +import threading +import typing +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "tollgate-clientd.py" +spec = importlib.util.spec_from_file_location("tollgate_clientd", SCRIPT) +clientd = importlib.util.module_from_spec(spec) +spec.loader.exec_module(clientd) + +ADV_BYTES = json.dumps({ + "kind": 10021, "pubkey": "ab" * 32, + "tags": [ + ["metric", "bytes"], + ["step_size", "22020096"], + ["price_per_step", "cashu", "1", "sat", "https://mint.example", "3"], + ["price_per_step", "cashu", "2", "sat", "https://mint2.example"], + ], + "content": "", +}) + +ADV_MS = json.dumps({ + "kind": 10021, "pubkey": "ab" * 32, + "tags": [ + ["metric", "milliseconds"], + ["step_size", "60000"], + ["price_per_step", "cashu", "1", "sat", "https://mint.example", "0"], + ], + "content": "", +}) + + +class TestParseAdvertisement: + def test_bytes_advertisement(self): + ad = clientd.parse_advertisement(ADV_BYTES) + assert ad.metric == "bytes" + assert ad.step_size == 22020096 + assert ad.pubkey == "ab" * 32 + assert len(ad.offers) == 2 + assert ad.offers[0].price == 1 + assert ad.offers[0].mint_url == "https://mint.example" + assert ad.offers[0].min_steps == 3 + + def test_offer_without_min_steps_defaults_to_zero(self): + ad = clientd.parse_advertisement(ADV_BYTES) + assert ad.offers[1].min_steps == 0 + + def test_default_offer_selects_mint(self): + ad = clientd.parse_advertisement(ADV_BYTES) + assert ad.default_offer("https://mint2.example/").price == 2 + assert ad.default_offer().mint_url == "https://mint.example" + + def test_unknown_mint_rejected(self): + ad = clientd.parse_advertisement(ADV_BYTES) + with pytest.raises(clientd.ClientError, match="not accepted"): + ad.default_offer("https://elsewhere.example") + + def test_rejects_non_advertisement(self): + with pytest.raises(clientd.ClientError): + clientd.parse_advertisement(json.dumps({"kind": 1, "tags": []})) + with pytest.raises(clientd.ClientError): + clientd.parse_advertisement("not json at all") + + +class TestFormatting: + def test_human_bytes(self): + assert clientd.human_bytes(512) == "512 B" + assert clientd.human_bytes(5 * 1024 * 1024) == "5.0 MB" + assert clientd.human_bytes(3 * 1024**3) == "3.0 GB" + + def test_human_ms(self): + assert clientd.human_ms(90_000) == "1:30" + assert clientd.human_ms(3_600_000) == "1:00:00" + + def test_format_remaining(self): + assert clientd.format_remaining("bytes", 5 * 1024 * 1024) == "5.0 MB left" + assert clientd.format_remaining("milliseconds", 90_000) == "1:30 left" + + def test_parse_renew_below(self): + assert clientd.parse_renew_below("20MB", "bytes") == 20 * 1024**2 + assert clientd.parse_renew_below("1.5GB", "bytes") == int(1.5 * 1024**3) + assert clientd.parse_renew_below("1000000", "bytes") == 1_000_000 + assert clientd.parse_renew_below("30s", "milliseconds") == 30_000 + assert clientd.parse_renew_below("2m", "milliseconds") == 120_000 + assert clientd.parse_renew_below("5000", "milliseconds") == 5_000 + with pytest.raises(clientd.ClientError): + clientd.parse_renew_below("20quux", "bytes") + + +class TestExtractToken: + def test_takes_cashu_line_before_balance_line(self): + out = "cashuB64TOKENHERE\nBalance: 21\n" + assert clientd._extract_token(out) == "cashuB64TOKENHERE" + + def test_v3_prefix(self): + assert clientd._extract_token("\ncashuAeyJ0b2tlbiI\n") == "cashuAeyJ0b2tlbiI" + + def test_no_token_raises(self): + with pytest.raises(clientd.ClientError): + clientd._extract_token("Balance: 0\nnothing to send") + + +class MockTollGate(BaseHTTPRequestHandler): + """Configurable mock: usage responses and payment outcomes.""" + + state: typing.ClassVar[dict] = {"usage_body": "-1/-1", "post_status": 200, + "post_body": "{}"} + + def log_message(self, *a): + pass + + def do_GET(self): + if self.path == "/": + body = ADV_MS.encode() + elif self.path == "/usage": + body = self.state["usage_body"].encode() + elif self.path == "/v1/keysets": + body = json.dumps({"keysets": [ + {"id": "0011223344556677" + "88" * 25}]}).encode() + else: + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length).decode() + self.state["last_post_body"] = body + self.state["last_post_path"] = self.path + self.send_response(self.state["post_status"]) + self.end_headers() + self.wfile.write(self.state["post_body"].encode()) + + +@pytest.fixture() +def mock_gate(): + server = HTTPServer(("127.0.0.1", 0), MockTollGate) + threading.Thread(target=server.serve_forever, daemon=True).start() + yield f"127.0.0.1:{server.server_address[1]}", MockTollGate.state + server.shutdown() + MockTollGate.state.update({"usage_body": "-1/-1", "post_status": 200, + "post_body": "{}"}) + + +class TestWireProtocol: + def test_get_usage_parses_pair(self, mock_gate): + gw, state = mock_gate + state["usage_body"] = "123/456" + assert clientd.get_usage(gw) == (123, 456) + + def test_get_usage_no_session(self, mock_gate): + gw, _ = mock_gate + assert clientd.get_usage(gw) is None + + def test_get_usage_garbage_raises(self, mock_gate): + gw, state = mock_gate + state["usage_body"] = "what is this" + with pytest.raises(clientd.ClientError): + clientd.get_usage(gw) + + def test_pay_success_and_mac_passthrough(self, mock_gate): + gw, state = mock_gate + state["post_status"], state["post_body"] = 200, json.dumps( + {"kind": 1022, "tags": [["allotment", "60000"]]}) + ev = clientd.pay(gw, "02:00:00:00:00:20", "cashuB64X") + assert ev["kind"] == 1022 + assert state["last_post_path"] == "/?mac=02:00:00:00:00:20" + assert state["last_post_body"] == "cashuB64X" + + def test_pay_notice_surfaces_message(self, mock_gate): + gw, state = mock_gate + state["post_status"] = 400 + state["post_body"] = json.dumps( + {"kind": 21023, "content": "payment below minimum"}) + with pytest.raises(clientd.ClientError, match="payment below minimum"): + clientd.pay(gw, "02:00:00:00:00:20", "cashuB64X") + + def test_pay_non_json_reply_raises(self, mock_gate): + gw, state = mock_gate + state["post_status"], state["post_body"] = 502, "bad gateway" + with pytest.raises(clientd.ClientError, match="not JSON"): + clientd.pay(gw, "02:00:00:00:00:20", "cashuB64X") + + +class TestKeysetExpansion: + def _make_v3_token(self, keyset_id, amount=1): + import base64 + data = {"token": [{"mint": "https://mint.example", + "proofs": [{"id": keyset_id, "amount": amount, + "secret": "s", "C": "c"}]}]} + return "cashuA" + base64.urlsafe_b64encode( + json.dumps(data).encode()).decode().rstrip("=") + + def test_short_ids_expanded_from_mint(self, mock_gate): + import base64 + gw, _ = mock_gate + token = self._make_v3_token("0011223344556677") + out = clientd._expand_keyset_ids(token, f"http://{gw}") + payload = out[6:] + payload += "=" * (4 - len(payload) % 4) + decoded = json.loads(base64.urlsafe_b64decode(payload)) + assert decoded["token"][0]["proofs"][0]["id"] == \ + "0011223344556677" + "88" * 25 + + def test_unknown_short_id_left_alone(self, mock_gate): + gw, _ = mock_gate + token = self._make_v3_token("ffffffffffffffff") + assert clientd._expand_keyset_ids(token, f"http://{gw}") == token + + def test_v4_tokens_untouched(self, mock_gate): + gw, _ = mock_gate + assert clientd._expand_keyset_ids("cashuB64XYZ", f"http://{gw}") == "cashuB64XYZ" + + +class TestCdkCliAdapter: + def _patch_run(self, monkeypatch, responses): + calls = [] + + def fake_run(cmd, input=None, capture_output=False, text=False, + check=False): + calls.append({"cmd": cmd, "input": input}) + if cmd[1:3] == ["send", "--help"]: + r = responses.get("help") + elif "--amount" in cmd: + r = responses.get("amount") + else: + r = responses.get("stdin") + return subprocess.CompletedProcess(cmd, r.get("rc", 0), + r.get("stdout", ""), + r.get("stderr", "")) + + monkeypatch.setattr(clientd.subprocess, "run", fake_run) + return calls + + def test_modern_cdk_cli_uses_amount_flag_and_v3(self, monkeypatch): + clientd._CDK_AMOUNT_FLAG = None + calls = self._patch_run(monkeypatch, { + "help": {"stdout": "--amount AMOUNT"}, + "amount": {"stdout": "cashuB64X\nBalance: 9\n"}, + }) + token = clientd.wallet_send_cdk_cli("https://mint.example", 21, None) + assert token == "cashuB64X" + send_call = [c for c in calls if "send" in c["cmd"]][-1] + assert "--v3" in send_call["cmd"] and "--amount" in send_call["cmd"] + assert "21" in send_call["cmd"] and send_call["input"] is None + + def test_legacy_cdk_cli_uses_stdin(self, monkeypatch): + clientd._CDK_AMOUNT_FLAG = None + calls = self._patch_run(monkeypatch, { + "help": {"stdout": "no amount flag here"}, + "stdin": {"stdout": "cashuB64Y\n"}, + }) + token = clientd.wallet_send_cdk_cli("https://mint.example", 5, "/tmp/w") + assert token == "cashuB64Y" + send_call = [c for c in calls if c["input"] is not None][-1] + assert send_call["input"] == "5\n" + assert "/tmp/w" in send_call["cmd"] + + def test_send_failure_surfaces_stderr(self, monkeypatch): + clientd._CDK_AMOUNT_FLAG = None + self._patch_run(monkeypatch, { + "help": {"stdout": "--amount"}, + "amount": {"rc": 1, "stderr": "insufficient funds"}, + }) + with pytest.raises(clientd.ClientError, match="insufficient funds"): + clientd.wallet_send_cdk_cli("https://mint.example", 21, None) + + +class Args: + gateway = None # filled per test + iface = "lo0" + mac = "02:00:00:00:00:02" + mint = None + renew_below = None + steps = 1 + wallet_dir = None + dry_run = False + interval = 0.05 + state_dir = None # filled per test (tmp_path) — never write to $HOME + payment_timeout = 30.0 + max_blind_payments = 3 + + +class TestDaemonAgainstMock: + def _daemon(self, gw, state, renew_below=None, steps=1, tmp_path=None): + state["usage_body"] = "-1/-1" + args = Args() + args.gateway = gw + args.renew_below = renew_below + args.steps = steps + args.state_dir = str(tmp_path) if tmp_path else None + payments = [] + + def wallet(mint_url, amount_sats, wallet_dir): + payments.append(amount_sats) + state["usage_body"] = f"0/{amount_sats * 60000}" + return f"cashuB64MOCK{amount_sats}" + + state["post_status"], state["post_body"] = 200, json.dumps( + {"kind": 1022, "tags": [["allotment", "60000"]]}) + if args.state_dir is None: + pytest.fail("daemon tests must pass tmp_path so pending tokens " + "never land in $HOME") + d = clientd.ClientDaemon(args, "stub", wallet) + return d, payments + + def test_initial_payment_when_no_session(self, mock_gate, tmp_path): + gw, state = mock_gate + d, payments = self._daemon(gw, state, tmp_path=tmp_path) + assert d.needs_top_up(None) + d.top_up() + assert payments == [1] + st = d.status() + assert st["session_active"] and st["allotment"] == 60000 + + def test_steps_raised_to_mint_minimum(self, mock_gate, tmp_path): + gw, state = mock_gate + d, _ = self._daemon(gw, state, steps=1, tmp_path=tmp_path) + # ADV_MS mint has min_steps 0; build a daemon against ADV_BYTES-style + # min via direct offer check instead + assert d.steps >= d.offer.min_steps + + def test_renewal_when_below_threshold(self, mock_gate, tmp_path): + gw, state = mock_gate + d, payments = self._daemon(gw, state, renew_below="50s", tmp_path=tmp_path) + d.top_up() + state["usage_body"] = "20000/60000" # 40s left <= 50s threshold + assert d.needs_top_up(d.status()["remaining"]) + d.last_payment = 0.0 # skip the 5s inter-payment throttle + d.top_up() + assert payments == [1, 1] + + def test_payment_throttle_blocks_rapid_duplicates(self, mock_gate, tmp_path): + gw, state = mock_gate + d, payments = self._daemon(gw, state, tmp_path=tmp_path) + assert d.top_up().startswith("paid") + assert d.top_up() == "throttled" # within PAYMENT_THROTTLE seconds + assert payments == [1] + + def test_no_renewal_when_above_threshold(self, mock_gate, tmp_path): + gw, state = mock_gate + d, payments = self._daemon(gw, state, renew_below="50s", tmp_path=tmp_path) + d.top_up() + state["usage_body"] = "5000/60000" # 55s left > 50s + assert not d.needs_top_up(d.status()["remaining"]) + assert len(payments) == 1 + + def test_status_shape(self, mock_gate, tmp_path): + gw, state = mock_gate + d, _ = self._daemon(gw, state, tmp_path=tmp_path) + d.top_up() + st = d.status() + for key in ("gateway", "mac", "metric", "mint", "wallet", + "session_active", "usage", "allotment", "remaining"): + assert key in st + assert st["metric"] == "milliseconds" + assert st["mint"] == "https://mint.example" + + +class TestSelftestEntry: + def test_selftest_passes(self): + rc = subprocess.run([sys.executable, str(SCRIPT), "--selftest"], + capture_output=True, text=True, timeout=120, + check=False) + assert rc.returncode == 0, rc.stdout + rc.stderr + assert "SELFTEST PASS" in rc.stdout + + +class TestMoneyPathGuards: + """The two #422/#423 blockers from the review: no blind re-pay loop, + and no token minted-then-dropped on a failed POST.""" + + def test_terminal_code_raises_terminal_error(self, mock_gate): + gw, state = mock_gate + state["post_status"] = 400 + state["post_body"] = json.dumps({ + "kind": 21023, "content": "This e-cash note is 1 sat but the " + "mint charges a 1 sat swap fee…", + "tags": [["code", "payment-error-below-swap-fee"]]}) + with pytest.raises(clientd.TerminalPaymentError) as ei: + clientd.pay(gw, "02:00:00:00:00:02", "cashuB64MOCK1") + assert ei.value.code == "payment-error-below-swap-fee" + + def test_retryable_rejection_stays_client_error(self, mock_gate): + gw, state = mock_gate + state["post_status"] = 400 + state["post_body"] = json.dumps({ + "kind": 21023, "content": "Mint is temporarily unavailable", + "tags": [["code", "payment-error-mint-unreachable"]]}) + with pytest.raises(clientd.ClientError) as ei: + clientd.pay(gw, "02:00:00:00:00:02", "cashuB64MOCK1") + assert not isinstance(ei.value, clientd.TerminalPaymentError) + + def test_terminal_rejection_stops_daemon(self, mock_gate, tmp_path): + gw, state = mock_gate + d, payments = self._blind_daemon(gw, state, tmp_path) + state["post_status"] = 400 + state["post_body"] = json.dumps({ + "kind": 21023, "content": "below swap fee", + "tags": [["code", "payment-error-below-swap-fee"]]}) + with pytest.raises(clientd.TerminalPaymentError): + d.run() + assert payments == [1], "terminal rejection must not re-mint" + + def test_blind_payments_capped_and_exits(self, mock_gate, tmp_path, + monkeypatch): + gw, state = mock_gate + monkeypatch.setattr(clientd, "PAYMENT_THROTTLE", 0.05) + d, payments = self._blind_daemon(gw, state, tmp_path) + # /usage never leaves -1/-1 and the router keeps saying 1022: the + # #422 runaway — now bounded at --max-blind-payments. + with pytest.raises(clientd.BlindPaymentCapExceeded): + d.run() + assert payments == [1, 1, 1], "exactly max_blind_payments minted" + + def test_blind_counter_resets_on_observed_usage(self, mock_gate, + tmp_path, monkeypatch): + gw, state = mock_gate + monkeypatch.setattr(clientd, "PAYMENT_THROTTLE", 0.05) + d, payments = self._blind_daemon(gw, state, tmp_path, + usage_grows=True) + import threading + import time as _t + t = threading.Thread(target=d.run, daemon=True) + t.start() + deadline = _t.time() + 5 + while len(payments) < 4 and _t.time() < deadline: + _t.sleep(0.02) + assert len(payments) >= 4, ( + f"healthy renewals must continue past the cap (got {len(payments)}) " + f"— without the /usage reset the cap would have stopped at 3") + + def test_pending_token_reused_on_retry(self, mock_gate, tmp_path): + gw, state = mock_gate + d, payments = self._blind_daemon(gw, state, tmp_path) + # First attempt: the POST dies mid-flight (connection error class). + state["post_status"] = 200 + state["post_body"] = "not json at all" + with pytest.raises(clientd.ClientError): + d.top_up() + assert payments == [1] + assert d._load_pending_token() == "cashuB64MOCK1", ( + "token must be on disk (0600) before the POST") + # Second attempt: the wallet is NOT asked for a new token. + state["post_status"], state["post_body"] = 200, json.dumps( + {"kind": 1022, "tags": [["allotment", "60000"]]}) + d.last_payment = 0.0 # skip the inter-payment throttle, like test_renewal + msg = d.top_up() + assert payments == [1], "retry must reuse the pending token" + assert "reused pending" in msg + assert d._load_pending_token() is None, "cleared after success" + + def test_terminal_spent_clears_pending(self, mock_gate, tmp_path): + gw, state = mock_gate + d, payments = self._blind_daemon(gw, state, tmp_path) + state["post_status"] = 400 + state["post_body"] = json.dumps({ + "kind": 21023, "content": "Token has already been spent", + "tags": [["code", "payment-error-token-spent"]]}) + with pytest.raises(clientd.TerminalPaymentError): + d.top_up() + assert d._load_pending_token() is None, ( + "a spent token must not be retried or advertised as recoverable") + + def test_terminal_keyset_expired_stops_daemon(self, mock_gate, tmp_path): + # S5's rotation scenario class: the router retired the keyset the + # wallet's notes were minted under — retrying can never succeed. + gw, state = mock_gate + d, payments = self._blind_daemon(gw, state, tmp_path) + state["post_status"] = 400 + state["post_body"] = json.dumps({ + "kind": 21023, + "content": "The note cannot be recovered by retrying; " + "obtain a new token", + "tags": [["code", "payment-error-keyset-expired"]]}) + with pytest.raises(clientd.TerminalPaymentError) as exc: + d.top_up() + assert exc.value.code == "payment-error-keyset-expired" + + def test_outcome_unknown_preserves_token_but_never_reuses( + self, mock_gate, tmp_path): + gw, state = mock_gate + d, payments = self._blind_daemon(gw, state, tmp_path) + state["post_status"] = 400 + state["post_body"] = json.dumps({ + "kind": 21023, "content": "Do not send this e-cash note again", + "tags": [["code", "payment-outcome-unknown"]]}) + with pytest.raises(clientd.TerminalPaymentError) as exc: + d.top_up() + assert exc.value.code == "payment-outcome-unknown" + assert d._load_pending_token() is None, ( + "an outcome-unknown token must never be re-POSTed") + preserved = list(tmp_path.rglob("*.token.outcome-unknown")) + assert preserved, ("the token must survive on disk for operator " + "reconciliation, under a non-reuse name") + + def test_fee_underbuy_warning_surfaces(self, mock_gate, tmp_path): + # A fee-charging mint credits fewer steps than paid for (S5 math: + # floor((amount - fee) / price)); the message must say so. + gw, state = mock_gate + args = Args() + args.gateway = gw + args.steps = 2 + args.state_dir = str(tmp_path) + payments = [] + + def wallet(mint_url, amount_sats, wallet_dir): + payments.append(amount_sats) + return f"cashuB64MOCK{amount_sats}" + + state["post_status"], state["post_body"] = 200, json.dumps( + {"kind": 1022, "tags": [["allotment", "60000"], + ["start-time", "42"]]}) + d = clientd.ClientDaemon(args, "stub", wallet) + msg = d.top_up() + assert "warning" in msg and "1 of 2" in msg, msg + + def test_post_expiry_renewal_does_not_trip_blind_cap( + self, mock_gate, tmp_path, monkeypatch): + # The #541 regression: a payment after the session expired opens a + # FRESH session whose allotment never exceeds the old high-water + # mark — the pre-fix counter never reset and the daemon stopped + # after 3 healthy payments. The reviewer's empirical repro. + gw, state = mock_gate + monkeypatch.setattr(clientd, "PAYMENT_THROTTLE", 0.01) + d, payments = self._blind_daemon(gw, state, tmp_path) + STEP = 22020096 + for i in range(1, 6): # five post-expiry cycles, cap is 3 + d.last_payment = 0.0 + state["post_body"] = json.dumps({"kind": 1022, "tags": [ + ["allotment", str(STEP)], ["start-time", str(i)]]}) + d.top_up() + # run() would observe the fresh session on the next poll + d.observe({"allotment": STEP, "remaining": STEP}) + # session expires before the next renewal + d.observe({"allotment": None, "remaining": None}) + assert len(payments) == 5, ( + f"post-expiry renewals must survive past the cap (got " + f"{len(payments)}; the pre-fix high-water reset stopped at 3)") + + def test_payment_timeout_defaults_wide(self, mock_gate, tmp_path): + gw, state = mock_gate + d, _ = self._blind_daemon(gw, state, tmp_path) + assert d.payment_timeout >= 30.0, ( + "payment POST timeout must default to >= 30 s (#423: routers " + "swap synchronously)") + + def _blind_daemon(self, gw, state, tmp_path, usage_grows=False): + """Daemon whose gateway accepts payments; /usage maps nothing + unless usage_grows (then every poll credits the session).""" + state["usage_body"] = "-1/-1" + args = Args() + args.gateway = gw + args.interval = 0.02 + args.state_dir = str(tmp_path) + payments = [] + + def wallet(mint_url, amount_sats, wallet_dir): + payments.append(amount_sats) + if usage_grows: + # credit the session AND burn most of it, so the daemon + # renews every cycle while /usage keeps reflecting credit + n = len(payments) + state["usage_body"] = f"{n * 58000}/{n * 60000}" + return f"cashuB64MOCK{amount_sats}" + + state["post_status"], state["post_body"] = 200, json.dumps( + {"kind": 1022, "tags": [["allotment", "60000"]]}) + return clientd.ClientDaemon(args, "stub", wallet), payments diff --git a/tests/cloud-lab/Dockerfile.client-ns b/tests/cloud-lab/Dockerfile.client-ns new file mode 100644 index 000000000..51db443c5 --- /dev/null +++ b/tests/cloud-lab/Dockerfile.client-ns @@ -0,0 +1,12 @@ +# Dockerfile.client-ns — client image + nutshell (cashu CLI) for the +# tollgate-clientd scenario battery (S3). +# +# The base image is built by the `client` service, so build in order: +# docker compose build client && docker compose build client-ns +# (FROM resolves the project-built cloud-lab-client image.) +# +# marshmallow is pinned <4: current environs (a cashu dependency) reads +# marshmallow.__version_info__, removed in marshmallow 4.x — a fresh +# `pip install cashu` crashes without the pin. +FROM cloud-lab-client:latest +RUN pip install --no-cache-dir cashu "marshmallow<4" diff --git a/tests/cloud-lab/README.md b/tests/cloud-lab/README.md index 94ad25cd6..c811b3ddd 100644 --- a/tests/cloud-lab/README.md +++ b/tests/cloud-lab/README.md @@ -98,6 +98,8 @@ docker compose down | `run-crash-injection.sh` | #497 acceptance at the TollGate layer: a killer proxy swallows every `/v1/swap` response once the mint has signed (the wallet's retry ladder included), the tollgate is SIGKILLed mid-wait, and on restart the boot-time intent resume must recover the value — then a fresh payment must still succeed. Requires a tollgate built with the gonuts swap-intent machinery | | `test_two_router_autopay.py` | Two-router chain: reseller processes payment without crashing, both TollGates stay alive | | `conformance/` | Fast-subset conformance lane (#503) via `./conformance/run-conformance.sh`: duplicate/timeout/kill-at-boundary/alias scenarios from the co-owned PRTA matrix, driven through the PRTA fault proxy; emits a per-invariant verdict table. Skips cleanly without docker or a PRTA checkout | +| `test_clientd_autotopup.py` | `scripts/tollgate-clientd.py` laptop client end-to-end: advertisement parsing, `--status`/`--waybar` output, dry-run never pays, daemon buys a step and auto-renews at the threshold (run standalone or after `docker compose restart upstream` — sessions are in-memory per client MAC) | +| `run-clientd-scenarios.sh` | Scenario battery for the laptop client (profile `clientd`): S2 bytes-metric lifecycle (renewal before exhaustion, gate close, re-payment), S3 real nutshell wallet paying live, S4 mint outage mid-session with backoff + recovery, S5 keyset rotation + swap fees (auto-skipped unless the rotation mints are defined). Run: `./run-clientd-scenarios.sh` or `SCENARIOS="S2 S3" ./run-clientd-scenarios.sh` — results in `/tmp/tollgate-clientd-scenarios/STATUS` | ## What This Tests vs What It Doesn't diff --git a/tests/cloud-lab/configs/upstream-bytes-config.json b/tests/cloud-lab/configs/upstream-bytes-config.json new file mode 100644 index 000000000..9b4e5fde4 --- /dev/null +++ b/tests/cloud-lab/configs/upstream-bytes-config.json @@ -0,0 +1,51 @@ +{ + "config_version": "v0.0.8", + "log_level": "debug", + "metric": "bytes", + "step_size": 5242880, + "margin": 0.1, + "show_setup": false, + "reseller_mode": false, + "accepted_mints": [ + { + "url": "http://mint:8085", + "min_balance": 0, + "balance_tolerance_percent": 0, + "payout_interval_seconds": 999999, + "min_payout_amount": 999999, + "price_per_step": 1, + "price_unit": "sats", + "purchase_min_steps": 0 + } + ], + "profit_share": [ + { "factor": 1.0, "identity": "owner" } + ], + "upstream_detector": { + "probe_timeout": 5000000000, + "probe_retry_count": 1, + "probe_retry_delay": 1000000000, + "require_valid_signature": false, + "ignore_interfaces": ["lo"], + "only_interfaces": [], + "discovery_timeout": 10000000000 + }, + "upstream_session_manager": { + "max_price_per_millisecond": 0.01, + "max_price_per_byte": 0.001, + "trust": { + "default_policy": "trust_all", + "allowlist": [], + "blocklist": [] + }, + "sessions": { + "preferred_session_increments_milliseconds": 60000, + "preferred_session_increments_bytes": 5242880, + "millisecond_renewal_offset": 10000, + "bytes_renewal_offset": 1048576 + }, + "usage_tracking": { + "data_monitoring_interval": 500000000 + } + } +} diff --git a/tests/cloud-lab/docker-compose.yml b/tests/cloud-lab/docker-compose.yml index e99e73d25..8aa485fdf 100644 --- a/tests/cloud-lab/docker-compose.yml +++ b/tests/cloud-lab/docker-compose.yml @@ -224,6 +224,8 @@ services: condition: service_healthy volumes: - .:/tests + - ../../scripts:/clientd:ro + - clientd-wallets:/w working_dir: /tests networks: tollgate-lab: @@ -231,10 +233,63 @@ services: entrypoint: ["pytest"] command: ["-sv", "--tb=short"] + # --- clientd scenario battery (run-clientd-scenarios.sh) -- profile "clientd" + + upstream-bytes: + build: + context: ../.. + dockerfile: tests/cloud-lab/Dockerfile.tollgate + container_name: tg-upstream-bytes + profiles: ["clientd"] + depends_on: + mint: + condition: service_healthy + environment: + - TOLLGATE_TEST_CONFIG_DIR=/etc/tollgate + volumes: + - ./configs/upstream-bytes-config.json:/etc/tollgate/config.json + - ./configs/install.json:/etc/tollgate/install.json + - ./configs/dhcp.leases:/tmp/dhcp.leases + - ./fake-ndsctl-filecounters.sh:/usr/local/bin/ndsctl:ro + - clientd-counters:/ndsctl-data + networks: + tollgate-lab: + ipv4_address: 172.28.0.12 + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:2121/"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + # Same static IP as `client` (172.28.0.20): the scenario battery never + # runs both concurrently, and /usage must resolve the daemon's MAC via + # dhcp.leases — keep them in sync if either IP changes. + client-ns: + build: + context: . + dockerfile: Dockerfile.client-ns + container_name: tg-client-ns + profiles: ["clientd"] + depends_on: + mint: + condition: service_healthy + environment: + - CASHU_DIR=/w + volumes: + - clientd-wallets:/w + - ../../scripts:/clientd:ro + networks: + tollgate-lab: + ipv4_address: 172.28.0.20 + entrypoint: ["pytest"] + volumes: mint-data: mint-fees-data: mint-rotate-data: + clientd-wallets: + clientd-counters: networks: tollgate-lab: diff --git a/tests/cloud-lab/fake-ndsctl-filecounters.sh b/tests/cloud-lab/fake-ndsctl-filecounters.sh new file mode 100755 index 000000000..50a8a4057 --- /dev/null +++ b/tests/cloud-lab/fake-ndsctl-filecounters.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# fake-ndsctl-filecounters — fake-ndsctl variant for the clientd scenario +# battery: same contract as fake-ndsctl.sh, but `json` reads live counters +# from /ndsctl-data/counters.env (DOWNLOADED_KB / UPLOADED_KB) so a scenario +# can advance data usage without restarting the container. +# +# The runner writes the file via: +# docker exec tg-upstream-bytes sh -c \ +# "printf 'DOWNLOADED_KB=%s\nUPLOADED_KB=0\n' 1024 > /ndsctl-data/counters.env" +LOG_FILE="${NDSCTL_LOG:-/tmp/ndsctl.log}" +mac="$2" +timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +case "$1" in + auth) + echo "${timestamp} AUTH ${mac}" >> "$LOG_FILE" + echo "Auth: ${mac} - Granted" + exit 0 + ;; + deauth) + echo "${timestamp} DEAUTH ${mac}" >> "$LOG_FILE" + echo "Auth: ${mac} - Removed" + exit 0 + ;; + json) + if [ -f /ndsctl-data/counters.env ]; then + . /ndsctl-data/counters.env + fi + downloaded="${DOWNLOADED_KB:-1024}" + uploaded="${UPLOADED_KB:-512}" + echo "{\"id\":1,\"ip\":\"172.28.0.20\",\"mac\":\"${mac}\",\"added\":$(date +%s),\"active\":$(date +%s),\"duration\":60,\"token\":\"fake-token\",\"state\":\"Authenticated\",\"downloaded\":${downloaded},\"avg_down_speed\":0,\"uploaded\":${uploaded},\"avg_up_speed\":0}" + exit 0 + ;; + *) + echo "OK" + exit 0 + ;; +esac diff --git a/tests/cloud-lab/run-clientd-scenarios.sh b/tests/cloud-lab/run-clientd-scenarios.sh new file mode 100755 index 000000000..b806c8512 --- /dev/null +++ b/tests/cloud-lab/run-clientd-scenarios.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# run-clientd-scenarios.sh — tollgate-clientd scenario battery for the +# cloud lab (profile "clientd"). +# +# S2 bytes-metric lifecycle: buy a data step, meter advances via the +# file-driven fake ndsctl, renewal before exhaustion, gate close on +# exhaustion, automatic re-payment. +# S3 nutshell wallet live: fund via cdk-cli, receive into a REAL cashu +# wallet, clientd pays with `--wallet nutshell`. +# S4 mint outage mid-session: renewal fails with logged backoff, mint +# restarts, clientd recovers. (ms sessions in this lab expire lazily, +# so expiry is a NOTE, not an assertion — recovery with a fresh +# session is the pass criterion.) +# S5 keyset rotation + swap fees — requires the upstream-rotate +# service S5 drives plus the mint-rotate/mint-fees services +# (keyset-rotation branch); SKIPPED with a message when the +# current compose does not define them. +# +# Usage (from tests/cloud-lab/): +# ./run-clientd-scenarios.sh # S2 S3 S4 +# SCENARIOS="S2" ./run-clientd-scenarios.sh # subset +# OUT=dir — results dir (default /tmp/tollgate-clientd-scenarios) +# +# Fee note: on mints charging input fees, the router credits +# floor((amount - fee)/price) steps — buy more per top-up than you need. +set -u +exec 9>/tmp/tollgate-clientd-scenarios.lock +flock -n 9 || { echo "another battery instance is already running"; exit 1; } +cd "$(dirname "$0")" || exit 1 + +OUT="${OUT:-/tmp/tollgate-clientd-scenarios}" +mkdir -p "$OUT" +STATUS="$OUT/STATUS" +: > "$STATUS" + +MAC=02:00:00:00:00:20 # mapped to the client IPs (172.28.0.20) in dhcp.leases + +log() { echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$OUT/RUNNER.log"; } +mark() { echo "$1" >> "$STATUS"; log "$1"; } + +DC() { docker compose "$@"; } + +usage_of() { docker exec "$1" curl -s --max-time 5 "http://$2:2121/usage" 2>/dev/null; } + +wait_usage() { # container gateway matcher timeout + local end=$(( $(date +%s) + $4 )) + while [ "$(date +%s)" -lt "$end" ]; do + if usage_of "$1" "$2" | grep -qE "$3"; then return 0; fi + sleep 2 + done + return 1 +} + +start_daemon() { # name gateway service extra-args... + local name=$1 gw=$2 svc=$3; shift 3 + if ! DC run -d --name "$name" --entrypoint python3 "$svc" \ + /clientd/tollgate-clientd.py --gateway "$gw" --mac "$MAC" \ + --interval 1 "$@" > "$OUT/$name-start.log" 2>&1; then + log "start_daemon $name FAILED — see $OUT/$name-start.log" + fi +} +daemon_log() { mkdir -p "$OUT/$1" && docker logs "$1" >> "$OUT/$1/clientd.log" 2>&1; } +scenario_dir() { mkdir -p "$OUT/$1"; } + +counters() { # downloaded_KB + docker exec tg-upstream-bytes sh -c \ + "printf 'DOWNLOADED_KB=%s\nUPLOADED_KB=0\n' '$1' > /ndsctl-data/counters.env" \ + 2>/dev/null +} + +ensure_images() { + log "building images (client, then client-ns layered on it)" + DC build client >/dev/null 2>&1 || { mark "FAIL images-client"; return 1; } + DC build client-ns >/dev/null 2>&1 || { mark "FAIL images-client-ns"; return 1; } +} + +# ---------------------------------------------------------------- S2 bytes +S2() { + scenario_dir S2 + DC --profile clientd up -d upstream-bytes >/dev/null 2>&1 + DC restart upstream-bytes >/dev/null 2>&1 + counters 0 + sleep 5 + DC run --rm --entrypoint sh client -c 'cdk-cli -w /w/s2 mint http://mint:8085 50 >/dev/null 2>&1' \ + > "$OUT/S2/fund.log" 2>&1 + start_daemon S2-clientd upstream-bytes client --wallet cdk-cli --wallet-dir /w/s2 --steps 1 --renew-below 2MB + sleep 8 + if ! wait_usage S2-clientd upstream-bytes "[0-9]+/5242880" 60; then + mark "FAIL S2-initial-payment"; daemon_log S2-clientd + docker rm -f S2-clientd >/dev/null 2>&1; return 1 + fi + mark "PASS S2-initial-payment" + # advance the meter ~1MiB/3s; renewal must fire before exhaustion + local renewed=0 cycles=0 + while [ $cycles -lt 60 ]; do + cycles=$((cycles+1)) + counters $(( cycles * 1024 )) + daemon_log S2-clientd + if docker logs S2-clientd 2>&1 | grep -q "allotment now 10485760"; then renewed=1; break; fi + sleep 3 + done + if [ $renewed = 1 ]; then mark "PASS S2-renewal-before-exhaustion"; else mark "FAIL S2-renewal-before-exhaustion"; fi + # force exhaustion: expect DEAUTH + re-payment + counters 30720 + sleep 10 + docker exec tg-upstream-bytes cat /tmp/ndsctl.log > "$OUT/S2/ndsctl.log" 2>/dev/null + if grep -q "DEAUTH $MAC" "$OUT/S2/ndsctl.log"; then mark "PASS S2-gate-closed-on-exhaustion"; else mark "FAIL S2-gate-closed-on-exhaustion"; fi + if wait_usage S2-clientd upstream-bytes "[0-9]+/[0-9]+" 60; then mark "PASS S2-repaid-after-close"; else mark "FAIL S2-repaid-after-close"; fi + daemon_log S2-clientd + docker rm -f S2-clientd >/dev/null 2>&1 + DC stop upstream-bytes >/dev/null 2>&1 +} + +# ---------------------------------------------------------------- S3 nutshell +S3() { + scenario_dir S3 + DC up -d mint upstream >/dev/null 2>&1 + DC restart upstream >/dev/null 2>&1 + sleep 5 + # fund via cdk-cli, then hand the tokens to a REAL nutshell wallet + # (wallet lives in the named volume via CASHU_DIR=/w — POSIX sh only) + if ! DC run --rm --entrypoint sh client-ns -c ' + set -e + rm -rf /w/ns /w/s3 + cdk-cli -w /w/s3 mint http://mint:8085 50 >/dev/null + TOKEN=$(cdk-cli -w /w/s3 send --mint-url http://mint:8085 --v3 --amount 40 | grep "^cashuA" | tail -1) + echo "token: $(printf %s "$TOKEN" | cut -c1-40)..." >&2 + cashu --wallet ns --yes receive "$TOKEN" + cashu --wallet ns balance + ' > "$OUT/S3/fund.log" 2>&1; then + mark "FAIL S3-nutshell-receive"; cat "$OUT/S3/fund.log"; return 1 + fi + mark "PASS S3-nutshell-received-token" + start_daemon S3-clientd upstream client-ns --wallet nutshell --wallet-dir ns --steps 1 --renew-below 30s + sleep 10 + if wait_usage S3-clientd upstream "[0-9]+/60000" 90; then + mark "PASS S3-nutshell-payment-accepted" + else + mark "FAIL S3-nutshell-payment-accepted" + fi + daemon_log S3-clientd + docker exec S3-clientd cashu --wallet ns balance > "$OUT/S3/balance-after.log" 2>&1 + docker rm -f S3-clientd >/dev/null 2>&1 + DC stop upstream >/dev/null 2>&1 +} + +# ---------------------------------------------------------------- S4 outage +S4() { + scenario_dir S4 + DC up -d mint upstream >/dev/null 2>&1 + DC restart upstream >/dev/null 2>&1 + sleep 5 + DC run --rm --entrypoint sh client -c 'cdk-cli -w /w/s4 mint http://mint:8085 50 >/dev/null 2>&1' \ + > "$OUT/S4/fund.log" 2>&1 + start_daemon S4-clientd upstream client --wallet cdk-cli --wallet-dir /w/s4 --steps 1 --renew-below 45s + wait_usage S4-clientd upstream "[0-9]+/60000" 60 || { mark "FAIL S4-setup"; daemon_log S4-clientd; docker rm -f S4-clientd >/dev/null 2>&1; return 1; } + mark "PASS S4-initial-payment" + sleep 5 + docker stop tg-mint >/dev/null 2>&1 + log "S4: mint stopped at $(date -u +%H:%M:%S)" + local failed=0 + for _ in $(seq 1 40); do + sleep 3 + if docker logs S4-clientd 2>&1 | grep -q "top-up failed"; then failed=1; break; fi + done + [ $failed = 1 ] && mark "PASS S4-failure-logged" || mark "FAIL S4-failure-logged" + # ms sessions expire lazily in this lab — record whether /usage flips, do + # not assert it; the hard criterion is recovery after restart below. + if wait_usage S4-clientd upstream "-1/-1" 90; then + log "NOTE S4: /usage flipped to -1/-1 (session expired)" + else + log "NOTE S4: /usage did not flip within 90s (lazy ms-expiry — known lab behavior)" + fi + docker start tg-mint >/dev/null 2>&1 + log "S4: mint restarted at $(date -u +%H:%M:%S)" + if wait_usage S4-clientd upstream "[0-9]+/[0-9]+" 240; then + mark "PASS S4-recovery-after-restart" + else + mark "FAIL S4-recovery-after-restart" + fi + daemon_log S4-clientd + docker rm -f S4-clientd >/dev/null 2>&1 + DC stop upstream >/dev/null 2>&1 +} + +# ------------------------------------------------- S5 rotation + fees (opt-in) +S5() { + if ! DC config --services 2>/dev/null | grep -q '^upstream-rotate$'; then + log "S5: SKIPPED — upstream-rotate service not defined in this compose (mint-rotate/mint-fees are on main; S5 drives upstream-rotate, still keyset-rotation-branch only)" + return 0 + fi + scenario_dir S5 + export MINT_ROTATE_ROTATIONS='[{"unit":"sat","version":"v1","input_fee_ppk":100,"expired":false}]' + DC up -d mint-rotate mint-fees upstream-rotate >/dev/null 2>&1 + sleep 5 + DC run --rm --entrypoint sh client -c ' + set -e + rm -rf /w/s5r /w/s5f + cdk-cli -w /w/s5r mint http://mint-rotate:8085 50 >/dev/null + cdk-cli -w /w/s5f mint http://mint-fees:8085 50 >/dev/null + ' > "$OUT/S5/fund.log" 2>&1 || { mark "FAIL S5-fund"; cat "$OUT/S5/fund.log"; return 1; } + + DC restart upstream-rotate >/dev/null 2>&1; sleep 5 + start_daemon S5-clientd upstream-rotate client --wallet cdk-cli --wallet-dir /w/s5r --steps 2 --renew-below 45s + if wait_usage S5-clientd upstream-rotate "[0-9]+/[0-9]+" 90; then + local allot; allot=$(usage_of S5-clientd upstream-rotate | cut -d/ -f2) + mark "PASS S5a-fee-mint-payment (allotment=${allot}ms; fee-aware step reduction if < 120000)" + echo "allotment_after_fee=${allot}" >> "$OUT/S5/state.txt" + else + mark "FAIL S5a-fee-mint-payment" + fi + daemon_log S5-clientd + docker rm -f S5-clientd >/dev/null 2>&1 + + export MINT_ROTATE_ROTATIONS='[{"unit":"sat","version":"v1","input_fee_ppk":100,"expired":true},{"unit":"sat","version":"v1","input_fee_ppk":0,"expired":false}]' + DC stop mint-rotate >/dev/null 2>&1 + DC up -d mint-rotate >/dev/null 2>&1 + sleep 8 + DC restart upstream-rotate >/dev/null 2>&1; sleep 5 + start_daemon S5b-clientd upstream-rotate client --wallet cdk-cli --wallet-dir /w/s5r --steps 2 --renew-below 45s + sleep 25 + daemon_log S5b-clientd + if docker logs S5b-clientd 2>&1 | grep -q "paid" \ + && docker logs S5b-clientd 2>&1 | grep -qE '\([0-9]+/[0-9]+\)'; then + mark "PASS S5b-rotation-survived (wallet re-keyed, payment credited — session observed)" + elif docker logs S5b-clientd 2>&1 | grep -qE "FATAL:.*(terminal code|payment-error-)"; then + mark "PASS S5b-rotation-terminal-stop (coded refusal, daemon exited — no wedge)" + else + mark "FAIL S5b-rotation-no-credit (no paid+session, no coded stop — wedged?)" + fi + docker rm -f S5b-clientd >/dev/null 2>&1 + + start_daemon S5c-clientd upstream-rotate client --wallet cdk-cli --wallet-dir /w/s5f --mint http://mint-fees:8085 --steps 1 --renew-below 45s + sleep 20 + daemon_log S5c-clientd + # A 1-sat token under a 1-sat swap fee is a TERMINAL coded refusal: the + # only acceptable outcomes are a credited session or the daemon stopping + # on the code. "top-up failed" backoff alone is a wedge, not a pass. + if docker logs S5c-clientd 2>&1 | grep -q "paid" \ + && docker logs S5c-clientd 2>&1 | grep -qE '\([0-9]+/[0-9]+\)'; then + mark "PASS S5c-fee-edge-credited (unexpected but verified: session observed)" + elif docker logs S5c-clientd 2>&1 | grep -qE "FATAL:.*(terminal code payment-error-below-swap-fee|payment-error-below-swap-fee)"; then + mark "PASS S5c-fee-edge-terminal (1-sat token refused with the terminal code, daemon exited)" + else + mark "FAIL S5c-fee-edge-wedged (neither credited nor terminally stopped)" + fi + docker rm -f S5c-clientd >/dev/null 2>&1 + DC stop upstream-rotate mint-rotate mint-fees >/dev/null 2>&1 +} + +SCENARIOS="${SCENARIOS:-S2 S3 S4}" +log "=== clientd scenario battery start: $SCENARIOS ===" +ensure_images || exit 1 +for s in $SCENARIOS; do + case "$s" in + S2|S3|S4|S5) "$s" || true ;; + *) log "unknown scenario: $s" ;; + esac +done +log "=== clientd scenario battery done ===" +cat "$STATUS" diff --git a/tests/cloud-lab/test_clientd_autotopup.py b/tests/cloud-lab/test_clientd_autotopup.py new file mode 100644 index 000000000..27500782f --- /dev/null +++ b/tests/cloud-lab/test_clientd_autotopup.py @@ -0,0 +1,139 @@ +"""test_clientd_autotopup.py — tollgate-clientd e2e against the real stack. + +Runs scripts/tollgate-clientd.py (bind-mounted at /clientd) inside the +client container against the live TollGate backend and cdk-mintd: + + 1. --list-offers parses the real advertisement + 2. --status --json / --waybar one-shot output + 3. --dry-run watches but never pays + 4. the daemon buys a 1-minute step, then renews at the threshold + +Sessions live in the upstream container's memory and are keyed by the +client MAC, so a leftover session from another suite blocks renewal +observation. Run standalone or restart first: + + docker compose up -d mint upstream + docker compose run --rm client -sv test_clientd_autotopup.py + # or: docker compose restart upstream +""" + +import json +import subprocess +import time + +import pytest +import requests +from conftest import UPSTREAM_URL + +CLIENTD = "/clientd/tollgate-clientd.py" +CLIENT_IP = "172.28.0.20" +LOG = "/tmp/clientd-daemon.log" + + +def run_clientd(*args, timeout=60): + return subprocess.run( + ["python3", CLIENTD, *args], + capture_output=True, text=True, timeout=timeout, check=False, + ) + + +def usage(): + r = requests.get(f"{UPSTREAM_URL}/usage", + headers={"X-Real-Ip": CLIENT_IP}, timeout=10) + return r.text.strip() + + +def current_allotment(): + u = usage() + return 0 if u == "-1/-1" else int(u.split("/")[1]) + + +def wait_allotment(predicate, timeout, what): + deadline = time.time() + timeout + last = -1 + while time.time() < deadline: + last = current_allotment() + if predicate(last): + return last + time.sleep(1) + pytest.fail(f"timed out waiting for {what} (last allotment={last})") + + +@pytest.fixture(scope="module") +def clean_session(upstream_health): + """Require no active session for the client MAC.""" + deadline = time.time() + 30 + while time.time() < deadline: + if usage() == "-1/-1": + return + time.sleep(2) + pytest.skip("client MAC already has an active session (leftover from " + "another suite); run `docker compose restart upstream` first") + + +class TestReadOnlyModes: + def test_list_offers_parses_real_advertisement(self, upstream_health, client_mac): + r = run_clientd("--gateway", "upstream", "--mac", client_mac, + "--list-offers") + assert r.returncode == 0, r.stderr + assert "metric=milliseconds" in r.stdout + assert "1 sats/step" in r.stdout + assert "http://mint:8085" in r.stdout + + def test_status_json_no_session(self, upstream_health, clean_session, client_mac): + r = run_clientd("--gateway", "upstream", "--mac", client_mac, + "--status", "--json") + assert r.returncode == 0, r.stderr + st = json.loads(r.stdout) + assert st["session_active"] is False + assert st["metric"] == "milliseconds" + assert st["mint"] == "http://mint:8085" + assert st["gateway"] == "upstream" + + def test_waybar_json_shape(self, upstream_health, clean_session, client_mac): + r = run_clientd("--gateway", "upstream", "--mac", client_mac, + "--waybar") + assert r.returncode == 0, r.stderr + w = json.loads(r.stdout) + assert {"text", "tooltip", "class", "percentage"} <= set(w) + assert w["class"] == "critical" + assert "no session" in w["text"] + + +def test_dry_run_never_pays(upstream_health, clean_session, client_mac): + proc = subprocess.Popen( + ["python3", CLIENTD, "--gateway", "upstream", "--mac", client_mac, + "--dry-run", "--interval", "0.5"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + time.sleep(6) + finally: + proc.terminate() + out, err = proc.communicate(timeout=10) + assert usage() == "-1/-1", "dry-run must not create a session" + assert "would top up" in out + err + + +def test_daemon_pays_and_renews(upstream_health, ecash_wallet, clean_session, client_mac): + with open(LOG, "w") as log: + proc = subprocess.Popen( + ["python3", CLIENTD, "--gateway", "upstream", "--mac", client_mac, + "--wallet", "cdk-cli", "--wallet-dir", ecash_wallet, + "--steps", "1", "--renew-below", "45s", "--interval", "1"], + stdout=log, stderr=log) + try: + first = wait_allotment(lambda a: a > 0, 60, "initial payment") + second = wait_allotment(lambda a: a > first, 90, + "threshold renewal") + finally: + proc.terminate() + proc.wait(timeout=10) + + with open(LOG) as f: + daemon_log = f.read() + assert daemon_log.count("-> paid") >= 2, daemon_log + assert second > first + # the bought time persists after the daemon exits + assert usage() != "-1/-1" + print(f"allotment after initial payment: {first} ms; after renewal: {second} ms") + print(daemon_log)