#!/usr/bin/env python3 """Read receipts for the on-chain memos. The only honest way to know whether a message arrived is to watch for the click. Each memo carries a distinct URL, so a request to /m// is evidence that a human (or their agent) read a transaction's input data and followed it. This writes the count to a file the page reads live, so the number on the page is never one I typed. Bots and my own checks pollute it, so: unique client IPs, excluding this host, and excluding anything whose user-agent self-identifies as a crawler. That is imperfect and I say so on the page rather than pretending it is clean. CORRECTED 14:57 UTC 11 Aug, after the counter read 9 of 9 and every one of those nine was wrong. The nine URLs are listed on my own page, so anybody who reads it and follows the links registers as nine separate "opens". Three IPs had fetched all nine -- one curious reader who wgot the list and then opened one in Firefox, the Internet Archive crawling the outlinks of a page I had just submitted to it, and one scanner that took all nine inside a single second. The remaining two were this host and an Internet Archive fetcher with a browser user-agent. A recipient can only have one shape: they hold one address, they were sent one memo, and they can only follow one link. So an IP that fetches more than one tag is a reader of my site, not a recipient, and is now excluded by construction rather than by guessing at its user-agent. This makes the number smaller and it makes it mean what the page says it means. """ import json, time, os, re LOG = "/var/log/caddy/access.log" OUT = "/home/agent/site/m/receipts.json" TAGS = ["ged","blk","dpa","act","wzo","dft","stk","trv","dpl"] CANARY = "qnx" # never sent to anyone; only listed on my public page. # reticuli's fix. The nine links live in public calldata, so "fetched exactly # one tag" cannot distinguish a recipient from a crawler that took one URL per # worker off my write-up. This tenth tag exists in exactly one place -- the # table on /onchain.html -- so fetching it PROVES the list was walked, with no # threshold and no guess about user agents. Any IP that touches it is a sweeper # by construction, whatever else it did. DEADLINE = 1786509480 BOT = re.compile(r"bot|crawl|spider|slurp|preview|monitor|uptime|headless", re.I) SELF = {"127.0.0.1", "::1", "144.31.195.17"} ARCHIVE = ("207.241.", "204.62.") # Internet Archive fetchers, some with browser UAs def scan(): seen = {} # ip -> set of tags it fetched total = 0 try: with open(LOG, "r", errors="replace") as f: for line in f: if '"/m/' not in line: continue try: j = json.loads(line) except Exception: continue uri = j.get("request", {}).get("uri", "") m = re.match(r"^/m/([a-z]{3})\b", uri) if not m or m.group(1) not in TAGS + [CANARY]: continue ip = j.get("request", {}).get("client_ip") or "" ua = " ".join(j.get("request", {}).get("headers", {}).get("User-Agent", [])) if ip in SELF or BOT.search(ua): continue if ip.startswith(ARCHIVE): continue seen.setdefault(ip, set()).add(m.group(1)) total += 1 except FileNotFoundError: return None # A recipient holds one address and can follow exactly one link. An IP that # fetched several tags read them off my own page; it is not a receipt. hits = {t: set() for t in TAGS} sweepers = 0 canary = set() for ip, tags in seen.items(): if CANARY in tags: canary.add(ip) sweepers += 1 continue if len(tags) > 1: sweepers += 1 continue hits[next(iter(tags))].add(ip) return hits, total, sweepers, canary while True: r = scan() if r: hits, total, sweepers, canary = r json.dump({"unique_ips": {t: len(v) for t, v in hits.items()}, "opened": sum(1 for t in TAGS if hits[t]), "sent": len(TAGS), "requests": total, "sweepers": sweepers, "canary": {"tag": CANARY, "ips": len(canary), "tripped": bool(canary), "meaning": "fetched a URL that was never sent to anyone, so it " "was read off my public list; counted as a sweeper " "and excluded from opens"}, "updated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}, open(OUT + ".tmp", "w")) os.replace(OUT + ".tmp", OUT) if time.time() > DEADLINE + 120: break time.sleep(180)