Only publish blocks that are still in force
docker-build-push / build-push (push) Successful in 4s

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.
This commit is contained in:
pmb committed 2026-09-13 01:05:52 -07:00
1 parent 8966de2a1d
commit 8490048d2d
6 files changed
+370 -9

No files matched your search

+87
View File
@@ -893,6 +893,92 @@ def blocks_api_tests():
main.BLOCKS_API_MAX_ITEMS = 5000
def reconcile_blocks_tests():
"""Drive app.reconcile_blocks.reconcile() against seeded rows with the
Mastodon admin list fetches stubbed out (no network). The important case
is the failure path: a 403 or an outage must never be read as "every
block was removed"."""
import app.reconcile_blocks as rec
# Seed: two blocked ranges and two blocked domains, all currently active.
main.record_signup_ip("950", "recone", "198.18.5.1", "datacenter", "X", True, "198.18.5.0/24")
main.mark_ipblock_registered("950")
main.record_signup_ip("951", "rectwo", "198.18.6.1", "datacenter", "X", True, "198.18.6.0/24")
main.mark_ipblock_registered("951")
main.record_signup_email("960", "recthree", "rec-blocked.test", True, 90, True)
main.mark_email_domain_blocked("960")
main.record_signup_email("961", "recfour", "rec-removed.test", True, 90, True)
main.mark_email_domain_blocked("961")
def active_ips():
return {e["cidr"] for e in main.blocked_ip_ranges()}
def active_domains():
return {e["domain"] for e in main.blocked_email_domains()}
assert "198.18.5.0/24" in active_ips() and "198.18.6.0/24" in active_ips()
assert "rec-removed.test" in active_domains()
# A. Mastodon no longer has 198.18.6.0/24 or rec-removed.test -> both drop
# out of what the API publishes; the survivors stay.
rec.live_ip_cidrs = lambda: {"198.18.5.0/24"}
rec.live_email_domains = lambda: {"rec-blocked.test"}
changed = rec.reconcile()
assert changed >= 2, changed
ips, domains = active_ips(), active_domains()
assert "198.18.5.0/24" in ips, "a block still present must stay published"
assert "198.18.6.0/24" not in ips, "a block removed in Mastodon must stop being published"
assert "rec-blocked.test" in domains
assert "rec-removed.test" not in domains
# B. It reappears (re-registered by hand, or the bot blocks it again) ->
# the row comes back rather than being stuck off.
rec.live_ip_cidrs = lambda: {"198.18.5.0/24", "198.18.6.0/24"}
rec.live_email_domains = lambda: {"rec-blocked.test", "rec-removed.test"}
rec.reconcile()
assert "198.18.6.0/24" in active_ips(), "a reappearing block must be republished"
assert "rec-removed.test" in active_domains()
# C. THE IMPORTANT ONE: the fetch fails (403 on a missing scope, or
# Mastodon down). Nothing may be marked inactive — an outage must not
# empty the published list.
rec.live_ip_cidrs = lambda: None
rec.live_email_domains = lambda: None
assert rec.reconcile() == 0, "a failed fetch must change nothing"
assert "198.18.5.0/24" in active_ips() and "198.18.6.0/24" in active_ips()
assert len(active_domains()) >= 2
# D. Half-failure: IP fetch works, domain fetch doesn't. Only the side
# that answered is reconciled.
rec.live_ip_cidrs = lambda: {"198.18.5.0/24"}
rec.live_email_domains = lambda: None
rec.reconcile()
assert "198.18.6.0/24" not in active_ips(), "the side that answered is reconciled"
assert "rec-removed.test" in active_domains(), "the side that failed is left alone"
# E. --dry-run reports without writing.
rec.live_ip_cidrs = lambda: set()
rec.live_email_domains = lambda: set()
assert rec.reconcile(dry_run=True) > 0
assert "198.18.5.0/24" in active_ips(), "dry-run must not change stored state"
# F. An expired block is treated as gone even though Mastodon still lists it.
future = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
assert rec._expired({"expires_at": past}) is True
assert rec._expired({"expires_at": future}) is False
assert rec._expired({"expires_at": None}) is False
assert rec._expired({}) is False
# G. A re-registration reactivates a row that had been reconciled away.
rec.live_ip_cidrs = lambda: set()
rec.live_email_domains = lambda: set()
rec.reconcile()
assert "198.18.5.0/24" not in active_ips()
main.mark_ipblock_registered("950")
assert "198.18.5.0/24" in active_ips(), "re-registering must reactivate the row"
if __name__ == "__main__":
routing_tests()
policy_tests()
@@ -904,4 +990,5 @@ if __name__ == "__main__":
suspicious_watch_tests()
suspicious_sweep_tests()
blocks_api_tests()
reconcile_blocks_tests()
print("ALL TESTS PASSED")