65ec8fcef2
Public-note kinds are now accepted only from an operator-configured list of
author pubkeys (FLOONET_AUTHORIZED_AUTHORS, hex or npub). Closed by default:
with no authors set, kinds 1 and 30023 are rejected for everyone, so random
notes cannot be spammed to the relay. Every other kind is unaffected and
kind 0 profiles stay open.
- add 30023 (long-form article) to the default kind whitelist
- check_authorized_authors runs right after check_kind; LOCKED_KINDS {1,30023}
- pure-python bech32 npub decoder (no new deps); invalid entries logged/skipped
- load_config also reads a KEY=VALUE floonet.env next to the plugin
(FLOONET_ENV_FILE), env vars win, so config changes need no container
recreate; strfry reloads the plugin on mtime change (no restart)
- extend test_policy.py (32 tests green)
- scripts/purge_public_notes.sh (dry-run default) to clean pre-existing notes
- scripts/smoke_test_lockdown.py post-deploy check
- README + .env.example config docs
320 lines
15 KiB
Markdown
320 lines
15 KiB
Markdown
# floonet-strfry
|
|
|
|
A hardened, easy-to-deploy [strfry](https://github.com/hoytech/strfry) relay
|
|
package for **Floonet**, the network of Nostr relays for the Grin community.
|
|
Anyone can run one, and anyone can run a name authority on it so people can
|
|
claim (and optionally pay GRIN for) a `name@domain` identity.
|
|
|
|
strfry core ships **stock**: the upstream C++ source is cloned at a pinned
|
|
commit and compiled unmodified. Everything Floonet-specific is layered on
|
|
through strfry's own extension points:
|
|
|
|
| Piece | What it is |
|
|
| --- | --- |
|
|
| `plugin/floonet_writepolicy.py` | The write policy plugin: default-deny kind whitelist, optional NIP-42 gate, optional paid-write gate |
|
|
| `name-authority/` | The bundled name authority (Rust/axum/SQLite): NIP-05 resolution, NIP-98 self-service registration, optional GoblinPay paywall — co-located on the relay's own domain by default |
|
|
| `deploy/tor/` | An optional Tor onion service so wallets can reach this relay over Tor without a Tor exit hop |
|
|
| `deploy/` | strfry conf + Dockerfile + apply-spec.sh, Caddy TLS proxy, landing page, hardened systemd units |
|
|
|
|
## Deploy
|
|
|
|
Pick your comfort level. All three paths produce the same relay.
|
|
|
|
### 1. Docker Compose (recommended)
|
|
|
|
One command brings up the whole unit: relay + name authority + auto-TLS
|
|
proxy (and, if enabled, a Tor onion).
|
|
|
|
```sh
|
|
cp .env.example .env # set FLOONET_DOMAIN, FLOONET_BASE_URL, FLOONET_RELAYS
|
|
docker compose up -d
|
|
```
|
|
|
|
DNS for `FLOONET_DOMAIN` must already point at the host; Caddy obtains the
|
|
certificate on first start. That is all a free relay needs.
|
|
|
|
### 2. apply-spec.sh + systemd (no Docker)
|
|
|
|
Builds stock strfry at the pinned ref and lays the Floonet conf + plugin on
|
|
top:
|
|
|
|
```sh
|
|
./deploy/strfry/apply-spec.sh # needs a C++ toolchain + strfry's libs
|
|
cd name-authority && cargo build --release
|
|
```
|
|
|
|
Then install the hardened units from `deploy/systemd/` (each unit's header
|
|
has the exact install commands): `floonet-strfry.service` and
|
|
`floonet-authority.service`. Put Caddy or nginx in front (see
|
|
`deploy/Caddyfile`); the proxy MUST set `X-Real-IP`, the authority's rate
|
|
limiting keys off it. To also front the relay with a Tor onion, run a system
|
|
tor with the snippet in `deploy/tor/torrc` (see "Tor onion" below).
|
|
|
|
### 3. From source (developers)
|
|
|
|
`deploy/strfry/Dockerfile` and `apply-spec.sh` document the strfry build
|
|
exactly; the authority is a plain `cargo build` crate; the plugin is a single
|
|
Python file with no dependencies. `plugin/test_policy.py` and `cargo test` in
|
|
`name-authority/` run the test suites.
|
|
|
|
## The kind whitelist (the keystone)
|
|
|
|
The relay is **default-deny**: the write policy rejects every event whose
|
|
kind is not explicitly allowed, at every ingest path (client publishes and
|
|
negentropy sync alike), failing closed on anything malformed. The shipped
|
|
set is exactly what the Goblin wallet uses:
|
|
|
|
| Kind | Meaning |
|
|
| --- | --- |
|
|
| 0 | profile metadata |
|
|
| 3 | contact list |
|
|
| 5 | deletion (NIP-09) |
|
|
| 13 | seal (NIP-59) |
|
|
| 1059 | gift wrap (NIP-59) |
|
|
| 10002 | relay list (NIP-65) |
|
|
| 10050 | DM relays (NIP-17) |
|
|
| 27235 | HTTP auth (NIP-98) |
|
|
|
|
To accept another kind, edit `FLOONET_ALLOWED_KINDS` in `.env` and restart
|
|
the relay. Nothing else changes.
|
|
|
|
## Public notes are author-locked
|
|
|
|
Public-note kinds (`1` text notes, `30023` long-form articles) are accepted
|
|
**only** from an operator-chosen list of authors. This is closed by default:
|
|
with no authors configured, kinds `1` and `30023` are rejected for everyone,
|
|
so random notes cannot be spammed to your relay. Everything else (profiles,
|
|
gift wraps, marketplace listings, lists, ephemeral events) is unaffected, and
|
|
kind `0` profiles stay open so wallets can republish them.
|
|
|
|
You decide who can post. List the authors in `FLOONET_AUTHORIZED_AUTHORS`,
|
|
comma-separated, each entry a hex pubkey or an npub (your choice):
|
|
|
|
```sh
|
|
FLOONET_AUTHORIZED_AUTHORS=npub1abc...,fd3a...hex...,npub1def...
|
|
```
|
|
|
|
Invalid entries are logged to stderr and skipped; the rest still apply.
|
|
|
|
### Changing authors without recreating the container
|
|
|
|
Where the container's environment cannot be changed without recreating it,
|
|
drop a plain `KEY=VALUE` file named `floonet.env` next to the plugin script
|
|
(override the path with `FLOONET_ENV_FILE`) and set the same keys there:
|
|
|
|
```sh
|
|
# /usr/local/bin/floonet.env
|
|
FLOONET_AUTHORIZED_AUTHORS=npub1abc...,npub1def...
|
|
```
|
|
|
|
Real environment variables take precedence over the file. strfry reloads the
|
|
plugin whenever the script's modification time changes, so after editing
|
|
`floonet.env` just `touch` the plugin script and the next write picks up the
|
|
new list. No relay or container restart is needed.
|
|
|
|
## Authentication (NIP-42), optional
|
|
|
|
Set `FLOONET_REQUIRE_AUTH=true` in `.env` and flip `relay.auth.enabled` to
|
|
`true` in `deploy/strfry/strfry.conf`. strfry then issues AUTH challenges
|
|
and validates the kind-22242 responses; the plugin rejects writes from
|
|
unauthenticated connections with an `auth-required:` message.
|
|
|
|
Client flow on stock strfry (pinned ref): publish events with a NIP-70 `-`
|
|
tag. The first protected publish triggers the AUTH challenge; the client
|
|
answers with a signed kind-22242 event and republishes. strfry enforces that
|
|
the event author is the authenticated key and hands that key to the plugin.
|
|
|
|
## Charge GRIN for your relay
|
|
|
|
Getting paid is editing a few `.env` keys; prices are yours to set and
|
|
change, no code involved. You need a running
|
|
[GoblinPay](https://code.gri.mw/GRIN/GoblinPay) server (your own payment
|
|
processor; it holds the wallet, produces payment proofs, and hosts the pay
|
|
pages).
|
|
|
|
```sh
|
|
FLOONET_PAY_MODE=name # or: write
|
|
FLOONET_NAME_PRICE_GRIN=1.5 # what a name costs, in GRIN
|
|
GOBLINPAY_URL=https://pay.your.domain
|
|
GOBLINPAY_TOKEN=<GP_API_TOKEN from your GoblinPay>
|
|
```
|
|
|
|
Modes:
|
|
|
|
- `off`: everything free (default).
|
|
- `name`: claiming `name@domain` requires payment. The register call answers
|
|
`402` with a JSON body carrying `pay_url` (the hosted GoblinPay checkout),
|
|
`invoice_id` and the price; the client sends the payer there and retries
|
|
the same call once the invoice settles. Payment is confirmed against
|
|
GoblinPay's REST API (which verifies the Grin payment on chain); a paid
|
|
claim consumes its grant, so releasing the name and claiming another needs
|
|
a fresh payment.
|
|
- `write`: publishing requires a one-time payment per pubkey. Clients NIP-42
|
|
AUTH (grants are per pubkey, see the section above), obtain a quote from
|
|
`POST /api/v1/quote` with `{"resource": "write"}` (NIP-98 signed), pay,
|
|
and publish. The relay plugin checks grants against the authority and
|
|
caches verdicts for `FLOONET_PAID_CACHE_SECS`.
|
|
|
|
Optionally set `GOBLINPAY_WEBHOOK_SECRET` and point a GoblinPay webhook at
|
|
`https://your.domain/api/v1/goblinpay/webhook`: payments then confirm the
|
|
moment GoblinPay sees them instead of on the next status poll. The webhook
|
|
is HMAC-verified and only ever triggers a re-check against the REST API, so
|
|
a replayed delivery grants nothing.
|
|
|
|
The relay's public NIP-11 metadata stays neutral in every mode; it carries
|
|
relay facts, nothing else.
|
|
|
|
## The name authority
|
|
|
|
Bundled in the package and consulted by the relay plugin; also usable on its
|
|
own. Names are lowercase `a-z0-9._-`, start and end alphanumeric, 3 to 20
|
|
characters, one active name per pubkey, with a reserved list (generic infra
|
|
and finance terms, your own domain labels, plus look-alike folding so
|
|
`g0blin` cannot impersonate `goblin`) and an anti-churn cooldown after
|
|
releasing a name.
|
|
|
|
| Endpoint | Auth | Purpose |
|
|
| --- | --- | --- |
|
|
| `GET /.well-known/nostr.json?name=<name>` | none | NIP-05 resolution |
|
|
| `GET /api/v1/name/{name}` | none | availability check |
|
|
| `POST /api/v1/register` | NIP-98 | claim `{name, pubkey}`; `402` + pay URL in paid mode |
|
|
| `DELETE /api/v1/register/{name}` | NIP-98 | release (owner only) |
|
|
| `GET /api/v1/profile/{name}` | none | name to pubkey |
|
|
| `GET /api/v1/by-pubkey/{pubkey}` | none | reverse lookup |
|
|
| `GET /api/v1/paid/{pubkey}` | none | write-grant status (what the plugin polls) |
|
|
| `POST /api/v1/quote` | NIP-98 | price + pay URL for a paid resource |
|
|
| `POST /api/v1/goblinpay/webhook` | HMAC | payment confirmation nudge |
|
|
| `GET /api/v1/health` | none | liveness |
|
|
|
|
NIP-98 requests are verified fully: signature, kind 27235, `u`/`method`/
|
|
`payload` tags against `FLOONET_BASE_URL`, a freshness window, and one-time
|
|
event ids (replay rejection).
|
|
|
|
## Co-locating names on the relay domain
|
|
|
|
`FLOONET_AUTHORITY_COLOCATED` controls whether the authority's NIP-05 lookup
|
|
(`/.well-known/nostr.json`) is served on the **relay's own domain**, so
|
|
`name@relay.example` resolves without the authority needing its own hostname.
|
|
|
|
- **Docker Compose / Caddy: on by default.** The whole stack lives on one
|
|
`FLOONET_DOMAIN`; `deploy/Caddyfile` routes `/.well-known/nostr.json` (and
|
|
`/api/*`) to the authority and everything else to the relay, so
|
|
`name@FLOONET_DOMAIN` just works. Nothing to configure.
|
|
|
|
- **Split nginx deploy: opt in.** When the relay and the authority run on
|
|
separate subdomains (the `deploy/us-east/` pattern — relay on
|
|
`relay.example`, the authority's own vhost on `nm.example`), enable it by
|
|
including the shipped snippet in the relay vhost's `:443` server block,
|
|
ahead of the WebSocket catch-all:
|
|
|
|
```nginx
|
|
# inside server { listen ...:443 ssl ...; server_name relay.example; }
|
|
# BEFORE location / { ...websocket... }
|
|
include /etc/nginx/snippets/floonet-colocated-authority.conf; # deploy/us-east/colocated-authority.conf
|
|
```
|
|
|
|
Then `nginx -t && nginx -s reload`, and
|
|
`https://relay.example/.well-known/nostr.json?name=<n>` returns the
|
|
authority's JSON. Only the exact-match read path is co-located; registration
|
|
and the rest of `/api/*` stay on the authority's own domain. The snippet sets
|
|
`X-Real-IP` (load-bearing — the authority's per-IP rate limiter keys off it).
|
|
|
|
## Tor onion (optional)
|
|
|
|
Goblin wallets connect to relays over Tor: the client opens a Tor circuit and
|
|
reaches the relay's ordinary clearnet endpoint (`FLOONET_DOMAIN`) through a
|
|
Tor exit, so the relay never sees the user's real IP. That works against any
|
|
Floonet relay with no extra setup here, and it is the whole transport story:
|
|
Tor hides the user's network location; the kind whitelist and gift-wrapped
|
|
(kind 1059) payloads hide everything else from the relay itself. The relay
|
|
needs no privacy component of its own.
|
|
|
|
An operator who wants to remove the Tor-exit hop entirely can front the relay
|
|
with a **Tor onion service**. Uncomment `COMPOSE_PROFILES=tor` in `.env` and
|
|
the package also runs the `tor` service: a stock system tor daemon whose
|
|
hidden service forwards straight to strfry's websocket listener (no TLS on
|
|
that hop, since the onion transport is already encrypted and authenticated end
|
|
to end). Wallets then reach the relay over an `.onion` with no exit hop at all.
|
|
|
|
tor prints the `.onion` address to its logs on first start and stores its key
|
|
on the `tor-data` volume; back that volume up, since losing it rotates the
|
|
address. Publish the `.onion` so wallets can find it. Without Docker, run a
|
|
system tor with the snippet in `deploy/tor/torrc` (a `HiddenServiceDir` plus a
|
|
`HiddenServicePort` pointed at the relay's local websocket port) alongside the
|
|
`floonet-strfry.service` unit.
|
|
|
|
## Extending the policy (plugins, paid resources)
|
|
|
|
- **Add a kind:** edit `FLOONET_ALLOWED_KINDS`, restart.
|
|
- **Add a policy check:** the plugin is a small, documented Python file.
|
|
Write `def check_foo(req, cfg): return None or "reason"`, append it to
|
|
`CHECKS`, and it runs on every write, fail-closed. strfry reloads the
|
|
plugin when the file's mtime changes.
|
|
- **Replace the policy entirely:** point `relay.writePolicy.plugin` in
|
|
`strfry.conf` at any executable speaking strfry's stdin/stdout JSONL
|
|
plugin protocol.
|
|
- **Add a paid resource:** the paywall is one mechanism applied to many
|
|
resources. `name` and `write` ship today; the same pattern fits paid
|
|
media/blob storage for GRIN (NIP-96 HTTP file storage or Blossom
|
|
content-addressed blobs, advertised with a kind 10063 server list): pick a
|
|
resource id, give it a price, gate the endpoint on `ensure_paid`, and the
|
|
plugin/authority handle quoting, the hosted pay page, and confirmation
|
|
unchanged. See `name-authority/src/paid.rs`.
|
|
|
|
## Security model
|
|
|
|
- **Fail closed everywhere.** Malformed events, plugin errors, unreachable
|
|
payment backend, unparseable config: all reject rather than admit.
|
|
- **Stock + spec.** strfry is never patched; the upstream ref is pinned in
|
|
`deploy/strfry/Dockerfile` and `apply-spec.sh`, so updating strfry is
|
|
bumping one hash.
|
|
- **Containers** run non-root (fixed uids) with the data volume as the only
|
|
writable state; **systemd units** use `DynamicUser`, `ProtectSystem=strict`,
|
|
`NoNewPrivileges`, syscall filtering, and a single writable state dir.
|
|
- **Reverse proxy sets `X-Real-IP`** (load-bearing: all per-IP rate limits
|
|
key off it); TLS terminates at Caddy.
|
|
- **Rate limits** per IP on the authority's read and write endpoints,
|
|
NIP-98 replay protection, name-change cooldown, and a poll throttle so
|
|
outsiders cannot hammer GoblinPay through the public paid endpoint.
|
|
- **No secrets in the repo.** The GoblinPay token comes from the environment
|
|
or a `0400` file via `GOBLINPAY_TOKEN_FILE`; the authority never logs it.
|
|
The relay itself holds no secrets at all.
|
|
- `events.maxEventSize` is sized so large gift-wrapped payloads fit.
|
|
|
|
## Configuration reference
|
|
|
|
Everything lives in `.env` (see `.env.example`, fully commented). The
|
|
essentials:
|
|
|
|
| Key | Default | Meaning |
|
|
| --- | --- | --- |
|
|
| `FLOONET_DOMAIN` | `floonet.example` | your domain (names + TLS cert) |
|
|
| `FLOONET_BASE_URL` | `https://floonet.example` | public base URL (NIP-98 verification) |
|
|
| `FLOONET_RELAYS` | `wss://floonet.example` | relays advertised in nostr.json |
|
|
| `FLOONET_ALLOWED_KINDS` | `0,3,5,13,1059,10002,10050,27235` | the whitelist |
|
|
| `FLOONET_AUTHORIZED_AUTHORS` | unset (closed) | authors (hex or npub) allowed to post kinds `1`/`30023` |
|
|
| `FLOONET_ENV_FILE` | `floonet.env` next to the plugin | optional `KEY=VALUE` config file (env vars win) |
|
|
| `FLOONET_REQUIRE_AUTH` | `false` | NIP-42 gate |
|
|
| `FLOONET_PAY_MODE` | `off` | `off` / `name` / `write` |
|
|
| `FLOONET_NAME_PRICE_GRIN` | `0` | price of a name, in GRIN |
|
|
| `FLOONET_WRITE_PRICE_GRIN` | `0` | price of write access, in GRIN |
|
|
| `GOBLINPAY_URL` / `GOBLINPAY_TOKEN` | unset | your GoblinPay server |
|
|
| `GOBLINPAY_WEBHOOK_SECRET` | unset | enables the webhook receiver |
|
|
| `COMPOSE_PROFILES` | unset | `tor` also runs a Tor onion in front of the relay |
|
|
|
|
## Note for Goblin wallet users
|
|
|
|
One wallet can hold multiple Nostr identities (npubs). If you pay for a name
|
|
and want to keep it, load the same wallet in Goblin and switch to (or add)
|
|
that npub; different identities share one wallet.
|
|
|
|
## License
|
|
|
|
Apache-2.0 for everything in this repository. strfry itself (built from
|
|
upstream at the pinned ref, never vendored here) is licensed under GPL-3.0
|
|
by its authors.
|
|
|
|
---
|
|
|
|
🤖 Built with AI pair-programming assistance (Claude)
|