floonet-strfry: hardened strfry relay for the Grin community

Stock strfry + a default-deny write-policy plugin (kinds 0,3,5,13,1059,
10002,10050,27235 only), NIP-42 auth, neutral NIP-11, a bundled name
authority (paid names/uses via GoblinPay), and a config-toggled co-located
mixnet exit. Docker Compose + Caddy + hardened systemd. strfry core stays
stock (plugin + config only). Validated end to end against real strfry.
This commit is contained in:
Goblin
2026-07-02 08:20:30 -04:00
commit 16302ed309
40 changed files with 6786 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""floonet-strfry write policy: the modular event-admission plugin.
strfry streams one JSON request per line on stdin and expects one JSON reply
per line on stdout (see strfry docs/plugins.md). This plugin is the policy
layer of a Floonet relay: strfry core stays stock, and every admission rule
lives here as a small check function.
Checks run in order; the first rejection wins. All checks fail closed: any
malformed input, unexpected error, or unreachable dependency rejects the
event rather than letting it through.
1. kind whitelist default-deny; only FLOONET_ALLOWED_KINDS pass
2. auth requirement optional; with FLOONET_REQUIRE_AUTH=true an event is
rejected unless the connection completed NIP-42 AUTH
(also enable relay.auth in strfry.conf)
3. paid write gate optional; with FLOONET_PAY_MODE=write the AUTHed
pubkey must hold a confirmed payment grant, checked
against the bundled name authority (which talks to
GoblinPay); results are cached for a short TTL
NIP-42/NIP-70 note for checks 2 and 3: stock strfry (pinned ref) issues the
AUTH challenge when a client publishes a NIP-70 protected event (a `-` tag)
and attaches the authed pubkey to protected writes only, after enforcing
author == authed key. So with auth or paid-write enabled, clients publish
their events with a `-` tag: first attempt triggers the challenge, the
client AUTHs, then republishes. Verified end to end against strfry
b80cda3a812af1b662223edad47eb70b053508b6.
Configuration is environment variables (set them on the strfry process; the
plugin inherits them, e.g. via docker compose or the systemd unit):
FLOONET_ALLOWED_KINDS comma-separated kind whitelist
[default: 0,3,5,13,1059,10002,10050,27235]
FLOONET_REQUIRE_AUTH true/false [default: false]
FLOONET_PAY_MODE off|name|write [default: off]
(only "write" changes plugin behavior; "name" is
enforced by the name authority itself)
FLOONET_AUTHORITY_URL base URL of the bundled name authority
[default: http://authority:8191]
FLOONET_PAID_CACHE_SECS TTL for cached paid-status lookups [default: 60]
To add a kind: edit FLOONET_ALLOWED_KINDS and restart (or touch the plugin
file; strfry reloads it on mtime change). To add a policy: write a function
`def check_foo(req, cfg): return None or "reject reason"` and append it to
CHECKS. To replace the whole policy: point relay.writePolicy.plugin at your
own executable.
"""
import json
import os
import sys
import time
import urllib.request
DEFAULT_ALLOWED_KINDS = "0,3,5,13,1059,10002,10050,27235"
def load_config(env=os.environ):
"""Parse plugin configuration from environment variables. Malformed
values fail fast at startup (never silently widen the policy)."""
kinds_raw = env.get("FLOONET_ALLOWED_KINDS", DEFAULT_ALLOWED_KINDS)
try:
allowed = frozenset(int(k) for k in kinds_raw.split(",") if k.strip())
except ValueError:
raise SystemExit(
"floonet-writepolicy: FLOONET_ALLOWED_KINDS must be a comma-"
"separated list of integers, got %r" % kinds_raw
)
if not allowed:
raise SystemExit("floonet-writepolicy: FLOONET_ALLOWED_KINDS is empty")
pay_mode = env.get("FLOONET_PAY_MODE", "off").strip().lower()
if pay_mode not in ("off", "name", "write"):
raise SystemExit(
"floonet-writepolicy: FLOONET_PAY_MODE must be off, name or "
"write, got %r" % pay_mode
)
return {
"allowed_kinds": allowed,
"require_auth": env.get("FLOONET_REQUIRE_AUTH", "false").strip().lower()
in ("1", "true", "yes", "on"),
"pay_mode": pay_mode,
"authority_url": env.get(
"FLOONET_AUTHORITY_URL", "http://authority:8191"
).rstrip("/"),
"paid_cache_secs": float(env.get("FLOONET_PAID_CACHE_SECS", "60")),
}
# --- checks (each returns None to pass or a rejection message) ---
def check_kind(req, cfg):
"""The keystone: default-deny kind whitelist. Anything not explicitly
allowed is rejected, including a missing or non-integer kind."""
kind = req.get("event", {}).get("kind")
# bool is an int subclass in Python; a JSON true/false kind is malformed.
if not isinstance(kind, int) or isinstance(kind, bool):
return "blocked: malformed event kind"
if kind not in cfg["allowed_kinds"]:
return "blocked: event kind not accepted by this relay"
return None
def check_auth(req, cfg):
"""Optional NIP-42 requirement: reject events from connections that have
not completed AUTH. strfry only includes `authed` after a valid kind-22242
flow, so presence of a well-formed pubkey is the proof."""
if not cfg["require_auth"]:
return None
authed = req.get("authed")
if not isinstance(authed, str) or len(authed) != 64:
return "auth-required: publish after NIP-42 AUTH"
return None
# paid-status cache: pubkey -> (paid: bool, expires_at: float)
_paid_cache = {}
def _paid_lookup(cfg, pubkey):
"""Ask the bundled name authority whether this pubkey holds a confirmed
write grant. The authority owns the GoblinPay conversation; the plugin
only reads the verdict. Raises on any transport/parse problem."""
url = "%s/api/v1/paid/%s" % (cfg["authority_url"], pubkey)
with urllib.request.urlopen(url, timeout=3) as resp:
body = json.loads(resp.read().decode("utf-8"))
return bool(body.get("paid"))
def check_paid(req, cfg, now=time.monotonic):
"""Optional pay-to-write gate. Requires an AUTHed pubkey (payment grants
are keyed by pubkey), then requires a confirmed grant. Unreachable
authority = reject (fail closed), with a short negative-cache so a dead
authority cannot be hammered once per event."""
if cfg["pay_mode"] != "write":
return None
authed = req.get("authed")
if not isinstance(authed, str) or len(authed) != 64:
return "auth-required: paid publishing needs NIP-42 AUTH"
cached = _paid_cache.get(authed)
t = now()
if cached is not None and cached[1] > t:
paid = cached[0]
else:
try:
paid = _paid_lookup(cfg, authed)
_paid_cache[authed] = (paid, t + cfg["paid_cache_secs"])
except Exception as e:
sys.stderr.write("floonet-writepolicy: paid lookup failed: %s\n" % e)
sys.stderr.flush()
# Negative-cache briefly, then fail closed.
_paid_cache[authed] = (False, t + min(cfg["paid_cache_secs"], 10.0))
return "blocked: payment status unavailable"
if not paid:
return "blocked: payment required to publish on this relay"
return None
CHECKS = [check_kind, check_auth, check_paid]
def decide(req, cfg):
"""Map one plugin request to an accept/reject reply. Fails closed on any
structurally unexpected input rather than trusting it. The checks apply
to every request type: strfry currently only sends type "new" (including
for sync ingest), and checking unconditionally means a future type can
never slip an unwanted event past the policy."""
event = req.get("event")
if not isinstance(event, dict):
return {"id": "", "action": "reject", "msg": "bad event structure"}
event_id = event.get("id")
if not isinstance(event_id, str):
event_id = ""
for check in CHECKS:
try:
msg = check(req, cfg)
except Exception as e:
sys.stderr.write("floonet-writepolicy: %s failed: %s\n" % (check.__name__, e))
sys.stderr.flush()
msg = "policy error"
if msg is not None:
return {"id": event_id, "action": "reject", "msg": msg}
return {"id": event_id, "action": "accept", "msg": ""}
def main():
cfg = load_config()
sys.stderr.write(
"floonet-writepolicy: allowed kinds %s, require_auth=%s, pay_mode=%s\n"
% (sorted(cfg["allowed_kinds"]), cfg["require_auth"], cfg["pay_mode"])
)
sys.stderr.flush()
# Use readline() in a loop rather than iterating stdin: the protocol is
# synchronous (strfry blocks waiting for each reply), so the iterator's
# read-ahead buffer must never stall the exchange. Flush every reply.
while True:
line = sys.stdin.readline()
if not line:
break # strfry closed stdin (shutdown/restart); exit cleanly.
line = line.strip()
if not line:
continue
try:
reply = decide(json.loads(line), cfg)
except Exception as e:
# A malformed request must never crash the loop and take the
# relay's write path down with it. Fail closed and log.
sys.stderr.write("floonet-writepolicy: %s\n" % e)
sys.stderr.flush()
reply = {"id": "", "action": "reject", "msg": "policy error"}
sys.stdout.write(json.dumps(reply) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Tests for the floonet write policy.
Run from the plugin directory: python3 test_policy.py
Two layers:
* unit tests over decide()/the check functions (whitelist, auth, paid,
fail-closed behavior), with the paid lookup stubbed by a real local HTTP
server standing in for the name authority;
* a subprocess pipe test that runs the plugin exactly the way strfry does
(JSONL on stdin, JSONL on stdout) and asserts accept/reject decisions.
"""
import json
import os
import subprocess
import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
import floonet_writepolicy as wp
PLUGIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "floonet_writepolicy.py")
PK = "a" * 64
DEFAULT_KINDS = (0, 3, 5, 13, 1059, 10002, 10050, 27235)
def req(kind, authed=None, event_id="e1"):
"""A request shaped exactly like strfry's plugin input."""
r = {
"type": "new",
"event": {"id": event_id, "pubkey": PK, "kind": kind, "tags": [], "content": ""},
"receivedAt": 1700000000,
"sourceType": "IP4",
"sourceInfo": "203.0.113.7",
}
if authed is not None:
r["authed"] = authed
return r
def cfg(**over):
base = wp.load_config(env={})
base.update(over)
return base
class KindWhitelist(unittest.TestCase):
def test_default_allowed_kinds_accepted(self):
for kind in DEFAULT_KINDS:
reply = wp.decide(req(kind), cfg())
self.assertEqual(reply["action"], "accept", "kind %d" % kind)
self.assertEqual(reply["id"], "e1")
def test_disallowed_kinds_rejected(self):
for kind in (1, 4, 6, 7, 14, 1058, 1060, 30023, 22242, -1):
reply = wp.decide(req(kind), cfg())
self.assertEqual(reply["action"], "reject", "kind %d" % kind)
self.assertIn("kind not accepted", reply["msg"])
def test_malformed_kind_fails_closed(self):
for bad in (None, "1059", 3.5, True, [1059]):
r = req(0)
r["event"]["kind"] = bad
self.assertEqual(wp.decide(r, cfg())["action"], "reject", repr(bad))
def test_missing_or_bad_event_fails_closed(self):
self.assertEqual(wp.decide({"type": "new"}, cfg())["action"], "reject")
self.assertEqual(wp.decide({"event": "nope"}, cfg())["action"], "reject")
def test_custom_kind_list_env(self):
c = wp.load_config(env={"FLOONET_ALLOWED_KINDS": "1,7"})
self.assertEqual(wp.decide(req(1), c)["action"], "accept")
self.assertEqual(wp.decide(req(0), c)["action"], "reject")
def test_empty_or_garbage_kind_list_refused_at_startup(self):
with self.assertRaises(SystemExit):
wp.load_config(env={"FLOONET_ALLOWED_KINDS": ""})
with self.assertRaises(SystemExit):
wp.load_config(env={"FLOONET_ALLOWED_KINDS": "0,x"})
class AuthRequirement(unittest.TestCase):
def test_off_by_default(self):
self.assertEqual(wp.decide(req(1059), cfg())["action"], "accept")
def test_unauthed_rejected_when_required(self):
c = cfg(require_auth=True)
reply = wp.decide(req(1059), c)
self.assertEqual(reply["action"], "reject")
self.assertIn("auth-required", reply["msg"])
def test_authed_accepted_when_required(self):
c = cfg(require_auth=True)
self.assertEqual(wp.decide(req(1059, authed=PK), c)["action"], "accept")
def test_malformed_authed_rejected(self):
c = cfg(require_auth=True)
self.assertEqual(wp.decide(req(1059, authed="short"), c)["action"], "reject")
class _Authority(BaseHTTPRequestHandler):
"""Stub name authority: /api/v1/paid/<paid-pubkey> answers paid."""
paid_pubkeys = set()
fail = False
def do_GET(self):
if _Authority.fail:
self.send_response(500)
self.end_headers()
return
pk = self.path.rsplit("/", 1)[-1]
body = json.dumps({"pubkey": pk, "paid": pk in _Authority.paid_pubkeys})
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body.encode())
def log_message(self, *a):
pass
class PaidWriteGate(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = HTTPServer(("127.0.0.1", 0), _Authority)
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
cls.url = "http://127.0.0.1:%d" % cls.server.server_port
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
def setUp(self):
wp._paid_cache.clear()
_Authority.paid_pubkeys = set()
_Authority.fail = False
def c(self):
return cfg(pay_mode="write", authority_url=self.url, paid_cache_secs=60.0)
def test_unauthed_rejected_in_write_mode(self):
reply = wp.decide(req(1059), self.c())
self.assertEqual(reply["action"], "reject")
self.assertIn("auth-required", reply["msg"])
def test_unpaid_pubkey_rejected(self):
reply = wp.decide(req(1059, authed=PK), self.c())
self.assertEqual(reply["action"], "reject")
self.assertIn("payment required", reply["msg"])
def test_paid_pubkey_accepted(self):
_Authority.paid_pubkeys = {PK}
self.assertEqual(wp.decide(req(1059, authed=PK), self.c())["action"], "accept")
def test_verdict_cached_within_ttl(self):
_Authority.paid_pubkeys = {PK}
c = self.c()
self.assertEqual(wp.decide(req(1059, authed=PK), c)["action"], "accept")
# Authority flips to unpaid, but the cached verdict still applies.
_Authority.paid_pubkeys = set()
self.assertEqual(wp.decide(req(1059, authed=PK), c)["action"], "accept")
# Once the cache expires the fresh (unpaid) verdict is used.
wp._paid_cache[PK] = (True, 0.0)
self.assertEqual(wp.decide(req(1059, authed=PK), c)["action"], "reject")
def test_authority_down_fails_closed(self):
_Authority.fail = True
reply = wp.decide(req(1059, authed=PK), self.c())
self.assertEqual(reply["action"], "reject")
self.assertIn("payment status unavailable", reply["msg"])
def test_kind_check_still_first_in_write_mode(self):
_Authority.paid_pubkeys = {PK}
reply = wp.decide(req(1, authed=PK), self.c())
self.assertEqual(reply["action"], "reject")
self.assertIn("kind not accepted", reply["msg"])
class StrfryPipeProtocol(unittest.TestCase):
"""Run the plugin as strfry does: one JSONL request per line on stdin,
one JSONL reply per line on stdout, in order."""
def run_plugin(self, lines, env=None):
e = {"PATH": os.environ.get("PATH", ""), "FLOONET_PAY_MODE": "off"}
if env:
e.update(env)
proc = subprocess.run(
[sys.executable, PLUGIN],
input="".join(json.dumps(l) + "\n" for l in lines),
capture_output=True,
text=True,
timeout=30,
env=e,
)
self.assertEqual(proc.returncode, 0, proc.stderr)
return [json.loads(out) for out in proc.stdout.splitlines()]
def test_accept_and_reject_over_the_wire(self):
replies = self.run_plugin([req(1059, event_id="ok1"), req(1, event_id="no1"), req(0, event_id="ok2")])
self.assertEqual(
[(r["id"], r["action"]) for r in replies],
[("ok1", "accept"), ("no1", "reject"), ("ok2", "reject" if 0 not in DEFAULT_KINDS else "accept")],
)
def test_malformed_line_fails_closed_and_loop_survives(self):
proc = subprocess.run(
[sys.executable, PLUGIN],
input="this is not json\n" + json.dumps(req(1059, event_id="after")) + "\n",
capture_output=True,
text=True,
timeout=30,
env={"PATH": os.environ.get("PATH", "")},
)
self.assertEqual(proc.returncode, 0)
replies = [json.loads(out) for out in proc.stdout.splitlines()]
self.assertEqual(replies[0]["action"], "reject")
self.assertEqual((replies[1]["id"], replies[1]["action"]), ("after", "accept"))
def test_env_whitelist_respected_over_the_wire(self):
replies = self.run_plugin(
[req(1, event_id="now-ok")], env={"FLOONET_ALLOWED_KINDS": "1"}
)
self.assertEqual((replies[0]["id"], replies[0]["action"]), ("now-ok", "accept"))
if __name__ == "__main__":
unittest.main(verbosity=2)