docker-build-push / build-push (push) Successful in 5s
Blocks were registered permanently. An IP block is a claim about who is currently behind an address, and that goes stale: VPS addresses are recycled between customers constantly, and now that the /24 ceiling means most blocks cover a single address, a permanent block increasingly punishes whoever gets that address next rather than the actor we saw. 24h is long enough to break a signup run -- the abuse arrives in bursts, not as a steady trickle -- and short enough that the blocklist stays a live picture rather than something that only ever grows. Mastodon expires these itself and reconcile_blocks already reads an elapsed expires_at as "gone", so expired blocks leave /api/blocks unaided. The one piece that needed adding is an expiry on our own dedup record: reconcile runs hourly, so between a block lapsing and the next pass, reputation_block_ installed would otherwise claim a block Mastodon had already dropped and skip re-installing it. expires_in is omitted rather than sent as 0 when the expiry is switched off. Email domain blocks have no expiry in Mastodon and are untouched.
356 lines
18 KiB
Bash
356 lines
18 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.
|
|
# How long a registered Admin::IpBlock lasts, in seconds. 0 = never expires.
|
|
#
|
|
# An IP block is a statement about who is *currently* behind an address, and
|
|
# that goes stale: VPS addresses get recycled between customers constantly,
|
|
# and with the /24 ceiling below most blocks now cover a single address, so a
|
|
# permanent block increasingly punishes whoever gets that address next rather
|
|
# than the actor we saw. 24h (86400) breaks a signup run -- which is what the
|
|
# abuse actually looks like, bursts rather than a steady trickle -- and keeps
|
|
# the blocklist a live picture instead of something that only accumulates.
|
|
#
|
|
# Mastodon expires these itself (Scheduler::IpCleanupScheduler) and
|
|
# app/reconcile_blocks.py treats an elapsed expires_at as "gone", so an
|
|
# expired block drops out of /api/blocks without anyone doing anything.
|
|
# Email domain blocks have no expiry in Mastodon and are unaffected.
|
|
IP_SCRUTINY_IPBLOCK_EXPIRES_IN=86400
|
|
|
|
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
|