Files
yttrx-welcomebot/.env.example
T
pmb 147673fd49
docker-build-push / build-push (push) Successful in 5s
Cap how wide a single auto-registered IP block may be
ipapi.is reports the flagged address's whole ASN route, and register_ip_block
blocked it whatever its width. That is the right unit for hosting space, where
a repeat bad actor comes back from a different address in the same VPS block.
It is badly wrong for an ISP aggregate, and the route cache holds routes as
wide as /8, /9 and /11: one `abuser` flag on a residential or mobile address
inside one of those would have walled off millions of consumer addresses.

That was not hypothetical -- 197.211.32.0/19 is registered against
GLOMOBILE-GPRS, a Benin mobile carrier, from a single `abuser` flag: 8,192
addresses of consumer mobile space.

Anything wider than /24 (v4) or /48 (v6) now falls back to blocking the single
address. This costs blocking strength against large hosting allocations -- an
OVH /16 blocks one address now, so a repeat offender from that block needs a
fresh block each time -- and in exchange makes the collateral-damage case
impossible. Both ceilings are env-tunable.

The cap applies to what gets BLOCKED, not to what gets CACHED: the wide route
is still the right cache key, since it genuinely belongs to one operator and
one lookup answers for the whole of it. Narrowing the cache key would multiply
provider lookups for no benefit.

Applied at registration time, so blocks already in force are untouched.
2026-09-13 16:08:37 -07:00

340 lines
17 KiB
Bash

