docker-build-push / build-push (push) Successful in 4s
A relative bound has to be datetime('now', '-24 hours'); datetime('-24 hours')
returns NULL, so the comparison was false for every row and --since silently
reported nothing rather than erroring. Caught when a --since '-10 minutes'
came back empty for queries that had demonstrably just happened.
Absolute forms ('2026-09-13') still take the single-argument path.
213 lines
7.7 KiB
Bash
Executable File
213 lines
7.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# welcomebot-reputation — inspect the pre-signup reputation query API's activity.
|
|
#
|
|
# Installed at /usr/local/bin/welcomebot-reputation on admin.yttrx.com.
|
|
# Source of truth: ~/yttrx-welcomebot/bin/welcomebot-reputation (workstation).
|
|
#
|
|
# Reads the persistent `reputation_query` (every answer the API gave) and
|
|
# `reputation_block` (every block it installed) sqlite tables, so this survives
|
|
# log rotation — see app/main.py's reputation_check / README's "Reputation
|
|
# query API". For the raw log lines instead, use `welcomebot-logs --reputation`.
|
|
set -euo pipefail
|
|
|
|
CONTAINER="${WELCOMEBOT_CONTAINER:-yttrx-welcomebot}"
|
|
limit=20
|
|
mode="queries"
|
|
filter="all"
|
|
kind_filter=""
|
|
since=""
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: welcomebot-reputation [options]
|
|
|
|
Show what the pre-signup reputation API has been asked and what it blocked,
|
|
newest first. This is the pre-emptive path (Mastodon asking before it creates
|
|
an account); for the reactive signup path use welcomebot-signups.
|
|
|
|
Options:
|
|
-n, --limit N Show the last N rows (default: $limit; 'all' for everything).
|
|
--blocks List installed blocks instead of queries (what is in force).
|
|
--stats Summary: volume, flag/block rate, cache hit rate, deferrals.
|
|
--flagged Queries only: just the ones that came back flagged.
|
|
--blocked Queries only: just the ones that installed or hit a block.
|
|
--deferred Queries only: just the ones refused by the hourly budget.
|
|
--ip Only the IP side.
|
|
--email Only the email-domain side.
|
|
--since WHEN Only rows newer than WHEN (SQL datetime, e.g. '2026-09-13'
|
|
or '-24 hours').
|
|
-h, --help Show this help.
|
|
|
|
Examples:
|
|
welcomebot-reputation # last $limit answers
|
|
welcomebot-reputation --stats # is it earning its keep?
|
|
welcomebot-reputation --blocks # what it currently has blocked
|
|
welcomebot-reputation --blocked --since '-24 hours'
|
|
welcomebot-reputation --deferred # did the budget ceiling bite?
|
|
EOF
|
|
}
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-n|--limit) shift; limit="${1:?--limit needs a value}" ;;
|
|
--blocks) mode="blocks" ;;
|
|
--stats) mode="stats" ;;
|
|
--flagged) filter="flagged" ;;
|
|
--blocked) filter="blocked" ;;
|
|
--deferred) filter="deferred" ;;
|
|
--ip) kind_filter="ip" ;;
|
|
--email) kind_filter="email" ;;
|
|
--since) shift; since="${1:?--since needs a value}" ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) echo "welcomebot-reputation: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
if ! command -v docker >/dev/null 2>&1; then
|
|
echo "welcomebot-reputation: docker not found in PATH" >&2; exit 1
|
|
fi
|
|
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
|
|
echo "welcomebot-reputation: container '$CONTAINER' not found (is it deployed/running?)" >&2; exit 1
|
|
fi
|
|
|
|
docker exec -i \
|
|
-e REP_LIMIT="$limit" \
|
|
-e REP_MODE="$mode" \
|
|
-e REP_FILTER="$filter" \
|
|
-e REP_KIND="$kind_filter" \
|
|
-e REP_SINCE="$since" \
|
|
"$CONTAINER" python3 <<'PYEOF'
|
|
import os
|
|
import sqlite3
|
|
|
|
conn = sqlite3.connect("/data/welcomed.db")
|
|
mode = os.environ.get("REP_MODE", "queries")
|
|
kind = os.environ.get("REP_KIND", "")
|
|
since = os.environ.get("REP_SINCE", "")
|
|
limit = os.environ.get("REP_LIMIT", "20")
|
|
|
|
|
|
def limited(sql):
|
|
return sql if limit == "all" else f"{sql} LIMIT {int(limit)}"
|
|
|
|
|
|
def since_expr(column):
|
|
"""SQL for the --since bound.
|
|
|
|
A leading +/- means "relative to now" — sqlite needs that spelled
|
|
datetime('now', '-24 hours'), NOT datetime('-24 hours'), which returns
|
|
NULL and silently matches no rows.
|
|
"""
|
|
if since.startswith(("+", "-")):
|
|
return f"{column} >= datetime('now', ?)"
|
|
return f"{column} >= datetime(?)"
|
|
|
|
|
|
def tables_exist():
|
|
names = {r[0] for r in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table'")}
|
|
return {"reputation_query", "reputation_block"} <= names
|
|
|
|
|
|
if not tables_exist():
|
|
print("(no reputation tables yet — the query API has never been called on "
|
|
"this deployment)")
|
|
raise SystemExit(0)
|
|
|
|
if mode == "stats":
|
|
where, params = [], []
|
|
if since:
|
|
where.append(since_expr("queried_at"))
|
|
params.append(since)
|
|
clause = (" WHERE " + " AND ".join(where)) if where else ""
|
|
row = conn.execute(
|
|
"SELECT COUNT(*),"
|
|
" SUM(flagged), SUM(blocked),"
|
|
" SUM(CASE WHEN classification = 'unknown' THEN 1 ELSE 0 END),"
|
|
" MIN(queried_at), MAX(queried_at)"
|
|
f" FROM reputation_query{clause}", params).fetchone()
|
|
total, flagged, blocked, unknown, first, last = row
|
|
total = total or 0
|
|
if not total:
|
|
print("(no queries in range)")
|
|
raise SystemExit(0)
|
|
|
|
def pct(n):
|
|
return f"{(n or 0) / total * 100:5.1f}%"
|
|
|
|
print(f"queries {total:>7} {first} .. {last}")
|
|
print(f" flagged {flagged or 0:>7} {pct(flagged)}")
|
|
print(f" led to a block {blocked or 0:>7} {pct(blocked)}")
|
|
print(f" unknown {unknown or 0:>7} {pct(unknown)} "
|
|
"(lookup failed, or refused by the hourly budget)")
|
|
print()
|
|
print("by source:")
|
|
for source, n, nb in conn.execute(
|
|
"SELECT COALESCE(source, '-'), COUNT(*), SUM(blocked) "
|
|
f"FROM reputation_query{clause} GROUP BY 1 ORDER BY 2 DESC", params):
|
|
print(f" {source:24s} {n:>7} queries, {nb or 0:>5} blocked")
|
|
print()
|
|
print("blocks installed (still in force):")
|
|
for kind_, n in conn.execute(
|
|
"SELECT kind, COUNT(*) FROM reputation_block "
|
|
"WHERE block_active = 1 AND (expires_at IS NULL OR expires_at > "
|
|
" strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now')) GROUP BY 1"):
|
|
print(f" {kind_:24s} {n:>7}")
|
|
raise SystemExit(0)
|
|
|
|
if mode == "blocks":
|
|
where, params = [], []
|
|
if kind:
|
|
where.append("kind = ?")
|
|
params.append(kind)
|
|
if since:
|
|
where.append(since_expr("first_blocked_at"))
|
|
params.append(since)
|
|
clause = (" WHERE " + " AND ".join(where)) if where else ""
|
|
rows = conn.execute(limited(
|
|
"SELECT kind, target, classification, org, queries, first_blocked_at,"
|
|
" last_seen_at, block_active, expires_at"
|
|
f" FROM reputation_block{clause} ORDER BY last_seen_at DESC"), params).fetchall()
|
|
if not rows:
|
|
print("(no blocks installed by the query API)")
|
|
for k, target, cls, org, queries, first, last, active, expires in rows:
|
|
state = "active" if active else "GONE"
|
|
exp = f"expires {expires}" if expires else "no expiry"
|
|
print(f"{last} {k:6s} {target:22s} {state:7s} {exp:34s} "
|
|
f"hits={queries:<5} {cls or ''} {org or ''}".rstrip())
|
|
raise SystemExit(0)
|
|
|
|
# mode == "queries"
|
|
where, params = [], []
|
|
filt = os.environ.get("REP_FILTER", "all")
|
|
if filt == "flagged":
|
|
where.append("flagged = 1")
|
|
elif filt == "blocked":
|
|
where.append("blocked = 1")
|
|
elif filt == "deferred":
|
|
where.append("classification = 'unknown'")
|
|
if kind:
|
|
where.append("kind = ?")
|
|
params.append(kind)
|
|
if since:
|
|
where.append(since_expr("queried_at"))
|
|
params.append(since)
|
|
clause = (" WHERE " + " AND ".join(where)) if where else ""
|
|
|
|
rows = conn.execute(limited(
|
|
"SELECT queried_at, kind, value, classification, org, risk, flagged,"
|
|
" target, blocked, source"
|
|
f" FROM reputation_query{clause} ORDER BY id DESC"), params).fetchall()
|
|
if not rows:
|
|
print("(no matching reputation queries)")
|
|
for ts, k, value, cls, org, risk, flagged, target, blocked, source in rows:
|
|
flag = "FLAGGED" if flagged else "-"
|
|
blk = "BLOCKED" if blocked else "-"
|
|
extra = org or ""
|
|
if k == "email" and risk:
|
|
extra = f"risk={risk}"
|
|
print(f"{ts} {k:6s} {value or '':24s} {cls or '':22s} {flag:8s} {blk:8s} "
|
|
f"{target or '':22s} {source or '-':16s} {extra}".rstrip())
|
|
PYEOF
|