Files
yttrx-welcomebot/CLAUDE.md
T
waffle2k 656eec09c0 Add disposable/high-risk email signup scrutiny via check-mail.org
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.
2026-07-03 09:08:30 -07:00

232 lines
11 KiB
Markdown

# CLAUDE.md
Guidance for Claude Code when working on **yttrx-welcomebot** (the welcome bot
+ abuse auto-silence bot for yttrx.com).
## What this is / where it runs
A FastAPI webhook server. One Mastodon admin webhook delivers `account.created`,
`account.approved`, and `report.created` to `POST /webhook`; the app dispatches:
- **account.created** → classify the signup IP (RDAP org lookup); flagged
(datacenter/hosting) signups get more scrutiny (held welcome, ip_blocks
registration, DM to moderator) before falling through to a normal welcome.
- **account.approved** → always DM the welcome (dedup-safe; this is what
releases a held welcome once a human clears the approval queue).
- **report.created** → classify the reported account, count distinct reporters,
and auto-**silence** young/dormant accounts past the threshold — lowered for
accounts with a flagged signup IP (see README).
| Where | What |
|---|---|
| Workstation | Source of truth: `~/yttrx-welcomebot/` (this repo) |
| `git.blairhaus.net/yttrx-welcomebot.git` | `origin` remote (HTTP Basic Auth, `GIT_AUTH_USER`/`GIT_AUTH_PASS` in `~/docker/.env` on admin — same shared cred as every other git.blairhaus.net repo, embedded in the remote URL). No CI (`.ci/run.sh`) configured — deploy is a manual `git pull` on admin.yttrx.com, not push-triggered. |
| `admin.yttrx.com` | Deploy target: `/root/yttrx-welcomebot/` is a **git checkout** of the above (converted from an rsync-deployed tree 2026-07-02), Docker container `yttrx-welcomebot` on `127.0.0.1:8087`, nginx site `hooks``hooks.yttrx.com`. `ssh admin.yttrx.com` logs in as **root**. Compose is **`docker-compose` v1.29.2** (hyphenated, not `docker compose`). |
| `mammut` (`ssh mammut`) | The Mastodon instance. `tootctl`/Rails run inside the `live-web-1` container. Bot accounts + tokens live here. |
Full ops history is in secondbrain `yttrx-documentation/changelog.md`; the
user-memory note `project_welcomebot` has the current state.
## HARD RULES
- **Never overwrite the `.env` on `admin.yttrx.com`.** It holds the live
`WEBHOOK_SECRET`, `BOT_ACCESS_TOKEN`, and `ABUSE_BOT_TOKEN`. It is untracked
(gitignored) in the deploy checkout — `git pull`/`git reset` never touch it.
Edit it in place for new vars.
- **Never commit secrets.** `.env` is git- and docker-ignored. Tokens/passwords
must not land in this repo, the changelog, or CLAUDE.md.
- The bot **silences** (reversible), it does not suspend by default
(`ABUSE_ACTION=silence`). It applies the action **without** `report_id` so the
report stays open for human review.
- **`git` commands on admin.yttrx.com run as root** against a repo whose files
are `chown`ed to `waffles` (see below) — root's global config there already
has `safe.directory /root/yttrx-welcomebot` set to avoid the "dubious
ownership" error; don't need to re-add it, but if the repo is ever
reconstructed at that path from scratch, re-run
`git config --global --add safe.directory /root/yttrx-welcomebot`.
## Deploy procedure (workstation → admin.yttrx.com)
```bash
# 1. Test locally (offline, no network)
cd ~/yttrx-welcomebot
python3 -m venv .venv && . .venv/bin/activate && pip install -q -r requirements.txt
WEBHOOK_SECRET=testsecret BOT_ACCESS_TOKEN=x python test_local.py # -> ALL TESTS PASSED
# 2. Commit + push to origin (git.blairhaus.net)
git add -A && git commit -m "..."
git push origin main
# 3. Pull on admin.yttrx.com (.env is gitignored — untouched by pull/reset)
ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && git pull origin main'
# 4. Re-normalise ownership if new files were added (chown is not tracked by git)
ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && chown -R waffles:waffles . && \
chown root:root .env; chmod 600 .env'
# 5. If new env vars were added this release, set them IN PLACE on the box, e.g.
ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && \
grep -q "^NEW_VAR=" .env || echo "NEW_VAR=value" >> .env'
# 6. Rebuild + restart (named volume welcomebot-data persists the dedup db;
# the volume name is derived from the directory basename, which is
# unchanged, so it survived the rsync -> git-checkout conversion)
ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && docker-compose up -d --build'
```
Rollback is now `git checkout <previous-sha> -- .` (or `git revert`) instead of
restoring `.bak` files. `.env.bak-*` snapshots from past deploys remain on the box; the
full pre-git-conversion tree (`/root/yttrx-welcomebot.pre-git-*/`) was removed
2026-07-02 once the git checkout was verified identical.
### Verify
```bash
ssh admin.yttrx.com 'curl -s localhost:8087/healthz' # {"ok":true}
ssh admin.yttrx.com 'docker logs --tail 20 yttrx-welcomebot' # clean startup
# confirm the running image + config:
ssh admin.yttrx.com 'docker exec yttrx-welcomebot python -c \
"import app.main as m; print(m.ABUSE_DRY_RUN, m.ABUSE_ACTION, sorted(m.ABUSE_ALLOWLIST))"'
```
### Viewing logs
A `welcomebot-logs` CLI lives at `/root/bin/welcomebot-logs` on admin.yttrx.com
(source: `bin/welcomebot-logs` in this repo) — `/root/bin` is the homelab's
standard location for root-owned admin scripts (same convention as `bsd`'s
`peteftw-analytics` and the NAS backup scripts). A symlink at
`/usr/local/bin/welcomebot-logs` makes the bare command work over
non-interactive `ssh admin.yttrx.com welcomebot-logs`, since that doesn't
source `~/.bashrc` and so wouldn't otherwise see `/root/bin` on `PATH`. It
wraps `docker logs` for the container:
```bash
ssh admin.yttrx.com welcomebot-logs # last 200 lines
ssh admin.yttrx.com welcomebot-logs -f --abuse # follow abuse activity only
ssh admin.yttrx.com welcomebot-logs -n 1000 --since 24h
ssh admin.yttrx.com welcomebot-logs --ip --since 24h # signup-IP scrutiny decisions
ssh admin.yttrx.com 'welcomebot-logs --help'
```
### Inspecting signup-IP classification history
`welcomebot-signups` (`/root/bin/welcomebot-signups`, symlinked from
`/usr/local/bin` same as above; source `bin/welcomebot-signups`) reads the
persistent `signup_ip` sqlite table directly — a permanent record of every
classification decision, independent of log retention:
```bash
ssh admin.yttrx.com welcomebot-signups # last 20 decisions
ssh admin.yttrx.com welcomebot-signups --flagged # only datacenter-flagged signups
ssh admin.yttrx.com welcomebot-signups -a someuser
ssh admin.yttrx.com welcomebot-signups -n all
```
Both CLIs are just files tracked in this repo now — after editing either one,
reinstall with:
```bash
ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && git pull origin main && \
install -m755 bin/welcomebot-logs bin/welcomebot-signups /root/bin/'
# /usr/local/bin/welcomebot-{logs,signups} are symlinks to /root/bin — no
# need to touch them again once created.
```
### Rollback
```bash
ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && \
cp app/main.py.bak-YYYYMMDD app/main.py && cp .env.bak-YYYYMMDD .env && \
docker-compose up -d --build'
```
## Rollout safety: dry-run
`ABUSE_DRY_RUN=true` makes the abuse handler log + DM what it *would* do without
silencing anyone. The shipped `.env.example` default is `true`; **production is
currently `false` (live and acting)**. To re-enter dry-run:
```bash
ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && \
sed -i "s/^ABUSE_DRY_RUN=.*/ABUSE_DRY_RUN=true/" .env && docker-compose up -d'
```
## The moderator bot token (gotcha)
The abuse handler posts to `POST /api/v1/admin/accounts/:id/action`, which needs
the **`admin:write:accounts`** OAuth scope — *separate* from `admin:write:reports`.
The bot account (`bot`) is an **Admin** (so it has the role permissions), but a
token created in the UI may omit `admin:write:accounts` → silence returns `403`.
**IP-scrutiny's `register_ip_block` needs an additional scope,
`admin:write:ip_blocks`**, to `POST /api/v1/admin/ip_blocks` — mint (or re-mint)
the token with it included, or `IP_SCRUTINY_AUTO_IPBLOCK` writes will 403 (logged
as an error; nothing else in the bot is affected).
**Email-domain scrutiny's `register_email_domain_block` needs another scope,
`admin:write:email_domain_blocks`**, to `POST /api/v1/admin/email_domain_blocks`
— same deal: mint (or re-mint) the token with it included, or
`CHECK_MAIL_AUTO_DOMAIN_BLOCK` writes (both the signup-time path and the
report-triggered suspend override) will 403 (logged as an error; the suspend
itself still goes through since that only needs `admin:write:accounts`).
Mint a correctly-scoped token from the Rails console on mammut:
```bash
ssh mammut 'docker exec $(docker ps -f name=live-web-1 -q) bin/rails runner "
acct = Account.find_local(%q{bot})
scopes = %q{read:statuses write:statuses admin:read:reports admin:write:accounts admin:write:ip_blocks admin:write:email_domain_blocks}
app = Doorkeeper::Application.create!(name: %q{Abuse handler vN}, scopes: scopes, redirect_uri: %q{urn:ietf:wg:oauth:2.0:oob})
tok = Doorkeeper::AccessToken.create!(application_id: app.id, resource_owner_id: acct.user.id, scopes: scopes)
puts tok.token
"'
```
Put the result in `ABUSE_BOT_TOKEN` on the box's `.env`, restart, and revoke the
old token (`Doorkeeper::AccessToken.find(<id>).revoke`).
**Harmless write-permission probe** (no real account touched — `403` = missing
scope, `404` = authorized):
```bash
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
-H "Authorization: Bearer $ABUSE_BOT_TOKEN" -d "type=none" \
https://yttrx.com/api/v1/admin/accounts/0/action
```
Same idea for the `admin:write:ip_blocks` scope IP-scrutiny needs (`403` =
missing scope; `422` = authorized, just missing/invalid params — no block is
created either way):
```bash
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
-H "Authorization: Bearer $ABUSE_BOT_TOKEN" \
https://yttrx.com/api/v1/admin/ip_blocks
```
And for `admin:write:email_domain_blocks` (`403` = missing scope; `422` =
authorized, just missing/invalid params):
```bash
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
-H "Authorization: Bearer $ABUSE_BOT_TOKEN" \
https://yttrx.com/api/v1/admin/email_domain_blocks
```
## Mastodon side (mammut)
- The webhook (Administration → Webhooks) is already subscribed to
`account.created`, `account.approved`, `report.created` — no change needed for
code redeploys.
- Staff are never auto-silenced: `ABUSE_SKIP_PRIVILEGED=true` reads the payload
`target_account.role` (skips any assigned staff role, id > 0), plus the
explicit `ABUSE_ALLOWLIST`. Current staff: `waffles`/`tommertron` (Owner),
`davis`/`bot` (Admin).
## Appeals page
Silenced users are DMed `ABUSE_HELP_URL` =
`https://welcome.yttrx.com/posts/account-limited/`. That page is a Hugo
(Compost) content file at `admin.yttrx.com:/var/www/html/welcome/content/posts/`,
rebuilt with `./build.sh` in that dir (see secondbrain `misc-sites.md`).