# Copy to .env and fill in. Do NOT commit .env.
# HMAC secret shown when you create the webhook in Mastodon
# (Administration -> Webhooks). Used to verify X-Hub-Signature.
WEBHOOK_SECRET=
# Access token for the bot account that sends the welcome DMs.
# Create a bot account on yttrx, then Preferences -> Development ->
# New application with scope: write:statuses (copy "Your access token").
BOT_ACCESS_TOKEN=
# Base URL of the Mastodon instance (no trailing slash).
MASTODON_BASE_URL=https://yttrx.com
# Welcome message template. {acct} is replaced with the new user's handle.
# The bot mentions @{acct} so a "direct" status reaches them.
WELCOME_MESSAGE=👋 Welcome to yttrx, @{acct}! Glad to have you here. If you have any questions, check out https://help.yttrx.com or just reply to this message.
# Only welcome accounts local to this instance (recommended: true).
LOCAL_ONLY=true
# Path to the sqlite dedup store inside the container (matches the volume).
DB_PATH=/data/welcomed.db
# --- Abuse-bot (report.created handling) -----------------------------------
# Access token for a DEDICATED MODERATOR bot account. That account must hold a
# role with the "Manage Users" + "Manage Reports" permissions, and the app
# token must request these scopes:
# admin:write:accounts admin:read:reports read:statuses write:statuses
# Leave blank to disable report handling entirely.
ABUSE_BOT_TOKEN=
# Master switch for report handling (also off if ABUSE_BOT_TOKEN is blank).
ABUSE_ENABLED=true
# Rollout safety: when true, log + DM what WOULD happen but take no action.
# Recommended for the first days in production; flip to false once happy.
ABUSE_DRY_RUN=true
# Moderation action: "silence" (reversible, agreed default) or "suspend".
ABUSE_ACTION=silence
# Only auto-act on accounts local to yttrx.
ABUSE_LOCAL_ONLY=true
# Account tiers (in days):
# young = oldest post newer than ABUSE_YOUNG_MAX_DAYS
# dormant = newest post older than ABUSE_DORMANT_MIN_DAYS
ABUSE_YOUNG_MAX_DAYS=30
ABUSE_DORMANT_MIN_DAYS=30
# Required number of DISTINCT reporter accounts (open reports) to auto-act:
# young / dormant / no-posts -> ABUSE_SOURCES_NEWDORMANT
# normally-active -> ABUSE_SOURCES_ACTIVE
ABUSE_SOURCES_NEWDORMANT=2
ABUSE_SOURCES_ACTIVE=3
# Safety cap on the backward status scan (40 statuses per page).
ABUSE_MAX_STATUS_PAGES=5
# Automatically never auto-act on accounts holding a staff role
# (Admin/Owner/Moderator), read from the report payload. Keep this true.
ABUSE_SKIP_PRIVILEGED=true
# Extra comma-separated handles (acct, no leading @) never to auto-act on —
# a backstop on top of ABUSE_SKIP_PRIVILEGED, and for non-staff special
# accounts. e.g. ABUSE_ALLOWLIST=waffles,tommertron,davis,bot
ABUSE_ALLOWLIST=
# Handle (acct, no @) to DM with a review summary after an auto-action.
# Leave blank to disable the DM. The DM is sent from the moderator bot.
MOD_ALERT_ACCT=
# Help/appeals page the silenced user is pointed to.
ABUSE_HELP_URL=https://welcome.yttrx.com/posts/account-limited/
# DM sent to the silenced user ({acct} + {help_url} substituted; must mention
# @{acct}). Leave blank to disable the user DM.
ABUSE_USER_DM=Hi @{acct} — your yttrx account has been temporarily limited while we review some reports. This is reversible and a moderator will take a look. Here's what happened and how to appeal: {help_url}
# --- IP-based signup scrutiny -----------------------------------------------
# Classifies each new signup's IP (Admin::Account.ip, delivered free on the
# account.created webhook) via ipapi.is, and flags it if ipapi.is reports it
# as any of datacenter, vpn, proxy, tor, or an independently-scored abuser.
# Master switch:
IP_SCRUTINY_ENABLED=true
# Optional ipapi.is API key for higher rate limits. Anonymous (blank) is
# capped at 1,000 req/day, which comfortably covers yttrx's signup volume;
# get a key at https://ipapi.is/ only if you expect to exceed that.
IP_SCRUTINY_IPAPI_KEY=
# Rollout safety: when true, classify + DM a moderator but take NO action
# (no held welcome, no ip_blocks write). Recommended for the first days in
# production; flip to false once you've reviewed the false-positive rate.
IP_SCRUTINY_DRY_RUN=true
# Hold the welcome DM for a flagged signup until account.approved fires
# (a human clears the existing approval-required registration gate), instead
# of welcoming immediately like every other signup.
IP_SCRUTINY_HOLD_WELCOME=true
# Distinct-reporter threshold used instead of the tier's usual
# ABUSE_SOURCES_* threshold (whichever is lower) when the reported account's
# signup IP was flagged.
IP_SCRUTINY_ABUSE_THRESHOLD=1
# Auto-register a flagged signup's network into Mastodon's native
# Admin::IpBlock — ipapi.is's asn.route CIDR when it's a valid network that
# actually contains the signup IP, else just that one /32 or /128. Blocking
# the whole network matters here because a flagged signup is almost always
# datacenter/VPN/proxy space, where a repeat bad actor is far more likely to
# come back from a different address in the same block than the exact one.
# Requires the ABUSE_BOT_TOKEN to carry the admin:write:ip_blocks scope (see
# CLAUDE.md's moderator-token-gotcha section) — without it this 403s and is
# logged as an error, but nothing else in the bot is affected.
IP_SCRUTINY_AUTO_IPBLOCK=true
# Severity applied to auto-registered blocks: sign_up_requires_approval (soft,
# sends flagged range's future signups to the moderator queue), sign_up_block
# (hard — flatly rejects future signups from the range at registration, no
# queue, cannot appeal via this bot), or no_access (blocks all access, not
# just signups).
IP_SCRUTINY_IPBLOCK_SEVERITY=sign_up_block
# Strong IP-reputation signals — datacenter, proxy, tor, or an independently-
# scored abuser, but NOT vpn alone (which also flags plenty of privacy-
# conscious real users and is a weaker signal on its own). A signup flagged
# with any of these is suspended immediately at signup time, regardless of
# the email-domain signal, instead of just the held-welcome/auto-ipblock/
# lowered-threshold treatment every other flagged signup gets. Master switch:
IP_SCRUTINY_STRONG_SUSPEND_ENABLED=true
# Moderation action taken immediately on a strong IP signal: "suspend"
# (default) or "silence".
IP_SCRUTINY_STRONG_SUSPEND_ACTION=suspend
# Rollout safety switch — ships "true" (dry-run) here, same reasoning as
# SUSPICIOUS_COMBINED_DRY_RUN, since this is a brand-new action path. Flip to
# "false" once the moderator DMs look right.
IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN=true
# check-mail.org disposable/high-risk email domain scrutiny (roadmap item B,
# anti-abuse.md). Domain-only query (never the full email) against
# POST https://api.check-mail.org/v2/, Authorization: Bearer <key>.
# Free tier: 1,000 req/month.
CHECK_MAIL_ENABLED=true
CHECK_MAIL_API_KEY=
# Log/DM only — no held welcome, no email_domain_block write, no
# report-triggered suspend. Keep true for rollout until you've watched the
# false-positive rate for a while (same role as IP_SCRUTINY_DRY_RUN /
# ABUSE_DRY_RUN).
CHECK_MAIL_DRY_RUN=true
# A domain is flagged if check-mail.org marks it is_disposable OR its risk
# score (0-100) is at or above this threshold.
CHECK_MAIL_RISK_THRESHOLD=80
# Hold the welcome DM for a flagged signup until account.approved fires, same
# semantics as IP_SCRUTINY_HOLD_WELCOME.
CHECK_MAIL_HOLD_WELCOME=true
# Auto-register a flagged signup's email domain into Mastodon's native
# Admin::EmailDomainBlock. Requires ABUSE_BOT_TOKEN to carry the
# admin:write:email_domain_blocks scope (see CLAUDE.md) — without it this
# 403s and is logged as an error, but nothing else in the bot is affected.
# Also gates the report-triggered suspend+block override in handle_report().
CHECK_MAIL_AUTO_DOMAIN_BLOCK=true
# --- Suspicious-signup sweep (app/suspicious_sweep.py, run on a schedule) ---
# Watches every account flagged by EITHER IP-scrutiny or email-domain
# scrutiny at signup. Once the account goes live (account.created if open/
# auto-approved, account.approved if this instance requires moderator
# approval — same dual handling the welcome flow already uses), a baseline
# post/follow count is recorded. A scheduled sweep (cron on admin.yttrx.com,
# `docker exec yttrx-welcomebot python -m app.suspicious_sweep`) then acts on
# any watch past SUSPICIOUS_GRACE_HOURS with zero new posts AND zero new
# follows since that baseline. Master switch:
SUSPICIOUS_SWEEP_ENABLED=true
# Hours of grace given to a flagged signup to show organic activity (a new
# post or a new follow) before it's considered dormant/bot-like. Lowered from
# the original 24 to 1 (2026-07-06) — the datacenter/hosting-IP signal alone
# is a strong enough spam indicator that a full day of grace was mostly just
# delaying an inevitable suspend; paired with a tighter sweep cadence (see
# CLAUDE.md) so real latency stays close to this value.
SUSPICIOUS_GRACE_HOURS=1
# Moderation action taken on a flagged signup with no activity in the grace
# window: "suspend" (agreed default — a stronger signal than the report-
# driven abuse-bot's default "silence", since this is zero organic activity,
# not just reports) or "silence".
SUSPICIOUS_ACTION=suspend
# Rollout safety switch, same role as ABUSE_DRY_RUN — ships "false" (live)
# here by design; flip to "true" to pause without redeploying.
SUSPICIOUS_DRY_RUN=false
# Requires ABUSE_BOT_TOKEN to additionally carry the admin:read:accounts
# scope (fetches current post/follow counts + role/suspended state via
# GET /api/v1/admin/accounts/:id) — see CLAUDE.md's moderator-token-gotcha
# section. Without it the sweep 403s per-account (logged as an error, that
# account is retried next sweep) but nothing else in the bot is affected.
# Reuses ABUSE_SKIP_PRIVILEGED and ABUSE_ALLOWLIST above — no separate vars.
# --- Combined-signal immediate suspend (app/main.py, process_signup) ---
# The sweep above acts on EITHER signal (IP or email) after
# SUSPICIOUS_GRACE_HOURS of inactivity. A signup flagged by BOTH signals at
# once is a stronger indicator, so it's auto-actioned immediately at signup
# time instead of waiting for the hourly sweep. Additive — doesn't change
# the OR-based sweep's handling of single-signal flags. Reuses
# ABUSE_ALLOWLIST above (staff can't be brand-new signups, but checked for
# defense in depth) — no separate allowlist var.
SUSPICIOUS_COMBINED_ENABLED=true
# Moderation action taken immediately on a combined-signal signup: "suspend"
# (agreed default, same reasoning as SUSPICIOUS_ACTION) or "silence".
SUSPICIOUS_COMBINED_ACTION=suspend
# Rollout safety switch — ships "true" (dry-run) here, unlike
# SUSPICIOUS_DRY_RUN's by-design "false" default above, since this is a
# brand-new action path. Flip to "false" once the moderator DMs look right.
SUSPICIOUS_COMBINED_DRY_RUN=true
# --- Blocklist API (app/main.py, GET /api/blocks) ---------------------------
# Read-only JSON of the IP ranges and email domains this bot has auto-blocked:
# GET /api/blocks both lists in one document
# GET /api/blocks/ip-ranges registered Admin::IpBlock networks
# GET /api/blocks/email-domains registered Admin::EmailDomainBlock domains
# Optional ?limit=N on any of them. Responses assert only that a range/domain
# is blocked — no account handles, account ids or email addresses, and none of
# the ipapi.is / check-mail.org classification behind the decision (publishing
# those verdicts would be redistributing a paid reputation feed's data). Note
# this reports the *bot's own* record, not Mastodon's full blocklist: blocks a
# moderator added by hand in the admin UI aren't in it.
#
# SERVED WITHOUT AUTHENTICATION, so it defaults to off and every deployment
# must opt in: publishing which ranges and domains are already blocked also
# tells a would-be spam signup which ones to avoid. With this unset the three
# paths 404 as if they didn't exist. yttrx.com sets this to true.
BLOCKS_API_ENABLED=false
# The list is kept honest by app/reconcile_blocks.py (cron, hourly), which
# drops entries whose block no longer exists in Mastodon. That needs
# admin:read:ip_blocks + admin:read:email_domain_blocks on ABUSE_BOT_TOKEN.
# Hard ceiling on entries per response, whatever ?limit asks for — the
# endpoint is unauthenticated, so response size can't be left to the caller.
BLOCKS_API_MAX_ITEMS=5000
# Public base URL this API is reached at. Used only to print a "source:" line
# in the text format's header comments, so a downloaded list says where it
# came from; it plays no part in routing.
BLOCKS_API_PUBLIC_URL=https://hooks.yttrx.com
# Cache-Control max-age (seconds) on blocklist responses. The data only
# changes when a signup is flagged, so a few minutes of client/intermediate
# caching costs nothing.
BLOCKS_API_CACHE_SECONDS=300
# Widest network a single auto-registered IP block may cover, as a prefix
# length. ipapi.is reports the flagged address's whole ASN route. That is the
# right unit for hosting space — a bad actor comes back from a different
# address in the same VPS block — but wrong for an ISP aggregate: routes as
# wide as /8, /9 and /11 turn up, and one `abuser` flag on a residential or
# mobile address inside one of those would wall off millions of consumer
# addresses. Anything wider than this ceiling falls back to blocking the
# single address.
#
# /24 costs real blocking strength against large hosting allocations (an OVH
# /16 now blocks one address, not the range, so a repeat offender out of that
# block needs a fresh block each time) in exchange for making the
# mobile/residential collateral-damage case impossible. Raise toward /16 to
# trade back the other way.
#
# Applied at registration time, not retroactively: blocks already in force
# keep whatever width they were registered with.
IP_SCRUTINY_MAX_BLOCK_PREFIX_V4=24
# The v6 ceiling. /48 is the standard end-site allocation — the rough
# equivalent of what a /24 represents in v4.
IP_SCRUTINY_MAX_BLOCK_PREFIX_V6=48
# ---------------------------------------------------------------------------
# Reputation query API (pre-signup pre-blocking)
# ---------------------------------------------------------------------------
# POST /api/reputation turns the bot from reactive into pre-emptive. Mastodon
# calls it during signup, immediately BEFORE running its own IpBlock /
# EmailDomainBlock lookup:
#
# POST /api/reputation {"ip": "...", "email": "...", "source": "..."}
# X-Reputation-Key: <REPUTATION_API_KEY>
#
# The handler classifies the IP (ipapi.is) and/or the email's domain
# (check-mail.org), records the answer, registers the matching Admin::IpBlock
# / EmailDomainBlock, and only THEN returns — so the lookup Mastodon makes
# microseconds later finds the block this call just installed and rejects the
# signup outright, instead of the bot cleaning up an account that already
# exists. Both request fields are optional (the IP choke point has no address
# to offer); at least one is required.
#
# The caller is the `call-welcomebot-reputation-api` patch on yttrx's Mastodon
# fork — see yttrx-documentation/mastodon-fork-patches.md. It fails open: if
# this endpoint is slow, down or disabled, Mastodon proceeds with its own
# lookup exactly as before.
#
# Unlike the blocklist API above this one WRITES (it installs blocks), so it
# is authenticated and refuses to serve without a key.
REPUTATION_API_ENABLED=false
# Shared secret the caller presents as the X-Reputation-Key header; must match
# WELCOMEBOT_REPUTATION_KEY on the Mastodon side. Generate with
# `openssl rand -hex 32`. Empty = the route 404s even with ENABLED=true: an
# unauthenticated endpoint that installs IP blocks is a denial-of-service
# lever, so there is deliberately no "open" mode.
REPUTATION_API_KEY=
# Whether a flagged lookup actually registers the block, or only classifies,
# records and answers. false is the dry-run equivalent for this path — the way
# to watch what it *would* pre-block before letting it act. The per-signal
# IP_SCRUTINY_DRY_RUN / CHECK_MAIL_DRY_RUN and IP_SCRUTINY_AUTO_IPBLOCK /
# CHECK_MAIL_AUTO_DOMAIN_BLOCK switches still apply on top of this.
REPUTATION_API_INSTALL_BLOCKS=true
# Ceiling on *fresh* (cache-missing) provider lookups per rolling hour.
# Mastodon calls this on every view of the signup form, not just on
# submission, so without a ceiling a crawler walking /auth/sign_up from a new
# range each time would set the ipapi.is / check-mail.org bill. Over budget an
# uncached value answers "unknown" and installs nothing — never "flagged".
# Cache hits are free and are never counted, and the webhook path never spends
# from this budget (account.created is authoritative and must not be starved).
# 0 = no ceiling.
REPUTATION_API_MAX_LOOKUPS_PER_HOUR=500
# DM MOD_ALERT_ACCT when this endpoint installs a block it has never installed
# before. Bounded by how many genuinely new bad ranges/domains appear, not by
# request volume: repeat hits on an already-installed block are silent.
REPUTATION_API_DM_MODERATOR=true