16302ed309
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.
218 lines
9.0 KiB
Python
Executable File
218 lines
9.0 KiB
Python
Executable File
#!/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()
|