Suspend (without the warning email) and delete any signup whose username
fully matches JUNK_USERNAME_PATTERNS, default bp[0-9a-f]{16}: a campaign of
~100/day never-confirmed signups whose confirmation emails were bouncing
into the yttrx inbox and bombing plus-tagged real inboxes. Runs before any
IP/email lookup or welcome; ships dry-run by default.
A /32 block is close to useless against what IP scrutiny fires on: a
datacenter/VPN/proxy customer has the rest of the range sitting right
there, so blocking the single address we saw stops nothing the same
actor does a minute later from the address next door. The width ceiling
added 2026-09-13 made this the common case rather than the exception:
its fallback for an over-wide ASN route was the bare address, so a
flagged signup out of a /18 hosting allocation got a /32.
Add IP_SCRUTINY_MIN_BLOCK_PREFIX_V4/_V6 (24/48) as a floor under that
ceiling. Anything narrower than the floor -- a missing route, the /32
recorded when a route didn't contain the IP, a small hosting allocation,
or the fallback from a route wider than the ceiling -- is widened to the
network of that width containing the address. At the defaults the floor
equals the ceiling, so every v4 block is now exactly the /24 containing
the flagged address.
State the collateral cost rather than waving at it: at the live settings
(sign_up_block severity, ~30d expiry) one flagged signup now hard-refuses
registration from 256 addresses for a month. Moving the severity to
sign_up_requires_approval or shortening the expiry buys that back, and
setting the floor to 32/128 restores the old behaviour entirely.
The pre-signup path had no inspection tool. Its activity landed in docker logs
and two sqlite tables with nothing to read them, while the reactive path has
had welcomebot-signups and welcomebot-suspicious all along.
welcomebot-reputation reads reputation_query and reputation_block directly so
it survives log rotation: recent answers, --stats for volume and flag/block
rate by source, --blocks for what is actually in force, and --deferred to see
whether the hourly provider-lookup ceiling is biting. --blocks marks rows GONE
once the reconcile pass finds the block no longer on the Mastodon side, so
"we installed this" and "this is in force" stay distinguishable.
welcomebot-logs --reputation covers the raw lines (pre-blocks, budget
refusals, width-cap narrowings).
README gets the full contract (request/response shape, the cost ceiling and
why it exists, the block-width cap and expiry, and the two new tables).
CLAUDE.md gets the two rules that are easy to undo by accident: the endpoint
must never serve unauthenticated, and the width cap and expiry are policy
decisions rather than tuning knobs.
ipblock_registered and domain_blocked only ever meant "we posted this
block once" — they said nothing about whether it still exists. When the
API went live it was publishing 24 /32 ranges that had been removed by
hand on 2026-07-21, so ~17% of the IP list didn't block anything. That's
a bad thing to hand another admin an importer for.
app/reconcile_blocks.py fetches Mastodon's live Admin::IpBlock and
Admin::EmailDomainBlock lists and maintains a new block_active column on
both tables: off for rows whose block is gone or whose expires_at has
elapsed, back on for any that reappear. The API serves only active rows.
Re-registering a block reactivates its row, so a range the bot blocks
again after a manual cleanup comes back on its own.
A failed fetch changes nothing — not a 403 from a missing scope, not
Mastodon being down, not a half-failure where one list answers and the
other doesn't. An outage must never be read as "every block was
removed"; the list degrades to over-reporting rather than emptying
itself, and there's a test for each of those paths.
Needs admin:read:ip_blocks + admin:read:email_domain_blocks, added to
the live token in place (scopes updated on the existing Doorkeeper
token, matched by digest) rather than by minting and rotating, so the
token value is unchanged and .env needed no edit.
Mastodon has no RBL/DNSBL support — nothing in it consults an outside
list when deciding whether to accept a signup, confirmed against v4.7.1
(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 rather than queried live, the same
way Garden Fence and other community lists propagate. These are the
pieces that make ours importable.
- ?format=txt on both per-list endpoints: one entry per line under a '#'
comment header, the form blocklists are traded in and the form a cron
job can consume without a JSON parser. The combined /api/blocks stays
JSON-only — a text rendering would mix CIDRs with domains in one file,
defeating the point. An unrecognised ?format is a 400 rather than a
silent fallback to JSON, and the enabled-gate still runs first so a
disabled deployment 404s either way.
- Access-Control-Allow-Origin: * on blocklist responses, so browser-side
admin tooling can read them. The data is already public.
- bin/yttrx-blocklist-import: standard-library-only importer another
admin can run against their own instance. Dry run unless --apply;
422 from Mastodon counts as "already blocked", not an error.
The importer defaults IP entries to sign_up_requires_approval rather
than the sign_up_block yttrx uses itself. The entries are whole ASN
routes, which suits yttrx because a flagged signup here is nearly always
datacenter/VPN/proxy space — that calibration isn't transferable, so an
importer gets the reviewable severity by default and has to opt into the
hard one.
Every row blocked before the block_cidr column existed had nothing to
report, so the endpoint fell back to the signup IP's /32 — the address
that signed up rather than the range that was blocked, which is the
opposite of what the list is for.
The range is recoverable from the bot's own ipapi range cache, which
stores the CIDR each classification was cached under — the same value
that was handed to register_ip_block. Run once on admin.yttrx.com:
of 154 blocked rows, 65 gained their real range, 79 genuinely were
single-address blocks (no asn.route from ipapi.is), and 10 with no cache
entry stay as a /32, since under-reporting beats claiming a range is
blocked when it isn't. Rows classified before 2026-07-15 are skipped —
range-blocking didn't exist yet, so only the /32 was ever registered.
Live output went from 150 entries (all /32) to 145, of which 59 are
real networks.
Publishes the IP ranges and email domains the bot has auto-blocked as
JSON: GET /api/blocks, /api/blocks/ip-ranges, /api/blocks/email-domains,
each taking an optional ?limit=N.
The endpoints are unauthenticated, so BLOCKS_API_ENABLED gates them and
defaults to false — with it unset the paths 404 as if they didn't exist.
Publishing which ranges are already blocked also tells a spam signup
which ones to avoid, so opting in is a per-deployment call; yttrx.com
sets it true in the box's .env.
Responses assert only that a block exists: the blocked range/domain, a
signups count, and first/last seen. No account identifiers, and none of
the ipapi.is or check-mail.org classification that drove the decision —
republishing a paid reputation feed's verdicts on an open endpoint would
be redistributing their data rather than ours.
Supporting changes:
- signup_ip gains a block_cidr column (migrated in place on open) so the
API reports the network actually blocked rather than the signup IP.
Rows predating it fall back to that IP's /32 or /128, which is exactly
what was registered before range-blocking started 2026-07-15.
- register_ip_block / register_email_domain_block now return whether the
block landed, and the caller only marks it registered on success, so a
failed POST is no longer published as a block that exists.
datacenter/proxy/tor/abuser is a much cleaner bulk/bot-signup indicator
than vpn, which also flags plenty of privacy-conscious real users. A
strong-flagged signup now suspends immediately regardless of the email
signal; vpn-only still gets the normal held-welcome/ipblock/lowered-
threshold treatment, falling through to the existing combined-signal
suspend only if also paired with a flagged email domain.
register_ip_block always blocked the exact signup IP as a /32 (or /128
for v6), which is close to pointless for the datacenter/VPN/proxy space
these flags fire on: a repeat bad actor from the same provider almost
never reuses the exact same address, but very often reuses a different
one in the same block.
classify_signup_ip now returns a fourth value, block_cidr — ipapi.is's
asn.route CIDR when it's a valid network that actually contains the
signup IP (already being tracked in ipapi_range_cache purely for lookup
caching), falling back to the address's own /32 or /128 when no usable
route exists. register_ip_block blocks that instead of always deriving
a /32 itself.
Added range_cache_tests() covering the cache/fallback logic directly
(pure sqlite + ipaddress, no network), and updated the existing
ip_scrutiny_tests()/combined_signal_tests() mocks for the new 4-tuple
classify_signup_ip return and register_ip_block arity.
Datacenter/hosting-IP-flagged signups are a strong enough spam signal on
their own that a full day of grace was mostly just delaying an inevitable
suspend. Cron cadence tightened from hourly to every 10 minutes so real
latency stays close to the new 1h window.
Watches every account flagged by IP-scrutiny or email-domain scrutiny at
signup and, once it goes live, records a baseline post/follow count. A
scheduled sweep (app/suspicious_sweep.py, run via cron on admin.yttrx.com)
suspends any watch past SUSPICIOUS_GRACE_HOURS with zero new posts and
zero new follows since that baseline; any activity clears the watch.
Works whether yttrx is open-registration or requires moderator approval,
since the watch starts at whichever event actually makes the account live
(account.created vs account.approved), same dual handling the welcome flow
already uses. Needs ABUSE_BOT_TOKEN re-minted with admin:read:accounts.
Classifies each signup's email domain (domain-only, GDPR-friendly) alongside
the existing IP scrutiny signal, with matching held-welcome and auto
email_domain_block behavior. A report against an account with a flagged
domain suspends immediately (no reporter-count threshold) and blocks the
domain, classified live from the report payload rather than any signup-time
record so it also covers pre-existing accounts. Ships CHECK_MAIL_DRY_RUN=true
by default, independent of ABUSE_DRY_RUN, so the new report-triggered suspend
path stays inert until watched.