#!/usr/bin/env python3 """Import yttrx.com's published signup blocklist into another Mastodon instance. Mastodon has no RBL/DNSBL support — nothing in it consults an outside list when deciding whether to accept a signup (the only outbound lookup in that path is EmailMxValidator's MX resolution, which feeds the *local* email_domain_blocks table). So a shared blocklist has to be imported, not queried live. This script does that: it fetches the published list and POSTs each entry to your own instance's admin API. Standard library only — no pip install, no venv. # see what it would do (default) ./yttrx-blocklist-import --instance https://your.instance --token "$TOKEN" # actually write the blocks ./yttrx-blocklist-import --instance https://your.instance --token "$TOKEN" --apply Your token needs `admin:write:ip_blocks` and `admin:write:email_domain_blocks`. Mint one from the Rails console on your instance: bin/rails runner ' acct = Account.find_local("youradmin") scopes = "admin:write:ip_blocks admin:write:email_domain_blocks" app = Doorkeeper::Application.create!(name: "blocklist import", scopes: scopes, redirect_uri: "urn:ietf:wg:oauth:2.0:oob") puts Doorkeeper::AccessToken.create!(application_id: app.id, resource_owner_id: acct.user.id, scopes: scopes).token' A NOTE ON SEVERITY, worth reading before --apply: The IP entries are whole networks (the ASN route a flagged signup's address belongs to), because on yttrx a flagged signup is almost always datacenter, VPN or proxy space where the next attempt comes from a neighbouring address. That is calibrated to yttrx's traffic, not yours. This script therefore defaults to `sign_up_requires_approval` — a flagged signup lands in your moderation queue rather than being rejected outright — even though yttrx runs these at `sign_up_block` itself. Pass --ip-severity sign_up_block if you want the harder behaviour; think about whether you have users behind a VPN first. Entries already present come back 422 from Mastodon and are counted as "already blocked", not as errors. """ from __future__ import annotations import argparse import json import sys import urllib.error import urllib.parse import urllib.request DEFAULT_SOURCE = "https://hooks.yttrx.com/api/blocks" IP_SEVERITIES = ("sign_up_requires_approval", "sign_up_block", "no_access") def fetch_source(url: str, timeout: float) -> dict: req = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode("utf-8")) def post(base: str, path: str, token: str, fields: dict, timeout: float) -> int: """POST form-encoded fields; returns the HTTP status (422 included, which Mastodon uses for 'this block already exists').""" req = urllib.request.Request( f"{base}{path}", data=urllib.parse.urlencode(fields).encode(), headers={"Authorization": f"Bearer {token}"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status except urllib.error.HTTPError as exc: return exc.code def import_entries(entries, label, base, path, token, field_fn, timeout, apply_changes): created = existing = failed = 0 for entry in entries: fields = field_fn(entry) shown = next(iter(fields.values())) if not apply_changes: print(f" would block {shown}") created += 1 continue status = post(base, path, token, fields, timeout) if status in (200, 201): created += 1 print(f" blocked {shown}") elif status == 422: existing += 1 elif status in (401, 403): print(f" ERROR {status} on {shown} — token is missing the required " f"admin write scope; stopping.", file=sys.stderr) failed += 1 break else: failed += 1 print(f" ERROR {status} on {shown}", file=sys.stderr) verb = "would block" if not apply_changes else "blocked" print(f"{label}: {created} {verb}, {existing} already blocked, {failed} failed") return failed def main() -> int: ap = argparse.ArgumentParser( description="Import yttrx.com's published signup blocklist into your Mastodon instance.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Runs as a dry run unless --apply is given.", ) ap.add_argument("--instance", required=True, help="your instance's base URL, e.g. https://your.instance") ap.add_argument("--token", required=True, help="admin token with admin:write:ip_blocks + admin:write:email_domain_blocks") ap.add_argument("--source", default=DEFAULT_SOURCE, help=f"blocklist JSON to import (default: {DEFAULT_SOURCE})") ap.add_argument("--ip-severity", default="sign_up_requires_approval", choices=IP_SEVERITIES, help="severity for imported IP ranges (default: %(default)s — see the note " "at the top of this script before choosing sign_up_block)") ap.add_argument("--ips-only", action="store_true", help="import only the IP ranges") ap.add_argument("--domains-only", action="store_true", help="import only the email domains") ap.add_argument("--min-signups", type=int, default=1, help="skip entries seen fewer than this many times (default: %(default)s)") ap.add_argument("--limit", type=int, default=0, help="import at most N of each kind") ap.add_argument("--timeout", type=float, default=30.0, help="HTTP timeout in seconds") ap.add_argument("--apply", action="store_true", help="actually write the blocks (without this, nothing is changed)") args = ap.parse_args() if args.ips_only and args.domains_only: ap.error("--ips-only and --domains-only are mutually exclusive") base = args.instance.rstrip("/") try: data = fetch_source(args.source, args.timeout) except (urllib.error.URLError, ValueError) as exc: print(f"could not fetch {args.source}: {exc}", file=sys.stderr) return 1 def pick(section): items = [e for e in data.get(section, {}).get("items", []) if e.get("signups", 1) >= args.min_signups] return items[:args.limit] if args.limit > 0 else items ip_ranges = [] if args.domains_only else pick("ip_ranges") domains = [] if args.ips_only else pick("email_domains") print(f"source : {args.source}") print(f"generated : {data.get('generated_at', 'unknown')}") print(f"target : {base}") print(f"mode : {'APPLY — writing blocks' if args.apply else 'dry run (pass --apply to write)'}") print(f"ip ranges : {len(ip_ranges)} (severity {args.ip_severity})") print(f"domains : {len(domains)}") print() failed = 0 if ip_ranges: failed += import_entries( ip_ranges, "IP ranges", base, "/api/v1/admin/ip_blocks", args.token, lambda e: {"ip": e["cidr"], "severity": args.ip_severity, "comment": f"imported from {args.source}"}, args.timeout, args.apply) if domains: failed += import_entries( domains, "email domains", base, "/api/v1/admin/email_domain_blocks", args.token, lambda e: {"domain": e["domain"]}, args.timeout, args.apply) return 1 if failed else 0 if __name__ == "__main__": sys.exit(main())