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
+107
View File
@@ -0,0 +1,107 @@
# floonet-strfry configuration. Copy to `.env` (for docker compose) and edit
# for your deployment. Every value shown is the built-in default, so an unset
# variable behaves exactly like the line below.
# --- Identity (the part you MUST change to run your own relay) ---
# Bare host this relay and its names live under: the `@domain` in
# `name@domain` and the domain Caddy obtains a TLS certificate for.
FLOONET_DOMAIN=floonet.example
# Public base URL clients actually reach. LOAD-BEARING: NIP-98 auth events
# are verified against `<FLOONET_BASE_URL><path>`, so this MUST be https://
# and its host MUST equal FLOONET_DOMAIN (a port is allowed). A wrong value
# silently breaks every authenticated call. The authority refuses to start
# if it and FLOONET_DOMAIN disagree.
FLOONET_BASE_URL=https://floonet.example
# Comma-separated relays advertised in /.well-known/nostr.json. Point this
# at your own wss:// URL (normally wss://FLOONET_DOMAIN).
FLOONET_RELAYS=wss://floonet.example
# --- The kind whitelist (the keystone) ---
# Comma-separated event kinds the relay stores. DEFAULT-DENY: anything not
# listed here is rejected at ingest. The shipped set is exactly what the
# Goblin wallet uses:
# 0 profile, 3 contacts, 5 delete (NIP-09), 13 seal, 1059 gift wrap
# (NIP-59), 10002 relay list (NIP-65), 10050 DM relays (NIP-17),
# 27235 NIP-98 HTTP auth
# To accept another kind, add it here and restart the relay.
FLOONET_ALLOWED_KINDS=0,3,5,13,1059,10002,10050,27235
# --- Authentication (optional) ---
# Require NIP-42 AUTH before accepting writes. Set to true AND flip
# relay.auth.enabled to true in deploy/strfry/strfry.conf (strfry issues the
# challenges; the plugin enforces the requirement).
FLOONET_REQUIRE_AUTH=false
# --- Charge GRIN for your relay (optional; all off by default) ---
# off = everything free
# name = claiming a name@domain costs FLOONET_NAME_PRICE_GRIN
# write = publishing to the relay needs a one-time payment of
# FLOONET_WRITE_PRICE_GRIN (clients must also NIP-42 AUTH, since
# payment grants are per pubkey)
FLOONET_PAY_MODE=off
# Prices, in GRIN (decimals allowed, e.g. 1.5). You set the price; edit and
# restart, no code change.
FLOONET_NAME_PRICE_GRIN=0
FLOONET_WRITE_PRICE_GRIN=0
# Your GoblinPay server (https://code.gri.mw/GRIN/GoblinPay). The authority
# creates invoices there and payers land on its hosted pay page.
GOBLINPAY_URL=
# The GoblinPay API token (GP_API_TOKEN on the GoblinPay side).
GOBLINPAY_TOKEN=
# Optional: GoblinPay webhook secret. When set, point a GoblinPay webhook at
# https://FLOONET_DOMAIN/api/v1/goblinpay/webhook and payments confirm
# instantly instead of on the next status poll.
GOBLINPAY_WEBHOOK_SECRET=
# Seconds the write policy plugin caches paid-status verdicts.
FLOONET_PAID_CACHE_SECS=60
# --- Mixnet exit (optional) ---
# Uncomment to ALSO run the bundled scoped mixnet exit, so wallets can reach
# this relay over the mixnet. The exit forwards ONLY to this stack's own TLS
# front (never arbitrary targets) and sees only ciphertext. On first start it
# prints (and stores) its stable mixnet address; publish that address in the
# relay pool listing so wallets can use it.
#COMPOSE_PROFILES=exit
# Where the exit pipes accepted streams. The default is this stack's own
# proxy; only change it if your TLS terminates elsewhere.
FLOONET_EXIT_UPSTREAM=caddy:443
# --- Name authority policy tunables ---
# Seconds a key must wait to claim a new name after releasing one (anti-churn).
FLOONET_NAME_CHANGE_COOLDOWN_SECS=600
# Max age (seconds) of an accepted NIP-98 auth event.
FLOONET_AUTH_MAX_AGE_SECS=60
# Allowed name length, in characters.
FLOONET_NAME_MIN=3
FLOONET_NAME_MAX=20
# --- Rate-limit ceilings (per X-Real-IP) ---
# Read endpoints: max requests per window / window length in seconds.
FLOONET_READ_RATE_MAX=120
FLOONET_READ_RATE_WINDOW_SECS=60
# Write endpoints (register/release/quote).
FLOONET_WRITE_RATE_MAX=10
FLOONET_WRITE_RATE_WINDOW_SECS=3600
# --- Optional ---
# Path to a file of additional reserved names (one per line, # comments).
# Extends the built-in generic list and your domain's own labels (which are
# always reserved). Leave unset to use only those defaults.
#FLOONET_RESERVED_FILE=/etc/floonet-authority.reserved
+7
View File
@@ -0,0 +1,7 @@
.env
target/
strfry-build/
*.db
*.db-shm
*.db-wal
__pycache__/
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+248
View File
@@ -0,0 +1,248 @@
# 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 |
| `mixexit/` | An optional, scoped mixnet exit so wallets can reach this relay over the mixnet |
| `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, the mixnet exit).
```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`,
`floonet-authority.service` and, optionally, `floonet-mixexit.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.
### 3. From source (developers)
`deploy/strfry/Dockerfile` and `apply-spec.sh` document the strfry build
exactly; the authority and the exit are plain `cargo build` crates; 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.
## 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).
## Mixnet exit (optional)
Uncomment `COMPOSE_PROFILES=exit` in `.env` and the package also runs
`floonet-mixexit`: a small, unbonded mixnet client that accepts incoming
mixnet streams and pipes every one of them to this stack's own TLS front.
Wallets that prefer not to touch DNS or reveal their relay choice can then
reach this relay entirely over the mixnet, with end-to-end TLS; the exit
sees only ciphertext.
It is deliberately **scoped**: per-stream targets are never honored, the one
upstream is fixed by config, so it is structurally not an open proxy and
carries no open-proxy liability. No bonding, no tokens, no directory
listing.
On first start it prints its **stable mixnet address** (also written to the
data volume's `nym_address.txt`). Publish that address in your relay pool
listing (the `exit` field) so wallets can find it, and back the data
directory up: losing it rotates the address.
## 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_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 | `exit` also runs the mixnet exit |
## 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)
+59
View File
@@ -0,0 +1,59 @@
# Caddy reverse proxy for the Floonet relay + name authority, with automatic
# HTTPS.
#
# Used by docker-compose.yml (upstreams are the compose service names
# `authority` and `relay`). For a bare-metal Caddy install, replace those
# with `127.0.0.1:8191` and `127.0.0.1:7777`, set the site address to your
# domain literally, and point the landing root at deploy/landing.
#
# FLOONET_DOMAIN is injected from the environment by compose.
{$FLOONET_DOMAIN} {
# SECURITY-CRITICAL (both upstreams): X-Real-IP is set from the real
# client address inside each reverse_proxy below. The name authority keys
# ALL of its per-IP rate limiting off this header; if the proxy does not
# set it, every request looks like one client and the limiter is
# defeated. Caddy's {remote_host} is the connecting peer, not a
# forwardable client header.
# NIP-05 resolution and the registration/paid API go to the authority.
# gzip is scoped here (HTTP/JSON) and deliberately NOT applied to the
# relay path: strfry already negotiates permessage-deflate on the
# WebSocket.
@authority {
path /.well-known/nostr.json /.well-known/nostr.json/* /api/*
}
handle @authority {
encode gzip
reverse_proxy authority:8191 {
header_up X-Real-IP {remote_host}
}
}
# The Floonet logo and a static landing page for plain browser visits.
# The relay wire protocol never matches these: WebSocket upgrades and
# NIP-11 info requests carry the headers excluded below and fall through
# to the relay.
handle /floonet-logo.svg {
root * /srv/landing
file_server
}
@browser {
path /
not header Connection *Upgrade*
not header Accept *application/nostr+json*
}
handle @browser {
root * /srv/landing
rewrite * /index.html
file_server
}
# Everything else, in particular WebSocket upgrades and the NIP-11
# document, is the relay.
handle {
reverse_proxy relay:7777 {
header_up X-Real-IP {remote_host}
}
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Floonet Relay</title>
<style>
body{margin:0;background:#0E0E0C;color:#FAFAF7;font:16px/1.5 system-ui,sans-serif;
display:flex;min-height:100vh;align-items:center;justify-content:center}
main{max-width:520px;padding:32px;text-align:center}
img{width:160px;height:auto;margin-bottom:16px}
h1{font-size:36px;letter-spacing:-1px;margin:0 0 12px;color:#FFD60A}
p{color:#9A988F;margin:8px 0}
code{background:#1A1A17;border-radius:6px;padding:2px 6px;color:#FAFAF7}
</style>
</head>
<body>
<main>
<img src="/floonet-logo.svg" alt="Floonet">
<h1>Floonet Relay</h1>
<p>A strfry Floonet relay for the Grin community Nostr network.</p>
<p>Please use a Nostr client to connect.</p>
</main>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
# Builds the floonet-strfry relay: STOCK strfry (https://github.com/hoytech/
# strfry), cloned fresh at a pinned commit and compiled UNMODIFIED (no fork,
# no patches), plus python3 and the Floonet write policy plugin. The only
# Floonet-specific bits are strfry.conf + floonet_writepolicy.py, layered on
# via strfry's own config and plugin mechanisms. docker-compose builds this
# as the `relay` service (build context = repo root).
#
# Pinned for reproducibility. Bump STRFRY_REF to a newer upstream commit to
# update strfry; nothing else changes, since the source is never touched.
FROM alpine:3.18 AS build
ENV TZ=Europe/London
WORKDIR /build
RUN apk --no-cache add \
linux-headers git g++ make perl pkgconfig libtool ca-certificates \
libressl-dev zlib-dev lmdb-dev flatbuffers-dev libsecp256k1-dev zstd-dev
ARG STRFRY_REF=b80cda3a812af1b662223edad47eb70b053508b6
RUN git clone https://github.com/hoytech/strfry . \
&& git checkout "${STRFRY_REF}" \
&& git submodule update --init \
&& make setup-golpe \
&& make -j"$(nproc)"
FROM alpine:3.18
WORKDIR /app
# PYTHONUNBUFFERED keeps the write policy plugin's stdio prompt; it also
# flushes explicitly, so this is belt-and-suspenders against buffering stalls.
ENV PYTHONUNBUFFERED=1
RUN apk --no-cache add \
lmdb flatbuffers libsecp256k1 libb2 zstd libressl python3 \
&& rm -rf /var/cache/apk/*
COPY --from=build /build/strfry /app/strfry
COPY plugin/floonet_writepolicy.py /usr/local/bin/floonet_writepolicy.py
# Run as a fixed non-root uid: the relay takes untrusted network input, so
# drop privilege. Only the db dir needs to be owned by that uid (the binary
# and plugin are world-readable and executable already); a named volume
# inherits this ownership, and a bind mount must be chowned to 10001.
RUN addgroup -g 10001 -S strfry \
&& adduser -u 10001 -S -G strfry strfry \
&& chmod +x /usr/local/bin/floonet_writepolicy.py \
&& mkdir -p /strfry-db \
&& chown -R strfry:strfry /strfry-db
USER strfry
EXPOSE 7777
ENTRYPOINT ["/app/strfry"]
CMD ["relay"]
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env sh
# Build a Floonet relay = STOCK upstream strfry + the Floonet spec in this dir.
#
# No fork, no patches, no vendored source: this clones hoytech/strfry fresh at
# a pinned commit, compiles it UNTOUCHED, then drops in strfry.conf and the
# write policy plugin, both of which use strfry's own native config + plugin
# mechanisms. The result is a ready-to-run Floonet relay.
#
# The Docker path (`docker compose up -d relay`) does the same thing and
# bundles the build deps; this script is the no-Docker equivalent. Needs a C++
# toolchain plus strfry's libs (liblmdb, flatbuffers, libsecp256k1, libb2,
# zstd, openssl, perl); see https://github.com/hoytech/strfry#compile-strfry
#
# Usage: ./apply-spec.sh [target-dir] (default: ./strfry-build)
set -eu
STRFRY_REPO="https://github.com/hoytech/strfry"
# Pinned for reproducibility. Keep in sync with the Dockerfile's STRFRY_REF.
STRFRY_REF="b80cda3a812af1b662223edad47eb70b053508b6"
SPEC_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_DIR="$(cd "$SPEC_DIR/../../plugin" && pwd)"
TARGET="${1:-$SPEC_DIR/strfry-build}"
echo ">> Cloning stock strfry into $TARGET"
if [ ! -d "$TARGET/.git" ]; then
git clone "$STRFRY_REPO" "$TARGET"
fi
cd "$TARGET"
git fetch origin
git checkout "$STRFRY_REF"
git submodule update --init
echo ">> Building strfry (unmodified upstream source @ $STRFRY_REF)"
make setup-golpe
make -j"$(nproc 2>/dev/null || echo 2)"
echo ">> Applying the Floonet spec (config + write policy plugin)"
cp "$SPEC_DIR/strfry.conf" "$TARGET/strfry.conf"
cp "$PLUGIN_DIR/floonet_writepolicy.py" "$TARGET/floonet_writepolicy.py"
chmod +x "$TARGET/floonet_writepolicy.py"
# The shipped conf uses container paths; repoint db + plugin at this local
# build (edits only the COPY in the build dir; the canonical spec files are
# untouched).
mkdir -p "$TARGET/strfry-db"
sed -i 's#^db = .*#db = "'"$TARGET"'/strfry-db/"#' "$TARGET/strfry.conf"
sed -i 's#/usr/local/bin/floonet_writepolicy.py#'"$TARGET"'/floonet_writepolicy.py#' "$TARGET/strfry.conf"
cat <<EOF
Done. Stock strfry + the Floonet spec is built at:
$TARGET/strfry
Run the relay (binds :7777 by default; see strfry.conf):
cd "$TARGET" && ./strfry relay
Policy configuration (kind whitelist, auth, paid mode) is environment
variables on the strfry process; see .env.example at the repo root and the
systemd units in deploy/systemd/ for a hardened bare-metal setup.
EOF
+132
View File
@@ -0,0 +1,132 @@
##
## floonet-strfry relay configuration.
##
## strfry (https://github.com/hoytech/strfry) is a high-performance C++/LMDB
## relay. strfry core ships stock; all Floonet policy lives in the write
## policy plugin (plugin/floonet_writepolicy.py), which enforces a
## default-deny kind whitelist and the optional auth and paid-write gates.
## The plugin reads its configuration from environment variables set on the
## strfry process (see .env.example at the repo root).
##
# Directory that contains the strfry LMDB database. Mounted as a volume so
# the data survives container restarts.
db = "/strfry-db/"
dbParams {
maxreaders = 256
# 10 TB virtual mmap; does NOT preallocate disk.
mapsize = 10995116277760
noReadAhead = false
}
events {
# Reject oversized events. 64 KiB comfortably fits profile metadata and
# large gift-wrapped payloads.
maxEventSize = 65536
# Clock-skew tolerance for future-dated events. NIP-59 gift wraps tweak
# created_at backwards (up to ~2 days), so they are unaffected by the
# future bound; keep it tight (the strfry default) to limit future-dated
# spam and replaceable-event games.
rejectEventsNewerThanSeconds = 900
# Accept back-dated events for a long window; gift-wrap timestamp
# tweaking stays far inside this (~3 years).
rejectEventsOlderThanSeconds = 94608000
rejectEphemeralEventsOlderThanSeconds = 60
ephemeralEventsLifetimeSeconds = 300
maxNumTags = 2000
maxTagValSize = 1024
}
relay {
# Listen on all interfaces inside the container; the reverse proxy in
# front is the only thing that reaches it.
bind = "0.0.0.0"
port = 7777
nofiles = 524288
# The reverse proxy (Caddy/nginx) sets this from the real client address.
# Used for logging and any IP-based policy.
realIpHeader = "x-real-ip"
auth {
# NIP-42 authentication. OFF by default: wallets publish and read
# gift wraps without authenticating. To require AUTH before writes,
# set enabled = true here AND FLOONET_REQUIRE_AUTH=true in the
# environment (the plugin enforces; strfry only issues challenges).
enabled = false
serviceUrl = ""
}
info {
# NIP-11 relay information document, served on GET / with
# `Accept: application/nostr+json`. Deliberately neutral: this
# metadata says nothing about what clients exchange over the relay.
# `nips` empty = advertise strfry's built-in supported NIPs;
# `software`/`version` are filled automatically.
name = "Floonet Relay"
description = "A strfry Floonet relay for the Grin community Nostr network."
pubkey = ""
contact = ""
# The bundled proxy serves the Floonet logo at /floonet-logo.svg.
# Set to your own domain, e.g. "https://your.domain/floonet-logo.svg".
icon = ""
nips = ""
}
maxWebsocketPayloadSize = 131072
maxReqFilterSize = 200
autoPingSeconds = 55
enableTcpKeepalive = true
queryTimesliceBudgetMicroseconds = 10000
# Plenty for a wallet's gift-wrap history scan; bounds a single REQ's work.
maxFilterLimit = 500
# A wallet keeps one live subscription plus a few one-shot fetches. Cap
# low so an unauthenticated client cannot open a flood of scanning subs.
maxSubsPerConnection = 20
maxPendingOutboundBytes = 33554432
writePolicy {
# The Floonet policy plugin: default-deny kind whitelist plus the
# optional NIP-42 and paid-write gates. Configured via FLOONET_*
# environment variables (see the plugin header and .env.example).
plugin = "/usr/local/bin/floonet_writepolicy.py"
timeoutSeconds = 10
}
compression {
enabled = true
slidingWindow = true
}
logging {
dumpInAll = false
dumpInEvents = false
dumpInReqs = false
dbScanPerf = false
invalidEvents = true
}
numThreads {
ingester = 3
reqWorker = 3
reqMonitor = 3
negentropy = 2
}
negentropy {
# Set reconciliation (NIP-77) so community mirrors can sync cheaply.
enabled = true
maxSyncEvents = 1000000
}
filterValidation {
# Leave OFF: wallets legitimately query several kinds in one filter,
# which strict validation (maxKindsPerFilter) would reject. Stored
# kinds are restricted by the write policy above.
enabled = false
}
}
+64
View File
@@ -0,0 +1,64 @@
# Hardened systemd unit for the Floonet name authority on bare metal.
#
# Install:
# cd name-authority && cargo build --release
# sudo install -m0755 target/release/floonet-name-authority /usr/local/bin/
# sudo install -m0640 ../.env /etc/floonet-authority.env # see .env.example
# sudo install -m0644 ../deploy/systemd/floonet-authority.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now floonet-authority
#
# The service stores only public data plus payment grant state, but it is
# still locked down: dynamic unprivileged user, read-only system, no new
# privileges. Keep the GoblinPay token out of world-readable files (the env
# file above is 0640, or use GOBLINPAY_TOKEN_FILE pointing at a 0400 file).
[Unit]
Description=Floonet name authority (name@domain -> nostr pubkey)
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
# DynamicUser allocates a throwaway unprivileged user at runtime. If you need
# a stable owner for the data dir, comment this out and set `User=floonet`
# (create the user first).
DynamicUser=yes
# Identity/config. Edit /etc/floonet-authority.env (copy of .env.example).
EnvironmentFile=/etc/floonet-authority.env
# Managed state at /var/lib/floonet-authority (created and chowned by systemd).
StateDirectory=floonet-authority
StateDirectoryMode=0750
Environment=FLOONET_NAMES_DB=/var/lib/floonet-authority/names.db
ExecStart=/usr/local/bin/floonet-name-authority
Restart=on-failure
RestartSec=2
# --- hardening ---
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
# Only the state directory is writable.
ReadWritePaths=/var/lib/floonet-authority
# No raw sockets; only IP.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.target
+59
View File
@@ -0,0 +1,59 @@
# Hardened systemd unit for the bundled mixnet exit on bare metal.
#
# Install:
# cd mixexit && cargo build --release
# sudo install -m0755 target/release/floonet-mixexit /usr/local/bin/
# sudo install -m0644 ../deploy/systemd/floonet-mixexit.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now floonet-mixexit
#
# The exit pipes every accepted mixnet stream to ONE fixed upstream (your own
# relay's TLS front) and honors no per-stream targets, so it is structurally
# not an open proxy. Its mixnet identity persists in the state directory:
# back it up, losing it rotates the exit's address and strands wallet pins.
# After first start, publish the address from
# /var/lib/floonet-mixexit/nym_address.txt in your relay pool listing.
[Unit]
Description=floonet-mixexit (scoped mixnet exit for the co-located relay)
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
DynamicUser=yes
# Where the exit pipes accepted streams: your relay's public TLS host:port.
Environment=FLOONET_EXIT_UPSTREAM=127.0.0.1:443
# Persistent mixnet identity at /var/lib/floonet-mixexit.
StateDirectory=floonet-mixexit
StateDirectoryMode=0750
Environment=FLOONET_MIXEXIT_DIR=/var/lib/floonet-mixexit
ExecStart=/usr/local/bin/floonet-mixexit
Restart=on-failure
RestartSec=5
# --- hardening ---
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
ReadWritePaths=/var/lib/floonet-mixexit
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.target
+60
View File
@@ -0,0 +1,60 @@
# Hardened systemd unit for the floonet-strfry relay on bare metal.
#
# Install (after deploy/strfry/apply-spec.sh has built the binary):
# sudo install -m0755 strfry-build/strfry /usr/local/bin/strfry
# sudo install -m0755 plugin/floonet_writepolicy.py /usr/local/bin/
# sudo install -m0644 -D deploy/strfry/strfry.conf /etc/floonet-strfry/strfry.conf
# sudo install -m0640 .env /etc/floonet-strfry.env # see .env.example
# sudo install -m0644 deploy/systemd/floonet-strfry.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now floonet-strfry
#
# Point the conf's `db` at the StateDirectory below, e.g.
# db = "/var/lib/floonet-strfry/"
# and its writePolicy plugin at /usr/local/bin/floonet_writepolicy.py.
[Unit]
Description=floonet-strfry relay (stock strfry + Floonet write policy)
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
DynamicUser=yes
# Plugin configuration (FLOONET_ALLOWED_KINDS etc). The plugin inherits the
# relay process environment.
EnvironmentFile=/etc/floonet-strfry.env
# Managed state at /var/lib/floonet-strfry (created and chowned by systemd).
StateDirectory=floonet-strfry
StateDirectoryMode=0750
ExecStart=/usr/local/bin/strfry --config /etc/floonet-strfry/strfry.conf relay
Restart=on-failure
RestartSec=2
LimitNOFILE=524288
# --- hardening ---
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
# Only the LMDB directory is writable.
ReadWritePaths=/var/lib/floonet-strfry
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.target
+115
View File
@@ -0,0 +1,115 @@
# A full, self-contained Floonet relay with automatic HTTPS.
#
# cp .env.example .env # edit FLOONET_DOMAIN etc.
# docker compose up -d
#
# gives you:
# - relay : stock strfry (built from source at a pinned ref) + the
# Floonet write policy plugin (default-deny kind whitelist,
# optional NIP-42 and paid-write gates)
# - authority : the bundled name authority (name@domain -> pubkey, with
# optional paid names / paid write access via GoblinPay)
# - caddy : auto-TLS reverse proxy terminating HTTPS for both
# - mixexit : OPTIONAL scoped mixnet exit (COMPOSE_PROFILES=exit), so
# wallets can reach this relay over the mixnet
#
# Set FLOONET_DOMAIN / FLOONET_BASE_URL / FLOONET_RELAYS in `.env` (copy
# .env.example) BEFORE bringing it up: Caddy obtains a certificate for
# FLOONET_DOMAIN, so DNS must already point at this host.
services:
relay:
build:
context: .
dockerfile: deploy/strfry/Dockerfile
image: floonet-strfry:latest
restart: unless-stopped
environment:
# Write policy plugin configuration (the plugin inherits strfry's
# environment). See plugin/floonet_writepolicy.py and .env.example.
FLOONET_ALLOWED_KINDS: ${FLOONET_ALLOWED_KINDS:-0,3,5,13,1059,10002,10050,27235}
FLOONET_REQUIRE_AUTH: ${FLOONET_REQUIRE_AUTH:-false}
FLOONET_PAY_MODE: ${FLOONET_PAY_MODE:-off}
FLOONET_AUTHORITY_URL: http://authority:8191
FLOONET_PAID_CACHE_SECS: ${FLOONET_PAID_CACHE_SECS:-60}
volumes:
- relay-data:/strfry-db
- ./deploy/strfry/strfry.conf:/app/strfry.conf:ro
expose:
- "7777"
# Bound the relay's footprint so an unauthenticated subscription/ingest
# flood can't starve the authority or proxy on the same host.
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
authority:
build: ./name-authority
image: floonet-name-authority:latest
restart: unless-stopped
environment:
# Identity. Override these in .env for your own deployment.
FLOONET_DOMAIN: ${FLOONET_DOMAIN:-floonet.example}
FLOONET_BASE_URL: ${FLOONET_BASE_URL:-https://floonet.example}
FLOONET_RELAYS: ${FLOONET_RELAYS:-wss://floonet.example}
# In-container paths (persisted on the named volume).
FLOONET_NAMES_BIND: 0.0.0.0:8191
FLOONET_NAMES_DB: /data/names.db
# Paid mode (all optional; free by default). See .env.example.
FLOONET_PAY_MODE: ${FLOONET_PAY_MODE:-off}
FLOONET_NAME_PRICE_GRIN: ${FLOONET_NAME_PRICE_GRIN:-0}
FLOONET_WRITE_PRICE_GRIN: ${FLOONET_WRITE_PRICE_GRIN:-0}
GOBLINPAY_URL: ${GOBLINPAY_URL:-}
GOBLINPAY_TOKEN: ${GOBLINPAY_TOKEN:-}
GOBLINPAY_WEBHOOK_SECRET: ${GOBLINPAY_WEBHOOK_SECRET:-}
volumes:
- authority-data:/data
expose:
- "8191"
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- authority
- relay
environment:
FLOONET_DOMAIN: ${FLOONET_DOMAIN:-floonet.example}
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./deploy/landing:/srv/landing:ro
- caddy-data:/data
- caddy-config:/config
# The optional co-located mixnet exit. Off unless the `exit` profile is
# active (set COMPOSE_PROFILES=exit in .env, the package's exit toggle).
# It pipes every accepted mixnet stream to this stack's own TLS front, so
# wallets reach the relay over the mixnet with end-to-end TLS; the exit
# sees only ciphertext and can reach nothing but this relay. Its stable
# mixnet address is printed at startup and written to the volume's
# nym_address.txt; publish that address (relay pool `exit` field) so
# wallets can find it.
mixexit:
build: ./mixexit
image: floonet-mixexit:latest
restart: unless-stopped
profiles: ["exit"]
depends_on:
- caddy
environment:
FLOONET_MIXEXIT_DIR: /data
FLOONET_EXIT_UPSTREAM: ${FLOONET_EXIT_UPSTREAM:-caddy:443}
volumes:
- mixexit-data:/data
volumes:
relay-data:
authority-data:
caddy-data:
caddy-config:
mixexit-data:
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "floonet-mixexit"
version = "0.1.0"
edition = "2024"
license = "Apache-2.0"
description = "Scoped mixnet exit bundled with a Floonet relay: pipes accepted mixnet streams to ONE fixed upstream (never arbitrary targets)."
## Pinned upstream nym rev. This is the same nym-sdk revision the Goblin
## wallet builds against (its `goblin` branch is this rev plus one
## Android-only TLS-roots commit), so both ends speak the same MixnetStream
## protocol. Developing against a local nym checkout instead:
## cargo build --config 'patch."https://github.com/nymtech/nym".nym-sdk.path="../../nym/sdk/rust/nym-sdk"'
[dependencies]
nym-sdk = { git = "https://github.com/nymtech/nym", rev = "b6eb391e85be7eb8fca62def6d1ac32fd1108c30" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal"] }
## Only to surface nym-sdk's tracing logs (RUST_LOG-style filtering).
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[profile.release]
strip = true
+34
View File
@@ -0,0 +1,34 @@
# Builds the bundled mixnet exit (floonet-mixexit): a scoped, unbonded mixnet
# client that accepts incoming mixnet streams and pipes every one of them to
# ONE fixed upstream, the TLS front of this very stack. It is structurally not
# an open proxy: per-stream targets are never honored, so running it carries
# no open-proxy liability and needs no exit policy.
#
# Enabled by the `exit` compose profile (COMPOSE_PROFILES=exit in .env).
# Note: the first build compiles the pinned nym-sdk from source; expect it to
# take a while.
FROM rust:1-bookworm AS builder
WORKDIR /build
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libssl-dev protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Non-root; the persistent mixnet identity lives under /data. Back that
# directory up: losing it rotates the exit's mixnet address and strands
# wallet pins until their next relay-pool refresh.
RUN useradd --system --uid 10001 --home-dir /data --shell /usr/sbin/nologin mixexit \
&& mkdir -p /data \
&& chown -R mixexit:mixexit /data
COPY --from=builder /build/target/release/floonet-mixexit /usr/local/bin/floonet-mixexit
USER mixexit
WORKDIR /data
VOLUME ["/data"]
ENV FLOONET_MIXEXIT_DIR=/data
ENTRYPOINT ["/usr/local/bin/floonet-mixexit"]
+2
View File
@@ -0,0 +1,2 @@
hard_tabs = true
edition = "2024"
+184
View File
@@ -0,0 +1,184 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! floonet-mixexit — the SCOPED Nym exit bundled with a Floonet relay.
//!
//! An ordinary UNBONDED mixnet client (no nym-node, no pledge, no directory
//! listing) that accepts incoming [`MixnetStream`]s and pipes each one to ONE
//! fixed upstream — the operator's own relay. No per-stream target or host
//! header is honored, so this is structurally NOT an open proxy: the only
//! thing it can ever reach is the configured relay, which is why operators
//! carry zero open-proxy liability and need no exit policy.
//!
//! The mixnet identity persists in `FLOONET_MIXEXIT_DIR`, so `nym_address()`
//! is STABLE across restarts — that address is what wallets pin (relay-pool
//! `exit` field / NIP-11 `nym_exit`). Wallets run hostname-validated TLS
//! (SNI = the relay host) end-to-end THROUGH the pipe, so this exit sees only
//! ciphertext. Design: ~/.claude/plans/floonet-nym-exit.md.
use std::path::PathBuf;
use nym_sdk::mixnet::{MixnetClientBuilder, MixnetStream, StoragePaths};
use tokio::io::copy_bidirectional;
use tokio::net::TcpStream;
const USAGE: &str = "\
floonet-mixexit — scoped Nym exit for a Floonet relay
Accepts incoming mixnet streams and pipes each one to ONE fixed upstream
(the co-located relay). Per-stream targets are never honored, so this is
structurally not an open proxy. The mixnet identity persists in the data
dir, keeping the nym address stable across restarts.
USAGE:
floonet-mixexit [--help | --selftest]
MODES:
(none) serve: accept mixnet streams, pipe each to the upstream
--selftest connect to the mixnet, print the (stable) nym address and
exit — never touches the upstream
--help this text
ENVIRONMENT:
FLOONET_MIXEXIT_DIR data dir for the persistent mixnet identity;
the nym address is also written to
<dir>/nym_address.txt [default: ./mixexit-data]
FLOONET_EXIT_UPSTREAM fixed host:port every stream is piped to
[default: relay.goblin.st:443]
RUST_LOG nym-sdk log filter [default: warn]
";
/// Data dir for the persistent mixnet identity (`FLOONET_MIXEXIT_DIR`).
fn data_dir() -> PathBuf {
std::env::var_os("FLOONET_MIXEXIT_DIR")
.map(Into::into)
.unwrap_or_else(|| PathBuf::from("./mixexit-data"))
}
/// The ONE upstream every stream is piped to (`FLOONET_EXIT_UPSTREAM`).
fn upstream() -> String {
std::env::var("FLOONET_EXIT_UPSTREAM").unwrap_or_else(|_| "relay.goblin.st:443".to_string())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mode = std::env::args().nth(1);
match mode.as_deref() {
Some("--help" | "-h") => {
print!("{USAGE}");
return Ok(());
}
None | Some("--selftest") => {}
Some(other) => {
eprintln!("unknown argument: {other}\n\n{USAGE}");
std::process::exit(2);
}
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
)
.init();
// Persistent identity: same data dir → same keystore (generated on first
// run) → the SAME nym address across restarts. That address is what
// wallets pin, so back this directory up — losing it rotates the address
// and strands wallet pins until the next pool/NIP-11 refresh.
let dir = data_dir();
std::fs::create_dir_all(&dir)?;
let storage_paths = StoragePaths::new_from_dir(&dir)?;
let mut client = MixnetClientBuilder::new_with_default_storage(storage_paths)
.await?
.build()?
.connect_to_mixnet()
.await?;
let address = *client.nym_address();
let address_file = dir.join("nym_address.txt");
std::fs::write(&address_file, format!("{address}\n"))?;
println!("=============================================================");
println!(" floonet-mixexit is on the mixnet. Nym address (STABLE — pin");
println!(" this in the relay pool `exit` field / NIP-11 `nym_exit`):");
println!(" {address}");
println!(" also written to {}", address_file.display());
println!("=============================================================");
if mode.as_deref() == Some("--selftest") {
println!("selftest OK");
client.disconnect().await;
return Ok(());
}
let upstream = upstream();
println!("piping every accepted stream to fixed upstream {upstream}");
let mut listener = client.listener()?;
loop {
tokio::select! {
_ = shutdown_signal() => {
println!("shutdown signal received; stopping");
break;
}
accepted = listener.accept() => match accepted {
Some(stream) => {
let upstream = upstream.clone();
tokio::spawn(pipe(stream, upstream));
}
None => {
eprintln!("mixnet stream router stopped; exiting");
break;
}
}
}
}
client.disconnect().await;
println!("floonet-mixexit stopped");
Ok(())
}
/// One accepted stream: TCP to the FIXED upstream (never a caller-chosen
/// target), then bytes both ways until either side closes. Errors are logged
/// and drop only this stream — the accept loop keeps serving.
async fn pipe(mut mix: MixnetStream, upstream: String) {
let mut tcp = match TcpStream::connect(&upstream).await {
Ok(tcp) => tcp,
Err(e) => {
eprintln!("stream dropped: upstream {upstream} connect failed: {e}");
return;
}
};
match copy_bidirectional(&mut mix, &mut tcp).await {
Ok((up, down)) => println!("stream closed ({up} B in → relay, {down} B relay → out)"),
Err(e) => eprintln!("stream ended with error: {e}"),
}
}
/// Resolves on SIGINT (Ctrl-C) or SIGTERM (systemd/docker stop).
async fn shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM handler");
tokio::select! {
_ = ctrl_c => {}
_ = term.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = ctrl_c.await;
}
}
+4
View File
@@ -0,0 +1,4 @@
target/
tests/
*.db
*.db-*
+1834
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "floonet-name-authority"
version = "0.1.0"
edition = "2021"
description = "Floonet name authority (name@domain -> nostr pubkey), with optional paid names and paid write access via GoblinPay"
license = "Apache-2.0"
[lib]
name = "floonet_name_authority"
path = "src/lib.rs"
[[bin]]
name = "floonet-name-authority"
path = "src/main.rs"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
nostr = { version = "0.44", default-features = false, features = ["std"] }
base64 = "0.22"
sha2 = "0.10"
hmac = "0.12"
hex = "0.4"
parking_lot = "0.12"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Blocking HTTP client for the GoblinPay REST calls (wrapped in
# spawn_blocking); small and rustls-based, no OpenSSL dependency.
ureq = { version = "2", features = ["json"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
[profile.release]
opt-level = 2
strip = true
+55
View File
@@ -0,0 +1,55 @@
# Multi-stage build: a Rust builder produces a static-ish binary, then a slim
# Debian runtime runs it as a non-root user. SQLite is bundled into the binary
# (the `bundled` rusqlite feature), so the runtime needs no system DB library.
# ---- builder ----
FROM rust:1-bookworm AS builder
WORKDIR /build
# Cache dependencies first.
COPY Cargo.toml Cargo.lock ./
# A throwaway lib + main so `cargo build` can compile dependencies before the
# real sources are present.
RUN mkdir -p src \
&& echo "pub fn _stub() {}" > src/lib.rs \
&& echo "fn main() {}" > src/main.rs \
&& cargo build --release --locked || true
RUN rm -rf src
# Real sources.
COPY src ./src
# Touch so cargo rebuilds the bin/lib with the actual code.
RUN touch src/main.rs src/lib.rs && cargo build --release --locked
# ---- runtime ----
FROM debian:bookworm-slim AS runtime
# ca-certificates for outbound TLS (GoblinPay); curl for the healthcheck.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Non-root user; data lives under /data.
RUN useradd --system --uid 10001 --home-dir /data --shell /usr/sbin/nologin floonet \
&& mkdir -p /data \
&& chown -R floonet:floonet /data
COPY --from=builder /build/target/release/floonet-name-authority /usr/local/bin/floonet-name-authority
USER floonet
WORKDIR /data
# Persist the database.
VOLUME ["/data"]
# Defaults can be overridden at run time; bind on all interfaces inside the
# container (the reverse proxy is the only thing in front of it).
ENV FLOONET_NAMES_BIND=0.0.0.0:8191 \
FLOONET_NAMES_DB=/data/names.db
EXPOSE 8191
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -fsS http://127.0.0.1:8191/api/v1/health || exit 1
ENTRYPOINT ["/usr/local/bin/floonet-name-authority"]
+91
View File
@@ -0,0 +1,91 @@
// NIP-98 HTTP authorization: verify a `Authorization: Nostr <base64-event>`
// header, including signature, kind, freshness, and the url/method/payload
// tags. The `u`-tag is checked against the configured public base URL, so a
// wrong BASE_URL silently fails every authenticated call.
use axum::http::{header, HeaderMap, Method, StatusCode};
use base64::Engine;
use nostr::{Event, JsonUtil, Kind, Timestamp};
use sha2::{Digest, Sha256};
/// Verify a NIP-98 auth header for `method`+`url_path` over `body`.
/// `base_url` is the operator's public base (`https://host`) and
/// `auth_max_age_secs` bounds event freshness.
/// On success returns (authenticated pubkey hex, auth event id hex).
pub fn verify_nip98(
headers: &HeaderMap,
method: &Method,
url_path: &str,
body: &[u8],
base_url: &str,
auth_max_age_secs: i64,
) -> Result<(String, String), (StatusCode, String)> {
let unauthorized = |msg: &str| (StatusCode::UNAUTHORIZED, msg.to_string());
let auth = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| unauthorized("missing Authorization header"))?;
let b64 = auth
.strip_prefix("Nostr ")
.ok_or_else(|| unauthorized("Authorization scheme must be Nostr"))?;
let raw = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.map_err(|_| unauthorized("invalid base64 auth event"))?;
let event = Event::from_json(&raw).map_err(|_| unauthorized("invalid auth event json"))?;
event
.verify()
.map_err(|_| unauthorized("bad event signature"))?;
if event.kind != Kind::HttpAuth {
return Err(unauthorized("auth event kind must be 27235"));
}
let now = Timestamp::now();
let age = (now.as_secs() as i64) - (event.created_at.as_secs() as i64);
// Allow modest backward skew but only a few seconds forward, to bound the
// replay window (paired with one-time event-id enforcement at the caller).
if age > auth_max_age_secs || age < -5 {
return Err(unauthorized("auth event expired or post-dated"));
}
let mut u_ok = false;
let mut method_ok = false;
let mut payload_hash: Option<String> = None;
for tag in event.tags.iter() {
let parts = tag.as_slice();
match parts.first().map(|s| s.as_str()) {
Some("u") => {
if let Some(u) = parts.get(1) {
let expected = format!("{base_url}{url_path}");
let normalized = u.trim_end_matches('/');
u_ok = normalized == expected.trim_end_matches('/');
}
}
Some("method") => {
if let Some(m) = parts.get(1) {
method_ok = m.eq_ignore_ascii_case(method.as_str());
}
}
Some("payload") => {
payload_hash = parts.get(1).cloned();
}
_ => {}
}
}
if !u_ok {
return Err(unauthorized("auth event url mismatch"));
}
if !method_ok {
return Err(unauthorized("auth event method mismatch"));
}
if let Some(expect) = payload_hash {
let got = hex::encode(Sha256::digest(body));
if !expect.eq_ignore_ascii_case(&got) {
return Err(unauthorized("auth event payload hash mismatch"));
}
} else if !body.is_empty() {
return Err(unauthorized("auth event missing payload hash"));
}
Ok((event.pubkey.to_hex(), event.id.to_hex()))
}
+439
View File
@@ -0,0 +1,439 @@
// Runtime configuration. Everything that identifies a particular operator's
// name authority lives here and is read from the environment at startup, so
// any operator can run their own authority without touching the source.
use std::time::Duration;
/// How this authority charges for its resources.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PayMode {
/// Everything is free.
Off,
/// Claiming a name requires a confirmed GoblinPay payment.
Name,
/// Publishing to the relay requires a confirmed GoblinPay payment
/// (enforced by the write policy plugin, which consults this authority).
Write,
}
impl PayMode {
fn parse(s: &str) -> Result<Self, String> {
match s {
"off" => Ok(PayMode::Off),
"name" => Ok(PayMode::Name),
"write" => Ok(PayMode::Write),
other => Err(format!(
"FLOONET_PAY_MODE must be off, name or write (got `{other}`)"
)),
}
}
}
/// Resolved, validated runtime configuration.
#[derive(Debug, Clone)]
pub struct Config {
/// Bare host the names live under, e.g. `floonet.example` (the `@domain`
/// part of `name@domain`).
pub domain: String,
/// Public base URL, e.g. `https://floonet.example`. Load-bearing: NIP-98
/// `u`-tag verification builds the expected URL from this, so it MUST
/// equal the scheme+host clients actually reach, or all authenticated
/// calls fail.
pub base_url: String,
/// Relays advertised in `/.well-known/nostr.json` `relays` map.
pub relays: Vec<String>,
/// Address the HTTP server binds (loopback by default; sit behind a proxy).
pub bind_addr: String,
/// SQLite database path.
pub db_path: String,
/// After releasing a name, how long a pubkey must wait before claiming a
/// new one (anti-churn brake).
pub name_change_cooldown: Duration,
/// Max age (seconds) of an accepted NIP-98 auth event.
pub auth_max_age_secs: i64,
/// Minimum/maximum name length in characters.
pub name_min: usize,
pub name_max: usize,
/// Read endpoints: requests per IP per `read_window`.
pub read_rate_max: usize,
pub read_rate_window: Duration,
/// Write endpoints (register/unregister/quote): per IP per `write_window`.
pub write_rate_max: usize,
pub write_rate_window: Duration,
/// Additional reserved names: the operator's own domain labels (so the
/// brand a domain represents can't be impersonated) plus any names from
/// an optional `FLOONET_RESERVED_FILE`. Extends the built-in generic list.
pub extra_reserved: Vec<String>,
/// Paid mode. `Off` runs a free authority (the default).
pub pay_mode: PayMode,
/// Price of a name, in nanogrin (parsed from FLOONET_NAME_PRICE_GRIN).
pub name_price_nanogrin: u64,
/// Price of write access, in nanogrin (FLOONET_WRITE_PRICE_GRIN).
pub write_price_nanogrin: u64,
/// GoblinPay server base URL (e.g. `https://pay.example`).
pub goblinpay_url: String,
/// GoblinPay API token (Bearer). From GOBLINPAY_TOKEN or, preferably, a
/// 0400 file named by GOBLINPAY_TOKEN_FILE.
pub goblinpay_token: String,
/// Optional GoblinPay webhook secret. When set, POST
/// /api/v1/goblinpay/webhook verifies the HMAC and refreshes the matching
/// grant immediately instead of waiting for the next poll.
pub goblinpay_webhook_secret: Option<String>,
/// Minimum interval between GoblinPay status polls per grant.
pub paid_poll_interval: Duration,
}
fn env_string(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
fn env_parse<T: std::str::FromStr>(key: &str, default: T) -> T {
match std::env::var(key) {
Ok(v) => v.parse().unwrap_or(default),
Err(_) => default,
}
}
/// Parse a decimal GRIN amount ("1", "0.5", "2.25") into nanogrin. Rejects
/// negatives, more than 9 fractional digits, and garbage.
pub fn grin_to_nanogrin(s: &str) -> Result<u64, String> {
let s = s.trim();
let bad = || format!("invalid GRIN amount `{s}`");
let (whole, frac) = match s.split_once('.') {
Some((w, f)) => (w, f),
None => (s, ""),
};
if whole.is_empty() && frac.is_empty() {
return Err(bad());
}
if !whole.chars().all(|c| c.is_ascii_digit()) || !frac.chars().all(|c| c.is_ascii_digit()) {
return Err(bad());
}
if frac.len() > 9 {
return Err(format!("`{s}` has more than 9 decimal places"));
}
let whole: u64 = if whole.is_empty() {
0
} else {
whole.parse().map_err(|_| bad())?
};
let mut frac_n: u64 = if frac.is_empty() {
0
} else {
frac.parse().map_err(|_| bad())?
};
frac_n *= 10u64.pow(9 - frac.len() as u32);
whole
.checked_mul(1_000_000_000)
.and_then(|n| n.checked_add(frac_n))
.ok_or_else(bad)
}
/// Format nanogrin as a trimmed decimal GRIN string.
pub fn nanogrin_to_grin(nano: u64) -> String {
let whole = nano / 1_000_000_000;
let frac = nano % 1_000_000_000;
if frac == 0 {
whole.to_string()
} else {
let frac = format!("{frac:09}");
format!("{whole}.{}", frac.trim_end_matches('0'))
}
}
impl Config {
/// Load from the environment and validate. Returns an error string on
/// misconfiguration (caller should fail fast).
pub fn from_env() -> Result<Self, String> {
let domain = env_string("FLOONET_DOMAIN", "floonet.example");
let base_url = env_string("FLOONET_BASE_URL", "https://floonet.example");
let relays = env_string("FLOONET_RELAYS", "wss://floonet.example")
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
let bind_addr = env_string("FLOONET_NAMES_BIND", "127.0.0.1:8191");
let db_path = env_string("FLOONET_NAMES_DB", "/var/lib/floonet-authority/names.db");
let name_change_cooldown =
Duration::from_secs(env_parse("FLOONET_NAME_CHANGE_COOLDOWN_SECS", 600u64));
let auth_max_age_secs = env_parse("FLOONET_AUTH_MAX_AGE_SECS", 60i64);
let name_min = env_parse("FLOONET_NAME_MIN", 3usize);
let name_max = env_parse("FLOONET_NAME_MAX", 20usize);
let read_rate_max = env_parse("FLOONET_READ_RATE_MAX", 120usize);
let read_rate_window =
Duration::from_secs(env_parse("FLOONET_READ_RATE_WINDOW_SECS", 60u64));
let write_rate_max = env_parse("FLOONET_WRITE_RATE_MAX", 10usize);
let write_rate_window =
Duration::from_secs(env_parse("FLOONET_WRITE_RATE_WINDOW_SECS", 3600u64));
// Reserve the operator's own domain labels (e.g. `floonet` for
// `floonet.example`) so the brand the domain stands for can't be
// claimed or look-alike-folded into. Then layer on any names from
// the optional reserved file.
let mut extra_reserved = crate::names::domain_reserved(&domain);
if let Ok(path) = std::env::var("FLOONET_RESERVED_FILE") {
if !path.is_empty() {
extra_reserved.extend(load_reserved_file(&path)?);
}
}
let pay_mode = PayMode::parse(&env_string("FLOONET_PAY_MODE", "off"))?;
let name_price_nanogrin = grin_to_nanogrin(&env_string("FLOONET_NAME_PRICE_GRIN", "0"))?;
let write_price_nanogrin = grin_to_nanogrin(&env_string("FLOONET_WRITE_PRICE_GRIN", "0"))?;
let goblinpay_url = env_string("GOBLINPAY_URL", "")
.trim_end_matches('/')
.to_string();
let goblinpay_token = match std::env::var("GOBLINPAY_TOKEN_FILE") {
Ok(path) if !path.is_empty() => std::fs::read_to_string(&path)
.map_err(|e| format!("GOBLINPAY_TOKEN_FILE `{path}` unreadable: {e}"))?
.trim()
.to_string(),
_ => env_string("GOBLINPAY_TOKEN", ""),
};
let goblinpay_webhook_secret =
std::env::var("GOBLINPAY_WEBHOOK_SECRET").ok().filter(|s| !s.is_empty());
let paid_poll_interval =
Duration::from_secs(env_parse("FLOONET_PAID_POLL_INTERVAL_SECS", 5u64));
let cfg = Config {
domain,
base_url,
relays,
bind_addr,
db_path,
name_change_cooldown,
auth_max_age_secs,
name_min,
name_max,
read_rate_max,
read_rate_window,
write_rate_max,
write_rate_window,
extra_reserved,
pay_mode,
name_price_nanogrin,
write_price_nanogrin,
goblinpay_url,
goblinpay_token,
goblinpay_webhook_secret,
paid_poll_interval,
};
cfg.validate()?;
Ok(cfg)
}
/// Fail-fast consistency checks. A wrong BASE_URL silently breaks every
/// authenticated call (the `u`-tag never matches), so we refuse to start;
/// likewise a paid mode without a working GoblinPay wiring or price.
pub fn validate(&self) -> Result<(), String> {
if self.domain.is_empty() {
return Err("FLOONET_DOMAIN must not be empty".into());
}
let host = self.base_url.strip_prefix("https://").ok_or_else(|| {
format!(
"FLOONET_BASE_URL must start with https:// (got `{}`)",
self.base_url
)
})?;
if host.is_empty() {
return Err("FLOONET_BASE_URL has no host".into());
}
// The host part of BASE_URL must match DOMAIN (allowing an explicit
// port), otherwise the `@domain` names and the auth URL disagree.
let host_no_port = host.split('/').next().unwrap_or(host);
let host_bare = host_no_port.split(':').next().unwrap_or(host_no_port);
if host_bare != self.domain {
return Err(format!(
"FLOONET_BASE_URL host `{host_bare}` does not match FLOONET_DOMAIN `{}`",
self.domain
));
}
if self.name_min == 0 || self.name_min > self.name_max {
return Err(format!(
"invalid name length bounds: min={} max={}",
self.name_min, self.name_max
));
}
if self.pay_mode != PayMode::Off {
if self.goblinpay_url.is_empty() {
return Err("FLOONET_PAY_MODE is on but GOBLINPAY_URL is not set".into());
}
if self.goblinpay_token.is_empty() {
return Err(
"FLOONET_PAY_MODE is on but no GoblinPay token is set \
(GOBLINPAY_TOKEN or GOBLINPAY_TOKEN_FILE)"
.into(),
);
}
}
if self.pay_mode == PayMode::Name && self.name_price_nanogrin == 0 {
return Err("FLOONET_PAY_MODE=name needs FLOONET_NAME_PRICE_GRIN > 0".into());
}
if self.pay_mode == PayMode::Write && self.write_price_nanogrin == 0 {
return Err("FLOONET_PAY_MODE=write needs FLOONET_WRITE_PRICE_GRIN > 0".into());
}
Ok(())
}
/// One-line summary for the startup log. The GoblinPay token is a secret
/// and never logged.
pub fn summary(&self) -> String {
format!(
"domain={} base_url={} relays={:?} bind={} db={} \
name_len={}..={} cooldown={}s auth_max_age={}s \
read={}req/{}s write={}req/{}s reserved_extra={} \
pay_mode={:?} name_price={}g write_price={}g goblinpay={}",
self.domain,
self.base_url,
self.relays,
self.bind_addr,
self.db_path,
self.name_min,
self.name_max,
self.name_change_cooldown.as_secs(),
self.auth_max_age_secs,
self.read_rate_max,
self.read_rate_window.as_secs(),
self.write_rate_max,
self.write_rate_window.as_secs(),
self.extra_reserved.len(),
self.pay_mode,
nanogrin_to_grin(self.name_price_nanogrin),
nanogrin_to_grin(self.write_price_nanogrin),
if self.goblinpay_url.is_empty() {
"unset"
} else {
&self.goblinpay_url
},
)
}
}
/// Read an optional reserved-names file: one lowercase name per line, blank
/// lines and `#` comments ignored. Missing file is a hard error (the operator
/// asked for it via env), but the names themselves are not validated here.
fn load_reserved_file(path: &str) -> Result<Vec<String>, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("FLOONET_RESERVED_FILE `{path}` unreadable: {e}"))?;
Ok(text
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| l.to_lowercase())
.collect())
}
impl Config {
/// A minimal config for tests/integration, pointing at in-memory state.
/// Kept out of the public docs but available to integration tests (which
/// compile as a separate crate and so can't see `#[cfg(test)]` items).
#[doc(hidden)]
pub fn for_test() -> Self {
Config {
domain: "floonet.example".into(),
base_url: "https://floonet.example".into(),
relays: vec!["wss://floonet.example".into()],
bind_addr: "127.0.0.1:0".into(),
db_path: ":memory:".into(),
name_change_cooldown: Duration::from_secs(600),
auth_max_age_secs: 60,
name_min: 3,
name_max: 20,
read_rate_max: 100_000,
read_rate_window: Duration::from_secs(60),
write_rate_max: 100_000,
write_rate_window: Duration::from_secs(3600),
// Mirror from_env: the domain's own label is reserved.
extra_reserved: crate::names::domain_reserved("floonet.example"),
pay_mode: PayMode::Off,
name_price_nanogrin: 0,
write_price_nanogrin: 0,
goblinpay_url: String::new(),
goblinpay_token: String::new(),
goblinpay_webhook_secret: None,
paid_poll_interval: Duration::ZERO,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Config {
Config::for_test()
}
#[test]
fn rejects_non_https_base_url() {
let mut c = base();
c.base_url = "http://floonet.example".into();
assert!(c.validate().is_err());
}
#[test]
fn rejects_base_url_domain_mismatch() {
let mut c = base();
c.base_url = "https://example.com".into();
assert!(c.validate().is_err());
}
#[test]
fn accepts_matching_base_url_with_port() {
let mut c = base();
c.domain = "names.example".into();
c.base_url = "https://names.example:8443".into();
assert!(c.validate().is_ok());
}
#[test]
fn rejects_bad_name_bounds() {
let mut c = base();
c.name_min = 10;
c.name_max = 5;
assert!(c.validate().is_err());
}
#[test]
fn paid_mode_requires_goblinpay_and_price() {
let mut c = base();
c.pay_mode = PayMode::Name;
assert!(c.validate().is_err()); // no url
c.goblinpay_url = "https://pay.example".into();
assert!(c.validate().is_err()); // no token
c.goblinpay_token = "tok".into();
assert!(c.validate().is_err()); // no price
c.name_price_nanogrin = grin_to_nanogrin("1.5").unwrap();
assert!(c.validate().is_ok());
let mut w = base();
w.pay_mode = PayMode::Write;
w.goblinpay_url = "https://pay.example".into();
w.goblinpay_token = "tok".into();
assert!(w.validate().is_err()); // no write price
w.write_price_nanogrin = 1;
assert!(w.validate().is_ok());
}
#[test]
fn grin_amount_parsing() {
assert_eq!(grin_to_nanogrin("1").unwrap(), 1_000_000_000);
assert_eq!(grin_to_nanogrin("0.5").unwrap(), 500_000_000);
assert_eq!(grin_to_nanogrin("2.25").unwrap(), 2_250_000_000);
assert_eq!(grin_to_nanogrin("0.000000001").unwrap(), 1);
assert_eq!(grin_to_nanogrin("0").unwrap(), 0);
assert!(grin_to_nanogrin("").is_err());
assert!(grin_to_nanogrin(".").is_err());
assert!(grin_to_nanogrin("-1").is_err());
assert!(grin_to_nanogrin("1.0000000001").is_err());
assert!(grin_to_nanogrin("1,5").is_err());
assert_eq!(nanogrin_to_grin(1_500_000_000), "1.5");
assert_eq!(nanogrin_to_grin(2_000_000_000), "2");
}
}
+136
View File
@@ -0,0 +1,136 @@
// Shared application state and the SQLite layer.
//
// `App` is the single piece of state handed to every handler: the database
// connection, the in-memory rate/cooldown maps, the optional paywall, and
// the resolved config. The schema is a const so tests can stand up an
// identical in-memory database.
use crate::config::Config;
use crate::paid::Paywall;
use parking_lot::Mutex;
use rusqlite::Connection;
use std::{
collections::HashMap,
time::{Duration, Instant},
};
/// The full schema. Idempotent (`IF NOT EXISTS`), so it doubles as the
/// migration applied at every startup.
pub const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS names (
name TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
created_at INTEGER NOT NULL,
released_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_names_pubkey ON names(pubkey);
-- Enforce one active name per pubkey at the DB layer (defeats the
-- check-then-insert race that app code alone cannot close).
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_pubkey
ON names(pubkey) WHERE released_at IS NULL;
-- Paid-resource grants: one open grant per (pubkey, resource). `status`
-- is 'pending' until the GoblinPay invoice settles, then 'paid'.
CREATE TABLE IF NOT EXISTS paid_grants (
pubkey TEXT NOT NULL,
resource TEXT NOT NULL,
invoice_id TEXT NOT NULL,
pay_url TEXT NOT NULL,
amount_nanogrin INTEGER NOT NULL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
paid_at INTEGER,
PRIMARY KEY (pubkey, resource)
);
CREATE INDEX IF NOT EXISTS idx_grants_invoice ON paid_grants(invoice_id);";
pub struct App {
pub db: Mutex<Connection>,
pub rate: Mutex<HashMap<String, Vec<Instant>>>,
/// Seen NIP-98 auth event ids (one-time use within the freshness window).
pub seen_auth: Mutex<HashMap<String, Instant>>,
/// Resolved runtime config.
pub cfg: Config,
/// GoblinPay paywall; `None` when FLOONET_PAY_MODE=off (everything free).
pub paywall: Option<Paywall>,
}
impl App {
/// Open the database at `cfg.db_path`, applying the schema and wiring the
/// paywall from config. Pass a `:memory:` db path for tests.
pub fn open(cfg: Config) -> Self {
let db = Connection::open(&cfg.db_path).expect("open sqlite db");
// WAL lets the readers (availability/well-known) proceed concurrently
// with the single writer instead of serializing on one lock.
let _ = db.pragma_update(None, "journal_mode", "WAL");
let _ = db.busy_timeout(Duration::from_secs(5));
db.execute_batch(SCHEMA).expect("init schema");
let paywall = Paywall::from_config(&cfg);
App {
db: Mutex::new(db),
rate: Mutex::new(HashMap::new()),
seen_auth: Mutex::new(HashMap::new()),
cfg,
paywall,
}
}
/// Active (non-released) pubkey for a name.
pub fn lookup(&self, name: &str) -> Option<String> {
self.db
.lock()
.query_row(
"SELECT pubkey FROM names WHERE name = ?1 AND released_at IS NULL",
[name],
|r| r.get::<_, String>(0),
)
.ok()
}
/// Active name owned by a pubkey.
pub fn name_of(&self, pubkey: &str) -> Option<String> {
self.db
.lock()
.query_row(
"SELECT name FROM names WHERE pubkey = ?1 AND released_at IS NULL",
[pubkey],
|r| r.get::<_, String>(0),
)
.ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A released name is immediately revivable by a new key via the register
/// upsert.
#[test]
fn released_name_immediately_reclaimable() {
let db = Connection::open_in_memory().expect("db");
db.execute_batch(SCHEMA).unwrap();
let (a, b) = ("aa".repeat(32), "bb".repeat(32));
db.execute(
"INSERT INTO names (name, pubkey, created_at, released_at) VALUES ('alice', ?1, 1, 5)",
rusqlite::params![a],
)
.unwrap();
let n = db
.execute(
"INSERT INTO names (name, pubkey, created_at) VALUES (?1, ?2, ?3)
ON CONFLICT(name) DO UPDATE SET pubkey = excluded.pubkey,
created_at = excluded.created_at, released_at = NULL
WHERE names.released_at IS NOT NULL",
rusqlite::params!["alice", b, 6],
)
.unwrap();
assert_eq!(n, 1);
let owner: String = db
.query_row(
"SELECT pubkey FROM names WHERE name='alice' AND released_at IS NULL",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(owner, b);
}
}
+47
View File
@@ -0,0 +1,47 @@
// Liveness and the landing page.
use crate::db::App;
use axum::{
extract::State,
response::{Html, IntoResponse, Response},
};
use std::sync::Arc;
pub async fn health() -> &'static str {
"ok"
}
/// A minimal, neutral landing page. Domain and relay are filled from config
/// so an operator's page reflects their own authority. Deliberately says
/// nothing about what clients exchange over the relay.
pub async fn landing(State(app): State<Arc<App>>) -> Response {
let domain = html_escape(&app.cfg.domain);
let relay = html_escape(app.cfg.relays.first().map(String::as_str).unwrap_or("-"));
Html(format!(
r#"<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Floonet</title>
<style>
body{{margin:0;background:#0E0E0C;color:#FAFAF7;font:16px/1.5 system-ui,sans-serif;
display:flex;min-height:100vh;align-items:center;justify-content:center}}
main{{max-width:520px;padding:32px}}
h1{{font-size:44px;letter-spacing:-1.5px;margin:0 0 12px;color:#FFD60A}}
p{{color:#9A988F;margin:8px 0}}
code{{background:#1A1A17;border-radius:6px;padding:2px 6px;color:#FAFAF7}}
</style></head><body><main>
<h1>Floonet</h1>
<p>A Floonet relay for the Grin community Nostr network.</p>
<p>Names here look like <code>you@{domain}</code>.</p>
<p>Relay: <code>{relay}</code></p>
<p>Please use a Nostr client to connect.</p>
</main></body></html>"#
))
.into_response()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
+33
View File
@@ -0,0 +1,33 @@
// HTTP handlers, grouped by surface. The `routes()` builder wires them onto
// an axum `Router` over the shared `App` state so both `main` and the
// integration tests construct the identical app.
pub mod misc;
pub mod paidapi;
pub mod profile;
pub mod registry;
pub mod wellknown;
use crate::db::App;
use axum::{
routing::{delete, get, post},
Router,
};
use std::sync::Arc;
/// Build the full router over a shared [`App`].
pub fn routes(app: Arc<App>) -> Router {
Router::new()
.route("/.well-known/nostr.json", get(wellknown::well_known))
.route("/api/v1/name/{name}", get(registry::availability))
.route("/api/v1/register", post(registry::register))
.route("/api/v1/register/{name}", delete(registry::unregister))
.route("/api/v1/profile/{name}", get(profile::profile))
.route("/api/v1/by-pubkey/{pubkey}", get(profile::by_pubkey))
.route("/api/v1/paid/{pubkey}", get(paidapi::paid_status))
.route("/api/v1/quote", post(paidapi::quote))
.route("/api/v1/goblinpay/webhook", post(paidapi::goblinpay_webhook))
.route("/api/v1/health", get(misc::health))
.route("/", get(misc::landing))
.with_state(app)
}
+247
View File
@@ -0,0 +1,247 @@
// The paid-resource API surface.
//
// GET /api/v1/paid/{pubkey} write-grant status. This is what the
// relay write policy plugin consults in
// FLOONET_PAY_MODE=write; it never sees
// the GoblinPay token. Lazily refreshes a
// pending grant (poll throttled).
// POST /api/v1/quote NIP-98 authed. Body {"resource": ...}.
// Returns 402 with the price and hosted
// pay URL while due, 200 once paid.
// POST /api/v1/goblinpay/webhook HMAC-verified nudge from GoblinPay.
// Never trusted on its own: it only
// triggers a REST re-poll of the invoice,
// so replays cannot grant anything.
use crate::auth::verify_nip98;
use crate::config::PayMode;
use crate::db::App;
use crate::names::valid_pubkey_hex;
use crate::paid::{ensure_paid, payment_required_json, PaidOutcome};
use crate::util::{client_ip, ct_eq};
use axum::{
extract::{Path, State},
http::{HeaderMap, Method, StatusCode},
response::{IntoResponse, Response},
Json,
};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use serde_json::json;
use sha2::Sha256;
use std::sync::Arc;
/// GET /api/v1/paid/{pubkey}: does this pubkey hold a confirmed `write`
/// grant? Public and cheap by design (the plugin calls it on the write path);
/// answers from the grants table, refreshing a pending grant at most once per
/// poll interval. In free mode everything is paid.
pub async fn paid_status(
State(app): State<Arc<App>>,
Path(pubkey): Path<String>,
headers: HeaderMap,
) -> Response {
if !app.allow_read(&client_ip(&headers)) {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error": "rate_limited"})),
)
.into_response();
}
let pubkey = pubkey.to_lowercase();
if !valid_pubkey_hex(&pubkey) {
return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
}
if app.cfg.pay_mode != PayMode::Write {
// Not selling write access: every pubkey may write.
return Json(json!({"pubkey": pubkey, "paid": true})).into_response();
}
// Fast path: answer from the table without touching GoblinPay.
if let Some(grant) = app.grant(&pubkey, "write") {
if grant.status == "paid" {
return Json(json!({"pubkey": pubkey, "paid": true})).into_response();
}
// Pending: give ensure_paid a (throttled) chance to see settlement.
let (app2, pk) = (app.clone(), pubkey.clone());
let outcome = tokio::task::spawn_blocking(move || ensure_paid(&app2, &pk, "write"))
.await
.unwrap_or_else(|e| PaidOutcome::Unavailable(format!("join error: {e}")));
let paid = matches!(outcome, PaidOutcome::Paid);
return Json(json!({"pubkey": pubkey, "paid": paid})).into_response();
}
// No grant at all: not paid. Quoting/creating invoices is NOT done here;
// that is the authenticated /api/v1/quote endpoint, so an unauthenticated
// scraper can never mint invoices.
Json(json!({"pubkey": pubkey, "paid": false})).into_response()
}
#[derive(Deserialize)]
struct QuoteBody {
resource: String,
}
/// POST /api/v1/quote (NIP-98): quote a paid resource for the authenticated
/// pubkey. Creates (or reuses) the GoblinPay invoice and returns 402 with the
/// pay URL while payment is due, 200 {"paid": true} once confirmed. This is
/// how a client obtains the pay page for `write` access; for `name`, the
/// register endpoint returns the same 402 shape on its own.
pub async fn quote(
State(app): State<Arc<App>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Response {
let ip = client_ip(&headers);
if !app.allow_write("quote", &ip) {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error": "rate_limited"})),
)
.into_response();
}
let (auth_pubkey, auth_id) = match verify_nip98(
&headers,
&Method::POST,
"/api/v1/quote",
&body,
&app.cfg.base_url,
app.cfg.auth_max_age_secs,
) {
Ok(v) => v,
Err((code, msg)) => return (code, Json(json!({"error": msg}))).into_response(),
};
if !app.auth_event_fresh(&auth_id) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "auth event replayed"})),
)
.into_response();
}
let req: QuoteBody = match serde_json::from_slice(&body) {
Ok(r) => r,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "invalid body"})),
)
.into_response()
}
};
let resource = req.resource;
let sellable = match (app.cfg.pay_mode, resource.as_str()) {
(PayMode::Name, "name") => true,
(PayMode::Write, "write") => true,
_ => false,
};
if !sellable {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "resource not for sale on this authority"})),
)
.into_response();
}
let (app2, pk, res2) = (app.clone(), auth_pubkey.clone(), resource.clone());
let outcome = tokio::task::spawn_blocking(move || ensure_paid(&app2, &pk, &res2))
.await
.unwrap_or_else(|e| PaidOutcome::Unavailable(format!("join error: {e}")));
match outcome {
PaidOutcome::Paid => Json(json!({
"resource": resource,
"pubkey": auth_pubkey,
"paid": true,
}))
.into_response(),
PaidOutcome::Due {
invoice_id,
pay_url,
price_nanogrin,
} => (
StatusCode::PAYMENT_REQUIRED,
Json(payment_required_json(
&resource,
&invoice_id,
&pay_url,
price_nanogrin,
)),
)
.into_response(),
PaidOutcome::Unavailable(e) => {
tracing::error!("quote unavailable: {e}");
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "payment backend unavailable, try again"})),
)
.into_response()
}
}
}
/// POST /api/v1/goblinpay/webhook: GoblinPay's payment notification
/// (HMAC-SHA256 over the raw body, `X-GoblinPay-Signature: sha256=<hex>`).
/// Verified against GOBLINPAY_WEBHOOK_SECRET, then used ONLY as a nudge: the
/// matching pending grant is re-polled over the authenticated REST API, so a
/// replayed or forged-but-signed delivery cannot grant anything on its own.
/// 404 when no secret is configured (feature off).
pub async fn goblinpay_webhook(
State(app): State<Arc<App>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Response {
let Some(secret) = app.cfg.goblinpay_webhook_secret.clone() else {
return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
};
let provided = headers
.get("x-goblinpay-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac accepts any key");
mac.update(&body);
let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
if !ct_eq(provided.trim().as_bytes(), expected.as_bytes()) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "bad signature"})),
)
.into_response();
}
let Ok(payload) = serde_json::from_slice::<serde_json::Value>(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "invalid body"})),
)
.into_response();
};
let Some(invoice_id) = payload.get("invoice_id").and_then(|v| v.as_str()) else {
// Signed but not about an invoice we track; acknowledge so GoblinPay
// stops retrying.
return Json(json!({"ok": true})).into_response();
};
let Some(grant) = app.grant_by_invoice(invoice_id) else {
return Json(json!({"ok": true})).into_response();
};
if grant.status != "paid" {
// Confirm via REST before promoting (the webhook is only a nudge).
let app2 = app.clone();
let iid = invoice_id.to_string();
let confirmed = tokio::task::spawn_blocking(move || {
app2.paywall
.as_ref()
.map(|p| p.backend.invoice_status(&iid))
.transpose()
.ok()
.flatten()
.map(|s| s == "paid")
.unwrap_or(false)
})
.await
.unwrap_or(false);
if confirmed {
app.mark_grant_paid(invoice_id);
tracing::info!(
"webhook confirmed payment: {} for {} ({})",
invoice_id,
grant.pubkey,
grant.resource
);
}
}
Json(json!({"ok": true})).into_response()
}
+80
View File
@@ -0,0 +1,80 @@
// Public profile lookup: `/api/v1/profile/{name}`.
use crate::db::App;
use crate::names::{valid_name, valid_pubkey_hex};
use crate::util::client_ip;
use axum::{
extract::{Path, State},
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use std::sync::Arc;
/// GET /api/v1/profile/{name} — public profile: the active pubkey for a name.
/// Avatars are not served here; clients render them from the pubkey.
pub async fn profile(
State(app): State<Arc<App>>,
Path(name): Path<String>,
headers: HeaderMap,
) -> Response {
if !app.allow_read(&client_ip(&headers)) {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error": "rate_limited"})),
)
.into_response();
}
let name = name.to_lowercase();
if !valid_name(&name, app.cfg.name_min, app.cfg.name_max) {
return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
}
match app.lookup(&name) {
Some(pubkey) => (
[
(header::CONTENT_TYPE, "application/json"),
(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
(header::CACHE_CONTROL, "no-store"),
],
json!({"name": name, "pubkey": pubkey}).to_string(),
)
.into_response(),
None => (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response(),
}
}
/// GET /api/v1/by-pubkey/{pubkey} — reverse lookup: the active name a pubkey
/// holds, if any. This is the authoritative answer to "what's this key's
/// @username", and unlike the kind-0 + well-known dance it needs a single
/// request — so a client can show a contact's name even when it can't fetch
/// their published profile. Returns `{name, pubkey}` or 404.
pub async fn by_pubkey(
State(app): State<Arc<App>>,
Path(pubkey): Path<String>,
headers: HeaderMap,
) -> Response {
if !app.allow_read(&client_ip(&headers)) {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error": "rate_limited"})),
)
.into_response();
}
let pubkey = pubkey.to_lowercase();
if !valid_pubkey_hex(&pubkey) {
return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
}
match app.name_of(&pubkey) {
Some(name) => (
[
(header::CONTENT_TYPE, "application/json"),
(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
(header::CACHE_CONTROL, "no-store"),
],
json!({"name": name, "pubkey": pubkey}).to_string(),
)
.into_response(),
None => (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response(),
}
}
+338
View File
@@ -0,0 +1,338 @@
// The name registry: availability, register, release. In paid mode (`name`),
// registration is gated on a confirmed GoblinPay payment.
use crate::auth::verify_nip98;
use crate::config::PayMode;
use crate::db::App;
use crate::names::{is_reserved, valid_name, valid_pubkey_hex};
use crate::paid::{ensure_paid, payment_required_json, PaidOutcome};
use crate::util::{client_ip, unix_now};
use axum::{
extract::{Path, State},
http::{HeaderMap, Method, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
pub async fn availability(
State(app): State<Arc<App>>,
headers: HeaderMap,
Path(name): Path<String>,
) -> Response {
if !app.allow_read(&client_ip(&headers)) {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error": "rate_limited"})),
)
.into_response();
}
let name = name.to_lowercase();
if !valid_name(&name, app.cfg.name_min, app.cfg.name_max) {
return (
StatusCode::OK,
Json(json!({"name": name, "available": false, "reason": "invalid"})),
)
.into_response();
}
if is_reserved(&name, &app.cfg.extra_reserved) {
return (
StatusCode::OK,
Json(json!({"name": name, "available": false, "reason": "reserved"})),
)
.into_response();
}
if app.lookup(&name).is_some() {
return (
StatusCode::OK,
Json(json!({"name": name, "available": false, "reason": "taken"})),
)
.into_response();
}
(
StatusCode::OK,
Json(json!({"name": name, "available": true})),
)
.into_response()
}
#[derive(Deserialize)]
struct RegisterBody {
name: String,
pubkey: String,
}
pub async fn register(
State(app): State<Arc<App>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Response {
let ip = client_ip(&headers);
if !app.allow_write("reg", &ip) {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error": "rate_limited"})),
)
.into_response();
}
let (auth_pubkey, auth_id) = match verify_nip98(
&headers,
&Method::POST,
"/api/v1/register",
&body,
&app.cfg.base_url,
app.cfg.auth_max_age_secs,
) {
Ok(v) => v,
Err((code, msg)) => return (code, Json(json!({"error": msg}))).into_response(),
};
if !app.auth_event_fresh(&auth_id) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "auth event replayed"})),
)
.into_response();
}
// The cooldown is set by a *release*, not a claim: it blocks re-registering
// a new name for the cooldown window after you let one go (anti-churn),
// while claiming itself never locks you out of an immediate release.
// Checked after auth so strangers can't probe someone's budget.
if app.cooldown_active("namechange", &auth_pubkey, app.cfg.name_change_cooldown) {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error": "name_change_cooldown"})),
)
.into_response();
}
let req: RegisterBody = match serde_json::from_slice(&body) {
Ok(r) => r,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "invalid body"})),
)
.into_response()
}
};
let name = req.name.to_lowercase();
let pubkey = req.pubkey.to_lowercase();
if !valid_pubkey_hex(&pubkey) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "invalid pubkey"})),
)
.into_response();
}
if pubkey != auth_pubkey {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "auth pubkey does not match body pubkey"})),
)
.into_response();
}
if !valid_name(&name, app.cfg.name_min, app.cfg.name_max) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "invalid name"})),
)
.into_response();
}
if is_reserved(&name, &app.cfg.extra_reserved) {
return (
StatusCode::FORBIDDEN,
Json(json!({"error": "name reserved"})),
)
.into_response();
}
// Existing active registration of this exact name.
if let Some(owner) = app.lookup(&name) {
if owner == pubkey {
return (
StatusCode::OK,
Json(json!({"name": name, "nip05": format!("{name}@{}", app.cfg.domain)})),
)
.into_response();
}
return (StatusCode::CONFLICT, Json(json!({"error": "name taken"}))).into_response();
}
// One active name per pubkey.
if let Some(existing) = app.name_of(&pubkey) {
return (
StatusCode::CONFLICT,
Json(json!({"error": "pubkey already has a name", "name": existing})),
)
.into_response();
}
// Paid names: with FLOONET_PAY_MODE=name, a confirmed GoblinPay payment
// must exist before the claim goes through. The 402 body carries the
// hosted pay URL so a client can send the payer straight there and retry
// this same call once the invoice settles. Checked AFTER validation and
// availability so nobody is asked to pay for an impossible claim.
if app.cfg.pay_mode == PayMode::Name {
let (app2, pk) = (app.clone(), auth_pubkey.clone());
let outcome = tokio::task::spawn_blocking(move || ensure_paid(&app2, &pk, "name"))
.await
.unwrap_or_else(|e| PaidOutcome::Unavailable(format!("join error: {e}")));
match outcome {
PaidOutcome::Paid => {}
PaidOutcome::Due {
invoice_id,
pay_url,
price_nanogrin,
} => {
return (
StatusCode::PAYMENT_REQUIRED,
Json(payment_required_json(
"name",
&invoice_id,
&pay_url,
price_nanogrin,
)),
)
.into_response();
}
PaidOutcome::Unavailable(e) => {
tracing::error!("paid check unavailable: {e}");
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "payment backend unavailable, try again"})),
)
.into_response();
}
}
}
// INSERT guarded by the name PRIMARY KEY and the partial-unique pubkey
// index. The ON CONFLICT(name) only revives a released name; a concurrent
// double-register (same pubkey, different names) is caught by the unique
// pubkey index and surfaces as a constraint error -> 409.
let res = app.db.lock().execute(
"INSERT INTO names (name, pubkey, created_at) VALUES (?1, ?2, ?3)
ON CONFLICT(name) DO UPDATE SET pubkey = excluded.pubkey,
created_at = excluded.created_at, released_at = NULL
WHERE names.released_at IS NOT NULL",
rusqlite::params![name, pubkey, unix_now()],
);
match res {
// rows == 0 means the ON CONFLICT no-op fired (name already active):
// not acquired, report a conflict rather than a false success.
Ok(0) => (StatusCode::CONFLICT, Json(json!({"error": "name taken"}))).into_response(),
Ok(_) => {
// A paid claim consumes its grant: releasing this name later and
// claiming another requires a fresh payment.
if app.cfg.pay_mode == PayMode::Name {
app.consume_grant(&auth_pubkey, "name");
}
// No record_op here: claiming a name must not start a cooldown,
// so a user can claim and then immediately release if they change
// their mind. Only release arms the cooldown.
tracing::info!("registered {name} -> {pubkey}");
(
StatusCode::CREATED,
Json(json!({"name": name, "nip05": format!("{name}@{}", app.cfg.domain)})),
)
.into_response()
}
Err(rusqlite::Error::SqliteFailure(e, _))
if e.code == rusqlite::ErrorCode::ConstraintViolation =>
{
// The partial-unique pubkey index rejected a second active name.
(
StatusCode::CONFLICT,
Json(json!({"error": "pubkey already has a name"})),
)
.into_response()
}
Err(e) => {
tracing::error!("db insert failed: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "db error"})),
)
.into_response()
}
}
}
pub async fn unregister(
State(app): State<Arc<App>>,
Path(name): Path<String>,
headers: HeaderMap,
) -> Response {
let ip = client_ip(&headers);
if !app.allow_write("unreg", &ip) {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error": "rate_limited"})),
)
.into_response();
}
let name = name.to_lowercase();
let path = format!("/api/v1/register/{name}");
let (auth_pubkey, auth_id) = match verify_nip98(
&headers,
&Method::DELETE,
&path,
&[],
&app.cfg.base_url,
app.cfg.auth_max_age_secs,
) {
Ok(v) => v,
Err((code, msg)) => return (code, Json(json!({"error": msg}))).into_response(),
};
if !app.auth_event_fresh(&auth_id) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "auth event replayed"})),
)
.into_response();
}
// Release is always allowed (no cooldown check): you can let a name go the
// instant after claiming it. Releasing is what *arms* the cooldown below,
// which then blocks re-registering a new name for the cooldown window.
match app.lookup(&name) {
Some(owner) if owner == auth_pubkey => {
let res = app.db.lock().execute(
"UPDATE names SET released_at = ?2 WHERE name = ?1 AND released_at IS NULL",
rusqlite::params![name, unix_now()],
);
match res {
Ok(_) => {
app.record_op("namechange", &auth_pubkey);
tracing::info!("released {name}");
(
StatusCode::OK,
Json(json!({"name": name, "released": true})),
)
.into_response()
}
Err(e) => {
tracing::error!("db release failed: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "db error"})),
)
.into_response()
}
}
}
Some(_) => (
StatusCode::FORBIDDEN,
Json(json!({"error": "not the owner"})),
)
.into_response(),
None => (
StatusCode::NOT_FOUND,
Json(json!({"error": "name not found"})),
)
.into_response(),
}
}
+54
View File
@@ -0,0 +1,54 @@
// NIP-05 resolution: `/.well-known/nostr.json?name=<name>`.
use crate::db::App;
use crate::names::valid_name;
use crate::util::client_ip;
use axum::{
extract::{Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
#[derive(Deserialize)]
pub struct WellKnownParams {
name: Option<String>,
}
pub async fn well_known(
State(app): State<Arc<App>>,
headers: axum::http::HeaderMap,
Query(params): Query<WellKnownParams>,
) -> Response {
if !app.allow_read(&client_ip(&headers)) {
return (
StatusCode::TOO_MANY_REQUESTS,
[(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")],
Json(json!({"error": "rate_limited"})),
)
.into_response();
}
let mut names = serde_json::Map::new();
let mut relays = serde_json::Map::new();
if let Some(name) = params.name.map(|n| n.to_lowercase()) {
if valid_name(&name, app.cfg.name_min, app.cfg.name_max) {
if let Some(pk) = app.lookup(&name) {
names.insert(name, json!(pk.clone()));
relays.insert(pk, json!(app.cfg.relays));
}
}
}
let body = json!({ "names": names, "relays": relays });
(
[
(header::CONTENT_TYPE, "application/json"),
(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
(header::CACHE_CONTROL, "no-store"),
],
body.to_string(),
)
.into_response()
}
+25
View File
@@ -0,0 +1,25 @@
// floonet-name-authority — the name authority bundled with a Floonet relay.
//
// `name@yourdomain` -> nostr pubkey, with NIP-98-authenticated self-service
// registration, and an optional GoblinPay paywall for paid names and paid
// relay write access. Avatars are not stored here: clients render them
// deterministically from the pubkey. The relay is a separate service; this
// crate advertises it in `/.well-known/nostr.json` and answers the relay
// write policy plugin's paid-status lookups.
//
// The crate is split so HTTP integration tests can build the same router the
// binary serves: construct an `App` (use `:memory:` for the db), then
// `handlers::routes(app)`.
pub mod auth;
pub mod config;
pub mod db;
pub mod handlers;
pub mod names;
pub mod paid;
pub mod ratelimit;
pub mod util;
pub use config::Config;
pub use db::App;
pub use handlers::routes;
+49
View File
@@ -0,0 +1,49 @@
// floonet-name-authority — the name authority bundled with a Floonet relay.
//
// Endpoints (see the repo README for the full table):
// GET /.well-known/nostr.json?name=<name> NIP-05 resolution (CORS *)
// GET /api/v1/name/{name} availability check
// POST /api/v1/register {name, pubkey} + NIP-98 auth
// DELETE /api/v1/register/{name} NIP-98 auth by owner
// GET /api/v1/profile/{name} public profile (pubkey)
// GET /api/v1/by-pubkey/{pubkey} reverse lookup
// GET /api/v1/paid/{pubkey} write-grant status (plugin)
// POST /api/v1/quote NIP-98; price/pay URL
// POST /api/v1/goblinpay/webhook HMAC-verified payment nudge
// GET /api/v1/health liveness
// GET / landing page
use floonet_name_authority::{handlers, App, Config};
use std::sync::Arc;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
let cfg = match Config::from_env() {
Ok(c) => c,
Err(e) => {
tracing::error!("configuration error: {e}");
eprintln!("configuration error: {e}");
std::process::exit(1);
}
};
tracing::info!("resolved config: {}", cfg.summary());
let bind = cfg.bind_addr.clone();
let app = Arc::new(App::open(cfg));
let router = handlers::routes(app);
let listener = tokio::net::TcpListener::bind(&bind).await.expect("bind");
tracing::info!("floonet-name-authority listening on {bind}");
axum::serve(listener, router)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await
.expect("server");
}
+221
View File
@@ -0,0 +1,221 @@
// Name and pubkey rules: validity, the reserved list, and look-alike folding
// that stops digit/separator homographs of reserved terms.
/// Built-in reserved names. These are generic infrastructure, role and
/// finance terms that no operator should hand out as a payment identity —
/// they are domain-agnostic on purpose. The operator's own brand is reserved
/// separately and dynamically from their domain (see
/// [`domain_reserved`]); operators can add more via a reserved file
/// (see [`crate::config::Config::extra_reserved`]).
pub const RESERVED: &[&str] = &[
"admin",
"administrator",
"root",
"support",
"help",
"info",
"mail",
"email",
"www",
"relay",
"nostr",
"pay",
"payment",
"payments",
"wallet",
"official",
"security",
"abuse",
"postmaster",
"hostmaster",
"webmaster",
"contact",
"team",
"staff",
"mod",
"moderator",
"moderators",
"system",
"bot",
"api",
"app",
"dev",
"developer",
"test",
"testing",
"anonymous",
"anon",
"null",
"void",
"owner",
"ceo",
"register",
"registration",
"account",
"accounts",
"verify",
"verified",
"billing",
"donate",
"treasury",
"faucet",
"exchange",
"swap",
"bank",
"money",
"cash",
"fees",
"fee",
"node",
"miner",
"mining",
"explorer",
"status",
"blog",
"news",
"docs",
"wiki",
"store",
"shop",
];
/// True when `name` satisfies the length bounds and character rules: ASCII
/// lowercase alphanumerics plus `. _ -`, starting and ending alphanumeric.
pub fn valid_name(name: &str, name_min: usize, name_max: usize) -> bool {
let len = name.chars().count();
if !(name_min..=name_max).contains(&len) {
return false;
}
let bytes = name.as_bytes();
let ok_char =
|c: u8| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'.' | b'_' | b'-');
if !bytes.iter().all(|&c| ok_char(c)) {
return false;
}
let first = bytes[0];
let last = bytes[bytes.len() - 1];
(first.is_ascii_lowercase() || first.is_ascii_digit())
&& (last.is_ascii_lowercase() || last.is_ascii_digit())
}
/// Fold a name to catch separator/digit look-alikes of reserved terms, so
/// `g0blin`, `g-o-b-l-i-n` and `supp0rt` can't impersonate `goblin`/`support`
/// as payment identities. Conservative: a name is only blocked when its folded
/// form exactly equals a reserved term's folded form (so `goblinfan` stays free).
pub fn fold_lookalike(name: &str) -> String {
name.chars()
.filter_map(|c| match c {
'.' | '_' | '-' => None,
'0' => Some('o'),
'1' => Some('i'),
'3' => Some('e'),
'4' => Some('a'),
'5' => Some('s'),
'7' => Some('t'),
'8' => Some('b'),
'9' => Some('g'),
c => Some(c),
})
.collect()
}
/// True when `name` is reserved outright or folds onto a reserved term. The
/// `extra` slice holds the operator's domain labels and any names from the
/// optional reserved file (see [`crate::config::Config::extra_reserved`]).
pub fn is_reserved(name: &str, extra: &[String]) -> bool {
if RESERVED.contains(&name) || extra.iter().any(|r| r == name) {
return true;
}
let folded = fold_lookalike(name);
RESERVED.iter().any(|r| fold_lookalike(r) == folded)
|| extra.iter().any(|r| fold_lookalike(r) == folded)
}
/// Reserved names derived from the operator's own domain, so a domain's brand
/// can't be claimed (or look-alike-folded) as a payment handle. Each dot label
/// except the final TLD is reserved: `goblin.st` → `["goblin"]`,
/// `names.acme.example` → `["names", "acme"]`. A single-label host (e.g.
/// `localhost`) reserves that label. Lowercased; empty labels dropped.
pub fn domain_reserved(domain: &str) -> Vec<String> {
let labels: Vec<&str> = domain
.trim()
.trim_end_matches('.')
.split('.')
.filter(|l| !l.is_empty())
.collect();
let keep = if labels.len() > 1 {
&labels[..labels.len() - 1]
} else {
&labels[..]
};
keep.iter().map(|l| l.to_lowercase()).collect()
}
pub fn valid_pubkey_hex(pk: &str) -> bool {
pk.len() == 64
&& pk
.bytes()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
}
#[cfg(test)]
mod tests {
use super::*;
const MIN: usize = 3;
const MAX: usize = 20;
#[test]
fn name_validation() {
assert!(valid_name("ada", MIN, MAX));
assert!(valid_name("ada.wren-99_x", MIN, MAX));
assert!(!valid_name("ab", MIN, MAX));
assert!(!valid_name("Ada", MIN, MAX));
assert!(!valid_name(".ada", MIN, MAX));
assert!(!valid_name("ada.", MIN, MAX));
assert!(!valid_name("a d a", MIN, MAX));
assert!(!valid_name(&"a".repeat(21), MIN, MAX));
assert!(valid_name(&"a".repeat(20), MIN, MAX));
assert!(!valid_name("päge", MIN, MAX));
}
#[test]
fn reserved_and_lookalikes() {
// Generic infra/role terms are reserved out of the box, with folding.
assert!(is_reserved("support", &[]));
assert!(is_reserved("supp0rt", &[]));
assert!(is_reserved("adm1n", &[]));
// Brand terms are NOT built in — they come from the domain labels.
assert!(!is_reserved("goblin", &[]));
// Operator/domain-supplied extras work both literally and folded.
assert!(is_reserved("acme", &["acme".to_string()]));
assert!(is_reserved("acm3", &["acme".to_string()]));
assert!(!is_reserved("acmecorp", &["acme".to_string()]));
}
#[test]
fn domain_labels_reserved() {
assert_eq!(domain_reserved("goblin.st"), vec!["goblin"]);
assert_eq!(domain_reserved("acme.example"), vec!["acme"]);
assert_eq!(domain_reserved("names.acme.example"), vec!["names", "acme"]);
assert_eq!(domain_reserved("GOBLIN.ST"), vec!["goblin"]);
assert_eq!(domain_reserved("localhost"), vec!["localhost"]);
// The brand and its look-alikes fall to is_reserved via these labels.
let extra = domain_reserved("goblin.st");
assert!(is_reserved("goblin", &extra));
assert!(is_reserved("g0blin", &extra));
assert!(is_reserved("g-o-b-l-i-n", &extra));
assert!(!is_reserved("goblinfan", &extra));
}
#[test]
fn pubkey_validation() {
assert!(valid_pubkey_hex(
"91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05"
));
assert!(!valid_pubkey_hex(
"91CF9DBBEA5E6511FD2BBB190B112055EE4131C5D2BBB9FAEDF3EE8CBEAC0D05"
));
assert!(!valid_pubkey_hex("abc"));
}
}
+507
View File
@@ -0,0 +1,507 @@
// The paid-resource layer: one GoblinPay-backed mechanism applied to many
// paid uses. `name` (pay to claim a name) and `write` (pay to publish on the
// relay) are the built-in resources; a future paid use (e.g. media/blob
// storage over NIP-96 or Blossom) is the same pattern: pick a resource id,
// give it a price, gate its endpoint on `ensure_paid`.
//
// Design notes:
// * The GoblinPay conversation is fully owned by this authority. The relay
// write policy plugin only ever asks "is this pubkey paid?"; it never
// holds the GoblinPay token.
// * GoblinPay's REST status is the single source of truth. Webhooks (when
// configured) are a nudge to re-poll, never trusted on their own, so a
// replayed delivery can grant nothing the REST API does not confirm.
// * Fail closed: any transport error means "not paid yet" and the caller
// keeps returning 402 with the same pay URL.
use crate::config::{nanogrin_to_grin, Config, PayMode};
use crate::db::App;
use crate::util::unix_now;
use std::sync::Arc;
/// A created (or previously created) GoblinPay invoice for a grant.
#[derive(Debug, Clone)]
pub struct Invoice {
pub id: String,
pub pay_url: String,
pub status: String,
}
/// The payment backend. Boxed as a trait so tests can substitute a mock and
/// a future backend can slot in without touching the grant logic.
pub trait PayBackend: Send + Sync {
/// Create an invoice; returns its id, hosted pay URL and status.
fn create_invoice(
&self,
order_ref: &str,
amount_nanogrin: u64,
memo: &str,
) -> Result<Invoice, String>;
/// Current status of an invoice: `open`, `paid` or `expired`.
fn invoice_status(&self, invoice_id: &str) -> Result<String, String>;
}
/// The real GoblinPay REST backend (`POST /invoice`, `GET /invoice/{id}`,
/// Bearer-token auth). Blocking (ureq); call via `spawn_blocking`.
pub struct GoblinPay {
pub url: String,
token: String,
agent: ureq::Agent,
}
impl GoblinPay {
pub fn new(url: String, token: String) -> Self {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
.build();
GoblinPay { url, token, agent }
}
}
impl PayBackend for GoblinPay {
fn create_invoice(
&self,
order_ref: &str,
amount_nanogrin: u64,
memo: &str,
) -> Result<Invoice, String> {
let resp = self
.agent
.post(&format!("{}/invoice", self.url))
.set("Authorization", &format!("Bearer {}", self.token))
.send_json(serde_json::json!({
"order_ref": order_ref,
"amount_grin": amount_nanogrin,
"memo": memo,
}))
.map_err(|e| format!("goblinpay create invoice: {e}"))?;
let body: serde_json::Value = resp
.into_json()
.map_err(|e| format!("goblinpay create invoice body: {e}"))?;
parse_invoice(&body)
}
fn invoice_status(&self, invoice_id: &str) -> Result<String, String> {
let resp = self
.agent
.get(&format!("{}/invoice/{invoice_id}", self.url))
.set("Authorization", &format!("Bearer {}", self.token))
.call()
.map_err(|e| format!("goblinpay invoice status: {e}"))?;
let body: serde_json::Value = resp
.into_json()
.map_err(|e| format!("goblinpay invoice status body: {e}"))?;
body.get("status")
.and_then(|s| s.as_str())
.map(str::to_string)
.ok_or_else(|| "goblinpay invoice status missing".into())
}
}
fn parse_invoice(body: &serde_json::Value) -> Result<Invoice, String> {
let field = |k: &str| {
body.get(k)
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| format!("goblinpay invoice missing `{k}`"))
};
Ok(Invoice {
id: field("invoice_id")?,
pay_url: field("pay_url")?,
status: field("status")?,
})
}
/// The paywall: a backend plus the operator's prices. Present on the `App`
/// only when `FLOONET_PAY_MODE` is not `off`.
pub struct Paywall {
pub backend: Box<dyn PayBackend>,
pub name_price_nanogrin: u64,
pub write_price_nanogrin: u64,
}
impl Paywall {
pub fn from_config(cfg: &Config) -> Option<Self> {
if cfg.pay_mode == PayMode::Off {
return None;
}
Some(Paywall {
backend: Box::new(GoblinPay::new(
cfg.goblinpay_url.clone(),
cfg.goblinpay_token.clone(),
)),
name_price_nanogrin: cfg.name_price_nanogrin,
write_price_nanogrin: cfg.write_price_nanogrin,
})
}
pub fn price_nanogrin(&self, resource: &str) -> u64 {
match resource {
"name" => self.name_price_nanogrin,
"write" => self.write_price_nanogrin,
_ => 0,
}
}
}
/// Outcome of a paid-resource check.
#[derive(Debug, Clone)]
pub enum PaidOutcome {
/// The pubkey holds a confirmed grant for the resource.
Paid,
/// Payment is still due; here is where to pay.
Due {
invoice_id: String,
pay_url: String,
price_nanogrin: u64,
},
/// The payment backend could not be reached or answered garbage. Callers
/// fail closed (treat as not paid) and surface a retryable error.
Unavailable(String),
}
/// A row in `paid_grants`.
#[derive(Debug, Clone)]
pub struct Grant {
pub pubkey: String,
pub resource: String,
pub invoice_id: String,
pub pay_url: String,
pub amount_nanogrin: u64,
pub status: String, // "pending" | "paid"
}
impl App {
/// The grant row for (pubkey, resource), if any.
pub fn grant(&self, pubkey: &str, resource: &str) -> Option<Grant> {
self.db
.lock()
.query_row(
"SELECT pubkey, resource, invoice_id, pay_url, amount_nanogrin, status
FROM paid_grants WHERE pubkey = ?1 AND resource = ?2",
rusqlite::params![pubkey, resource],
|r| {
Ok(Grant {
pubkey: r.get(0)?,
resource: r.get(1)?,
invoice_id: r.get(2)?,
pay_url: r.get(3)?,
amount_nanogrin: r.get::<_, i64>(4)? as u64,
status: r.get(5)?,
})
},
)
.ok()
}
/// Insert or replace the pending grant for (pubkey, resource).
pub fn put_pending_grant(&self, pubkey: &str, resource: &str, inv: &Invoice, amount: u64) {
let _ = self.db.lock().execute(
"INSERT INTO paid_grants
(pubkey, resource, invoice_id, pay_url, amount_nanogrin, status, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6)
ON CONFLICT(pubkey, resource) DO UPDATE SET
invoice_id = excluded.invoice_id, pay_url = excluded.pay_url,
amount_nanogrin = excluded.amount_nanogrin, status = 'pending',
created_at = excluded.created_at, paid_at = NULL",
rusqlite::params![pubkey, resource, inv.id, inv.pay_url, amount as i64, unix_now()],
);
}
/// Mark the grant holding `invoice_id` as paid. Returns true if a row
/// changed.
pub fn mark_grant_paid(&self, invoice_id: &str) -> bool {
self.db
.lock()
.execute(
"UPDATE paid_grants SET status = 'paid', paid_at = ?2
WHERE invoice_id = ?1 AND status != 'paid'",
rusqlite::params![invoice_id, unix_now()],
)
.map(|n| n > 0)
.unwrap_or(false)
}
/// Grant row (if any) holding `invoice_id`; used by the webhook receiver.
pub fn grant_by_invoice(&self, invoice_id: &str) -> Option<Grant> {
self.db
.lock()
.query_row(
"SELECT pubkey, resource, invoice_id, pay_url, amount_nanogrin, status
FROM paid_grants WHERE invoice_id = ?1",
[invoice_id],
|r| {
Ok(Grant {
pubkey: r.get(0)?,
resource: r.get(1)?,
invoice_id: r.get(2)?,
pay_url: r.get(3)?,
amount_nanogrin: r.get::<_, i64>(4)? as u64,
status: r.get(5)?,
})
},
)
.ok()
}
/// Delete a grant (used to consume a `name` grant once the registration
/// it paid for succeeds, so a released name needs a fresh payment).
pub fn consume_grant(&self, pubkey: &str, resource: &str) {
let _ = self.db.lock().execute(
"DELETE FROM paid_grants WHERE pubkey = ?1 AND resource = ?2",
rusqlite::params![pubkey, resource],
);
}
}
/// The heart of the paid layer: make sure `pubkey` has paid for `resource`.
///
/// * confirmed grant: `Paid`.
/// * pending grant: poll GoblinPay (throttled by `paid_poll_interval` via the
/// in-memory cooldown map so external callers cannot hammer GoblinPay);
/// `paid` promotes the grant, `expired` rolls a fresh invoice, otherwise
/// the existing pay URL is returned again.
/// * no grant: create an invoice and store a pending grant.
///
/// Blocking (talks to GoblinPay); call via `spawn_blocking` from handlers.
pub fn ensure_paid(app: &Arc<App>, pubkey: &str, resource: &str) -> PaidOutcome {
let Some(paywall) = app.paywall.as_ref() else {
// No paywall configured: everything is free.
return PaidOutcome::Paid;
};
let price = paywall.price_nanogrin(resource);
if let Some(grant) = app.grant(pubkey, resource) {
if grant.status == "paid" {
return PaidOutcome::Paid;
}
// Pending: throttle status polls per grant.
let poll_key = format!("{pubkey}:{resource}");
if !app.cooldown_active("paidpoll", &poll_key, app.cfg.paid_poll_interval) {
app.record_op("paidpoll", &poll_key);
match paywall.backend.invoice_status(&grant.invoice_id) {
Ok(status) if status == "paid" => {
app.mark_grant_paid(&grant.invoice_id);
tracing::info!("grant paid: {resource} for {pubkey}");
return PaidOutcome::Paid;
}
Ok(status) if status == "expired" => {
tracing::info!("invoice expired, rolling a new one: {}", grant.invoice_id);
return new_grant(app, paywall, pubkey, resource, price);
}
Ok(_) => {}
Err(e) => {
tracing::warn!("paid poll failed: {e}");
// Fall through: return the existing pay URL; the client
// can pay/retry regardless of this poll failing.
}
}
}
return PaidOutcome::Due {
invoice_id: grant.invoice_id,
pay_url: grant.pay_url,
price_nanogrin: grant.amount_nanogrin,
};
}
new_grant(app, paywall, pubkey, resource, price)
}
fn new_grant(
app: &Arc<App>,
paywall: &Paywall,
pubkey: &str,
resource: &str,
price: u64,
) -> PaidOutcome {
let order_ref = format!("floonet-{resource}:{pubkey}");
let memo = format!("Floonet {resource} ({})", app.cfg.domain);
match paywall.backend.create_invoice(&order_ref, price, &memo) {
Ok(inv) => {
app.put_pending_grant(pubkey, resource, &inv, price);
PaidOutcome::Due {
invoice_id: inv.id,
pay_url: inv.pay_url,
price_nanogrin: price,
}
}
Err(e) => {
tracing::error!("create invoice failed: {e}");
PaidOutcome::Unavailable(e)
}
}
}
/// The JSON body of a 402 response: everything a client needs to pay and
/// retry, including the hosted GoblinPay page it can send the payer to.
pub fn payment_required_json(
resource: &str,
invoice_id: &str,
pay_url: &str,
price_nanogrin: u64,
) -> serde_json::Value {
serde_json::json!({
"error": "payment_required",
"resource": resource,
"invoice_id": invoice_id,
"pay_url": pay_url,
"price_grin": nanogrin_to_grin(price_nanogrin),
"price_nanogrin": price_nanogrin,
"currency": "GRIN",
})
}
#[doc(hidden)]
pub mod testing {
//! A mock backend for unit/integration tests. Not part of the public
//! API surface; exposed (like `Config::for_test`) because integration
//! tests compile as a separate crate and cannot see `#[cfg(test)]` items.
use super::*;
use parking_lot::Mutex;
use std::collections::HashMap;
#[derive(Default)]
pub struct MockPay {
/// invoice_id -> status
pub statuses: Mutex<HashMap<String, String>>,
pub created: Mutex<Vec<String>>,
pub fail: Mutex<bool>,
counter: Mutex<u64>,
}
impl PayBackend for std::sync::Arc<MockPay> {
fn create_invoice(
&self,
order_ref: &str,
_amount_nanogrin: u64,
_memo: &str,
) -> Result<Invoice, String> {
if *self.fail.lock() {
return Err("mock backend down".into());
}
let mut c = self.counter.lock();
*c += 1;
let id = format!("inv-{}", *c);
self.statuses.lock().insert(id.clone(), "open".into());
self.created.lock().push(order_ref.to_string());
Ok(Invoice {
id: id.clone(),
pay_url: format!("https://pay.example/pay/{id}"),
status: "open".into(),
})
}
fn invoice_status(&self, invoice_id: &str) -> Result<String, String> {
if *self.fail.lock() {
return Err("mock backend down".into());
}
self.statuses
.lock()
.get(invoice_id)
.cloned()
.ok_or_else(|| "unknown invoice".into())
}
}
}
#[cfg(test)]
mod tests {
use super::testing::MockPay;
use super::*;
use crate::config::Config;
/// An app in paid (name) mode plus a shared handle to its mock backend.
fn paid_app() -> (Arc<App>, std::sync::Arc<MockPay>) {
let mock = std::sync::Arc::new(MockPay::default());
let mut cfg = Config::for_test();
cfg.pay_mode = PayMode::Name;
let mut app = App::open(cfg);
app.paywall = Some(Paywall {
backend: Box::new(mock.clone()),
name_price_nanogrin: 1_500_000_000,
write_price_nanogrin: 500_000_000,
});
(Arc::new(app), mock)
}
#[test]
fn free_mode_is_always_paid() {
let app = Arc::new(App::open(Config::for_test()));
assert!(matches!(ensure_paid(&app, &"a".repeat(64), "name"), PaidOutcome::Paid));
}
#[test]
fn unpaid_gets_invoice_then_paid_after_settlement() {
let (app, mock) = paid_app();
let pk = "a".repeat(64);
// First ask: an invoice is created and payment is due.
let due = ensure_paid(&app, &pk, "name");
let PaidOutcome::Due { invoice_id, pay_url, price_nanogrin } = due else {
panic!("expected Due, got {due:?}");
};
assert_eq!(price_nanogrin, 1_500_000_000);
assert!(pay_url.contains(&invoice_id));
// Second ask: same invoice (idempotent), still due.
let again = ensure_paid(&app, &pk, "name");
let PaidOutcome::Due { invoice_id: id2, .. } = again else {
panic!("expected Due");
};
assert_eq!(id2, invoice_id);
// Settle at the backend; the next ask promotes the grant.
mock.statuses.lock().insert(invoice_id.clone(), "paid".into());
assert!(matches!(ensure_paid(&app, &pk, "name"), PaidOutcome::Paid));
// And it stays paid without further polling.
assert!(matches!(ensure_paid(&app, &pk, "name"), PaidOutcome::Paid));
}
#[test]
fn expired_invoice_rolls_a_fresh_one() {
let (app, mock) = paid_app();
let pk = "b".repeat(64);
let PaidOutcome::Due { invoice_id, .. } = ensure_paid(&app, &pk, "name") else {
panic!("expected Due");
};
mock.statuses.lock().insert(invoice_id.clone(), "expired".into());
let PaidOutcome::Due { invoice_id: id2, .. } = ensure_paid(&app, &pk, "name") else {
panic!("expected Due");
};
assert_ne!(id2, invoice_id, "a fresh invoice replaces the expired one");
}
#[test]
fn backend_down_fails_closed() {
let (app, mock) = paid_app();
*mock.fail.lock() = true;
let out = ensure_paid(&app, &"c".repeat(64), "name");
assert!(matches!(out, PaidOutcome::Unavailable(_)));
}
#[test]
fn consume_grant_requires_repayment() {
let (app, _mock) = paid_app();
let pk = "d".repeat(64);
let PaidOutcome::Due { invoice_id, .. } = ensure_paid(&app, &pk, "name") else {
panic!("expected Due");
};
app.mark_grant_paid(&invoice_id);
assert!(matches!(ensure_paid(&app, &pk, "name"), PaidOutcome::Paid));
app.consume_grant(&pk, "name");
assert!(matches!(ensure_paid(&app, &pk, "name"), PaidOutcome::Due { .. }));
}
#[test]
fn resources_are_independent() {
let (app, _mock) = paid_app();
let pk = "e".repeat(64);
let PaidOutcome::Due { invoice_id, .. } = ensure_paid(&app, &pk, "name") else {
panic!("expected Due");
};
app.mark_grant_paid(&invoice_id);
assert!(matches!(ensure_paid(&app, &pk, "name"), PaidOutcome::Paid));
// Paying for a name does not grant write access.
assert!(matches!(ensure_paid(&app, &pk, "write"), PaidOutcome::Due { .. }));
}
}
+119
View File
@@ -0,0 +1,119 @@
// In-memory rate limiting, cooldowns, and NIP-98 replay tracking. All of this
// state is intentionally non-persistent: on restart the replay window and
// cooldowns reset (documented in the security model).
use crate::db::App;
use std::time::{Duration, Instant};
impl App {
/// Record a NIP-98 auth event id as used; returns false if already seen
/// within the freshness window (replay).
pub fn auth_event_fresh(&self, event_id: &str) -> bool {
let now = Instant::now();
let window = Duration::from_secs(self.cfg.auth_max_age_secs as u64 + 5);
let mut seen = self.seen_auth.lock();
seen.retain(|_, t| now.duration_since(*t) < window);
if seen.contains_key(event_id) {
return false;
}
seen.insert(event_id.to_string(), now);
true
}
/// True when an operation in this bucket happened within the window.
/// Check-only — pair with [`Self::record_op`] on success, so failed
/// attempts (taken name, bad auth) never burn the caller's cooldown.
pub fn cooldown_active(&self, bucket: &str, key: &str, window: Duration) -> bool {
let k = format!("{bucket}:{key}");
let now = Instant::now();
let mut map = self.rate.lock();
if let Some(hits) = map.get_mut(&k) {
hits.retain(|t| now.duration_since(*t) < window);
return !hits.is_empty();
}
false
}
/// Record a completed operation for cooldown tracking.
pub fn record_op(&self, bucket: &str, key: &str) {
let k = format!("{bucket}:{key}");
self.rate.lock().entry(k).or_default().push(Instant::now());
}
/// Sliding-window in-memory rate limiter. Returns true when the call is allowed.
pub fn allow(&self, bucket: &str, ip: &str, max: usize, window: Duration) -> bool {
let key = format!("{bucket}:{ip}");
let now = Instant::now();
let mut map = self.rate.lock();
let hits = map.entry(key).or_default();
hits.retain(|t| now.duration_since(*t) < window);
if hits.len() >= max {
return false;
}
hits.push(now);
// Opportunistic global cleanup to bound memory.
if map.len() > 50_000 {
map.retain(|_, v| v.iter().any(|t| now.duration_since(*t) < window));
}
true
}
/// Convenience wrappers using the configured ceilings.
pub fn allow_read(&self, ip: &str) -> bool {
self.allow(
"read",
ip,
self.cfg.read_rate_max,
self.cfg.read_rate_window,
)
}
pub fn allow_write(&self, bucket: &str, ip: &str) -> bool {
self.allow(
bucket,
ip,
self.cfg.write_rate_max,
self.cfg.write_rate_window,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn app() -> App {
App::open(Config::for_test())
}
#[test]
fn allow_caps_at_max() {
let app = app();
let w = Duration::from_secs(60);
for _ in 0..3 {
assert!(app.allow("b", "1.2.3.4", 3, w));
}
assert!(!app.allow("b", "1.2.3.4", 3, w));
// A different IP has its own bucket.
assert!(app.allow("b", "5.6.7.8", 3, w));
}
#[test]
fn auth_replay_detected() {
let app = app();
assert!(app.auth_event_fresh("eventid"));
assert!(!app.auth_event_fresh("eventid"));
}
#[test]
fn cooldown_records_and_clears() {
let app = app();
let w = Duration::from_secs(600);
assert!(!app.cooldown_active("nc", "pk", w));
app.record_op("nc", "pk");
assert!(app.cooldown_active("nc", "pk", w));
// A zero window means nothing is ever "recent".
assert!(!app.cooldown_active("nc", "pk", Duration::ZERO));
}
}
+49
View File
@@ -0,0 +1,49 @@
// Small cross-cutting helpers.
use axum::http::HeaderMap;
use std::time::{SystemTime, UNIX_EPOCH};
pub fn unix_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// The client IP, taken from `X-Real-IP`. SECURITY-CRITICAL: the reverse proxy
/// MUST set this header from the real peer address — all per-IP rate limiting
/// keys off it, so a missing/forgeable value defeats the limiter.
pub fn client_ip(headers: &HeaderMap) -> String {
headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown")
.to_string()
}
/// Constant-time byte equality (for webhook signature comparison). A length
/// mismatch returns early, which leaks only the length — the expected value's
/// length is public anyway (`sha256=` + 64 hex chars).
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::ct_eq;
#[test]
fn ct_eq_basics() {
assert!(ct_eq(b"abc", b"abc"));
assert!(!ct_eq(b"abc", b"abd"));
assert!(!ct_eq(b"abc", b"ab"));
assert!(ct_eq(b"", b""));
}
}
+547
View File
@@ -0,0 +1,547 @@
// HTTP integration tests: drive the real router via `tower::ServiceExt::oneshot`
// with signed NIP-98 auth events, covering the registration and release flows
// (auth/replay/cooldown edge cases) plus the paid-name, paid-write and
// GoblinPay-webhook flows against a mock payment backend.
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use base64::Engine;
use floonet_name_authority::config::PayMode;
use floonet_name_authority::paid::{testing::MockPay, Paywall};
use floonet_name_authority::{handlers, App, Config};
use hmac::{Hmac, Mac};
use http_body_util::BodyExt;
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
use serde_json::Value;
use sha2::{Digest, Sha256};
use tower::ServiceExt;
const BASE_URL: &str = "https://floonet.example";
/// Build a NIP-98 `Authorization: Nostr <b64>` header value, signed by `keys`,
/// for the given method/path/body. `age_secs` ages the event's created_at into
/// the past (negative = post-dated); the default flow uses 0.
fn nip98_header(keys: &Keys, method: &str, path: &str, body: &[u8], age_secs: i64) -> String {
let url = format!("{BASE_URL}{path}");
let mut tags = vec![
Tag::parse(["u", &url]).unwrap(),
Tag::parse(["method", method]).unwrap(),
];
if !body.is_empty() {
let payload = hex::encode(Sha256::digest(body));
tags.push(Tag::parse(["payload", &payload]).unwrap());
}
let created = Timestamp::now().as_secs() as i64 - age_secs;
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.custom_created_at(Timestamp::from_secs(created as u64))
.sign_with_keys(keys)
.unwrap();
let b64 = base64::engine::general_purpose::STANDARD.encode(event.as_json());
format!("Nostr {b64}")
}
fn test_app() -> Arc<App> {
Arc::new(App::open(Config::for_test()))
}
/// An app in the given paid mode wired to a shared mock GoblinPay backend.
fn paid_test_app(mode: PayMode) -> (Arc<App>, std::sync::Arc<MockPay>) {
let mock = std::sync::Arc::new(MockPay::default());
let mut cfg = Config::for_test();
cfg.pay_mode = mode;
cfg.goblinpay_webhook_secret = Some("whsec".into());
let mut app = App::open(cfg);
app.paywall = Some(Paywall {
backend: Box::new(mock.clone()),
name_price_nanogrin: 1_500_000_000,
write_price_nanogrin: 500_000_000,
});
(Arc::new(app), mock)
}
async fn send(app: Arc<App>, req: Request<Body>) -> (StatusCode, Value) {
let resp = handlers::routes(app).oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
};
(status, json)
}
fn register_req(keys: &Keys, name: &str) -> Request<Body> {
register_req_aged(keys, name, 0)
}
/// Like [`register_req`] but ages the NIP-98 event `age_secs` into the past.
/// Retry-style tests need distinct ages: two otherwise-identical auth events
/// signed within the same second share an event id and trip the (correct)
/// replay rejection.
fn register_req_aged(keys: &Keys, name: &str, age_secs: i64) -> Request<Body> {
let body = serde_json::json!({ "name": name, "pubkey": keys.public_key().to_hex() })
.to_string()
.into_bytes();
let auth = nip98_header(keys, "POST", "/api/v1/register", &body, age_secs);
Request::builder()
.method("POST")
.uri("/api/v1/register")
.header("authorization", auth)
.header("x-real-ip", "10.0.0.1")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap()
}
#[tokio::test]
async fn register_happy_path() {
let app = test_app();
let keys = Keys::generate();
let (status, json) = send(app, register_req(&keys, "alice")).await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(json["nip05"], "alice@floonet.example");
}
#[tokio::test]
async fn register_replay_rejected() {
let app = test_app();
let keys = Keys::generate();
let body = serde_json::json!({ "name": "alice", "pubkey": keys.public_key().to_hex() })
.to_string()
.into_bytes();
let auth = nip98_header(&keys, "POST", "/api/v1/register", &body, 0);
let build = || {
Request::builder()
.method("POST")
.uri("/api/v1/register")
.header("authorization", auth.clone())
.header("x-real-ip", "10.0.0.2")
.header("content-type", "application/json")
.body(Body::from(body.clone()))
.unwrap()
};
let (s1, _) = send(app.clone(), build()).await;
assert_eq!(s1, StatusCode::CREATED);
// Same signed auth event a second time -> replay rejection.
let (s2, json) = send(app, build()).await;
assert_eq!(s2, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"], "auth event replayed");
}
#[tokio::test]
async fn register_expired_auth_rejected() {
let app = test_app();
let keys = Keys::generate();
let body = serde_json::json!({ "name": "alice", "pubkey": keys.public_key().to_hex() })
.to_string()
.into_bytes();
// 120s in the past, older than the 60s max age.
let auth = nip98_header(&keys, "POST", "/api/v1/register", &body, 120);
let req = Request::builder()
.method("POST")
.uri("/api/v1/register")
.header("authorization", auth)
.header("x-real-ip", "10.0.0.3")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let (status, json) = send(app, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"], "auth event expired or post-dated");
}
#[tokio::test]
async fn register_u_tag_mismatch_rejected() {
let app = test_app();
let keys = Keys::generate();
let body = serde_json::json!({ "name": "alice", "pubkey": keys.public_key().to_hex() })
.to_string()
.into_bytes();
// Sign for the wrong path so the u-tag won't match.
let auth = nip98_header(&keys, "POST", "/api/v1/profile/alice", &body, 0);
let req = Request::builder()
.method("POST")
.uri("/api/v1/register")
.header("authorization", auth)
.header("x-real-ip", "10.0.0.4")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let (status, json) = send(app, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"], "auth event url mismatch");
}
#[tokio::test]
async fn register_wrong_pubkey_rejected() {
let app = test_app();
let signer = Keys::generate();
let other = Keys::generate();
// Body claims `other`'s pubkey but is signed by `signer`.
let body = serde_json::json!({ "name": "alice", "pubkey": other.public_key().to_hex() })
.to_string()
.into_bytes();
let auth = nip98_header(&signer, "POST", "/api/v1/register", &body, 0);
let req = Request::builder()
.method("POST")
.uri("/api/v1/register")
.header("authorization", auth)
.header("x-real-ip", "10.0.0.5")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let (status, json) = send(app, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"], "auth pubkey does not match body pubkey");
}
#[tokio::test]
async fn taken_name_conflicts() {
let app = test_app();
let alice = Keys::generate();
let bob = Keys::generate();
let (s1, _) = send(app.clone(), register_req(&alice, "shared")).await;
assert_eq!(s1, StatusCode::CREATED);
let (s2, json) = send(app, register_req(&bob, "shared")).await;
assert_eq!(s2, StatusCode::CONFLICT);
assert_eq!(json["error"], "name taken");
}
#[tokio::test]
async fn second_name_per_key_conflicts() {
let app = test_app();
let keys = Keys::generate();
let (s1, _) = send(app.clone(), register_req(&keys, "first")).await;
assert_eq!(s1, StatusCode::CREATED);
let (s2, json) = send(app, register_req(&keys, "second")).await;
assert_eq!(s2, StatusCode::CONFLICT);
assert_eq!(json["error"], "pubkey already has a name");
}
#[tokio::test]
async fn release_arms_cooldown_blocking_reregister() {
let app = test_app();
let keys = Keys::generate();
let (s1, _) = send(app.clone(), register_req(&keys, "alice")).await;
assert_eq!(s1, StatusCode::CREATED);
// Release the name.
let del_auth = nip98_header(&keys, "DELETE", "/api/v1/register/alice", &[], 0);
let del = Request::builder()
.method("DELETE")
.uri("/api/v1/register/alice")
.header("authorization", del_auth)
.header("x-real-ip", "10.0.0.6")
.body(Body::empty())
.unwrap();
let (sdel, _) = send(app.clone(), del).await;
assert_eq!(sdel, StatusCode::OK);
// A fresh registration is now blocked by the cooldown the release armed.
let (sreg, json) = send(app, register_req(&keys, "bob")).await;
assert_eq!(sreg, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(json["error"], "name_change_cooldown");
}
#[tokio::test]
async fn wellknown_resolves_registered_name() {
let app = test_app();
let keys = Keys::generate();
let (s1, _) = send(app.clone(), register_req(&keys, "alice")).await;
assert_eq!(s1, StatusCode::CREATED);
let req = Request::builder()
.method("GET")
.uri("/.well-known/nostr.json?name=alice")
.header("x-real-ip", "10.0.2.1")
.body(Body::empty())
.unwrap();
let (status, json) = send(app, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["names"]["alice"], keys.public_key().to_hex());
}
#[tokio::test]
async fn by_pubkey_reverse_lookup() {
let app = test_app();
let keys = Keys::generate();
let pk = keys.public_key().to_hex();
let (s1, _) = send(app.clone(), register_req(&keys, "alice")).await;
assert_eq!(s1, StatusCode::CREATED);
// Known key -> its active name.
let req = Request::builder()
.method("GET")
.uri(format!("/api/v1/by-pubkey/{pk}"))
.header("x-real-ip", "10.0.3.1")
.body(Body::empty())
.unwrap();
let (status, json) = send(app.clone(), req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["name"], "alice");
assert_eq!(json["pubkey"], pk);
// Unknown (but well-formed) key -> 404.
let other = Keys::generate().public_key().to_hex();
let req = Request::builder()
.method("GET")
.uri(format!("/api/v1/by-pubkey/{other}"))
.header("x-real-ip", "10.0.3.2")
.body(Body::empty())
.unwrap();
let (status, _) = send(app.clone(), req).await;
assert_eq!(status, StatusCode::NOT_FOUND);
// Malformed key -> 404, not a 500.
let req = Request::builder()
.method("GET")
.uri("/api/v1/by-pubkey/not-a-key")
.header("x-real-ip", "10.0.3.3")
.body(Body::empty())
.unwrap();
let (status, _) = send(app, req).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
// --- paid mode ---
#[tokio::test]
async fn paid_name_402_then_registers_after_payment() {
let (app, mock) = paid_test_app(PayMode::Name);
let keys = Keys::generate();
// First attempt: 402 with the invoice + hosted pay URL.
let (s1, j1) = send(app.clone(), register_req(&keys, "alice")).await;
assert_eq!(s1, StatusCode::PAYMENT_REQUIRED);
assert_eq!(j1["error"], "payment_required");
assert_eq!(j1["resource"], "name");
assert_eq!(j1["price_grin"], "1.5");
let invoice_id = j1["invoice_id"].as_str().unwrap().to_string();
assert!(j1["pay_url"].as_str().unwrap().contains(&invoice_id));
// Retrying before payment: same invoice, still 402.
let (s2, j2) = send(app.clone(), register_req_aged(&keys, "alice", 1)).await;
assert_eq!(s2, StatusCode::PAYMENT_REQUIRED);
assert_eq!(j2["invoice_id"], invoice_id.as_str());
// Settle the invoice at the (mock) backend; the retry now succeeds.
mock.statuses.lock().insert(invoice_id, "paid".into());
let (s3, j3) = send(app.clone(), register_req_aged(&keys, "alice", 2)).await;
assert_eq!(s3, StatusCode::CREATED);
assert_eq!(j3["nip05"], "alice@floonet.example");
// The grant was consumed: releasing and claiming again needs a new payment
// (checked via the grants table through a fresh register by another name;
// the cooldown from release also applies, so check the grant directly).
assert!(app.grant(&keys.public_key().to_hex(), "name").is_none());
}
#[tokio::test]
async fn paid_name_does_not_quote_for_invalid_or_taken_names() {
let (app, mock) = paid_test_app(PayMode::Name);
let alice = Keys::generate();
// Pay and claim as alice.
let (_, j) = send(app.clone(), register_req(&alice, "alice")).await;
let invoice_id = j["invoice_id"].as_str().unwrap().to_string();
mock.statuses.lock().insert(invoice_id, "paid".into());
let (s, _) = send(app.clone(), register_req_aged(&alice, "alice", 1)).await;
assert_eq!(s, StatusCode::CREATED);
// Bob tries the taken name: conflict BEFORE any invoice is created.
let bob = Keys::generate();
let created_before = mock.created.lock().len();
let (s2, j2) = send(app.clone(), register_req(&bob, "alice")).await;
assert_eq!(s2, StatusCode::CONFLICT);
assert_eq!(j2["error"], "name taken");
assert_eq!(mock.created.lock().len(), created_before, "no invoice minted");
// A reserved name also never mints an invoice.
let (s3, _) = send(app.clone(), register_req(&bob, "admin")).await;
assert_eq!(s3, StatusCode::FORBIDDEN);
assert_eq!(mock.created.lock().len(), created_before);
}
#[tokio::test]
async fn paid_status_reflects_write_grants() {
let (app, mock) = paid_test_app(PayMode::Write);
let keys = Keys::generate();
let pk = keys.public_key().to_hex();
let paid_req = |pk: &str| {
Request::builder()
.method("GET")
.uri(format!("/api/v1/paid/{pk}"))
.header("x-real-ip", "10.0.4.1")
.body(Body::empty())
.unwrap()
};
// No grant: not paid (and no invoice is minted by the public endpoint).
let (s1, j1) = send(app.clone(), paid_req(&pk)).await;
assert_eq!(s1, StatusCode::OK);
assert_eq!(j1["paid"], false);
assert_eq!(mock.created.lock().len(), 0);
// Quote write access (NIP-98) -> 402 with the pay URL.
let body = serde_json::json!({"resource": "write"}).to_string().into_bytes();
let auth = nip98_header(&keys, "POST", "/api/v1/quote", &body, 0);
let quote = Request::builder()
.method("POST")
.uri("/api/v1/quote")
.header("authorization", auth)
.header("x-real-ip", "10.0.4.2")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let (s2, j2) = send(app.clone(), quote).await;
assert_eq!(s2, StatusCode::PAYMENT_REQUIRED);
assert_eq!(j2["resource"], "write");
assert_eq!(j2["price_grin"], "0.5");
let invoice_id = j2["invoice_id"].as_str().unwrap().to_string();
// Still unpaid until the invoice settles.
let (_, j3) = send(app.clone(), paid_req(&pk)).await;
assert_eq!(j3["paid"], false);
// Settle; the status endpoint (which the relay plugin polls) flips.
mock.statuses.lock().insert(invoice_id, "paid".into());
let (_, j4) = send(app.clone(), paid_req(&pk)).await;
assert_eq!(j4["paid"], true);
}
#[tokio::test]
async fn quote_rejects_resources_not_for_sale() {
let (app, _mock) = paid_test_app(PayMode::Name);
let keys = Keys::generate();
let body = serde_json::json!({"resource": "write"}).to_string().into_bytes();
let auth = nip98_header(&keys, "POST", "/api/v1/quote", &body, 0);
let req = Request::builder()
.method("POST")
.uri("/api/v1/quote")
.header("authorization", auth)
.header("x-real-ip", "10.0.5.1")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let (status, json) = send(app, req).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(json["error"], "resource not for sale on this authority");
}
#[tokio::test]
async fn paid_status_is_true_when_not_selling_writes() {
// In `name` mode (and off mode) the relay plugin must see paid=true.
let (app, _mock) = paid_test_app(PayMode::Name);
let pk = Keys::generate().public_key().to_hex();
let req = Request::builder()
.method("GET")
.uri(format!("/api/v1/paid/{pk}"))
.header("x-real-ip", "10.0.6.1")
.body(Body::empty())
.unwrap();
let (status, json) = send(app, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["paid"], true);
}
#[tokio::test]
async fn goblinpay_webhook_nudges_grant_to_paid() {
let (app, mock) = paid_test_app(PayMode::Write);
let keys = Keys::generate();
let pk = keys.public_key().to_hex();
// Create a pending write grant via quote.
let body = serde_json::json!({"resource": "write"}).to_string().into_bytes();
let auth = nip98_header(&keys, "POST", "/api/v1/quote", &body, 0);
let quote = Request::builder()
.method("POST")
.uri("/api/v1/quote")
.header("authorization", auth)
.header("x-real-ip", "10.0.7.1")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let (_, j) = send(app.clone(), quote).await;
let invoice_id = j["invoice_id"].as_str().unwrap().to_string();
// Settle at the backend, then deliver the signed webhook nudge.
mock.statuses.lock().insert(invoice_id.clone(), "paid".into());
let payload = serde_json::json!({
"event_id": "evt-1",
"event_type": "payment.confirmed",
"invoice_id": invoice_id,
})
.to_string();
let mut mac = Hmac::<Sha256>::new_from_slice(b"whsec").unwrap();
mac.update(payload.as_bytes());
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
let hook = Request::builder()
.method("POST")
.uri("/api/v1/goblinpay/webhook")
.header("x-goblinpay-signature", sig)
.header("content-type", "application/json")
.body(Body::from(payload.clone()))
.unwrap();
let (s, _) = send(app.clone(), hook).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(app.grant(&pk, "write").unwrap().status, "paid");
// A bad signature is rejected outright.
let hook = Request::builder()
.method("POST")
.uri("/api/v1/goblinpay/webhook")
.header("x-goblinpay-signature", "sha256=deadbeef")
.header("content-type", "application/json")
.body(Body::from(payload))
.unwrap();
let (s, _) = send(app, hook).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn webhook_alone_cannot_grant_unpaid_invoice() {
// The webhook is only a nudge: if GoblinPay's REST API still says the
// invoice is open, a signed webhook claiming payment changes nothing.
let (app, _mock) = paid_test_app(PayMode::Write);
let keys = Keys::generate();
let pk = keys.public_key().to_hex();
let body = serde_json::json!({"resource": "write"}).to_string().into_bytes();
let auth = nip98_header(&keys, "POST", "/api/v1/quote", &body, 0);
let quote = Request::builder()
.method("POST")
.uri("/api/v1/quote")
.header("authorization", auth)
.header("x-real-ip", "10.0.8.1")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let (_, j) = send(app.clone(), quote).await;
let invoice_id = j["invoice_id"].as_str().unwrap().to_string();
// Signed webhook, but the backend still reports the invoice open.
let payload = serde_json::json!({
"event_id": "evt-2",
"event_type": "payment.confirmed",
"invoice_id": invoice_id,
})
.to_string();
let mut mac = Hmac::<Sha256>::new_from_slice(b"whsec").unwrap();
mac.update(payload.as_bytes());
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
let hook = Request::builder()
.method("POST")
.uri("/api/v1/goblinpay/webhook")
.header("x-goblinpay-signature", sig)
.header("content-type", "application/json")
.body(Body::from(payload))
.unwrap();
let (s, _) = send(app.clone(), hook).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(app.grant(&pk, "write").unwrap().status, "pending");
}
+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)