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:
+107
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
target/
|
||||
strfry-build/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
__pycache__/
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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"]
|
||||
Executable
+60
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,2 @@
|
||||
hard_tabs = true
|
||||
edition = "2024"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
tests/
|
||||
*.db
|
||||
*.db-*
|
||||
Generated
+1834
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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""));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Executable
+217
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user