commit 16302ed30907a8b073d54c4dd7101aa41c869cdc Author: Goblin Date: Thu Jul 2 08:20:30 2026 -0400 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7839b01 --- /dev/null +++ b/.env.example @@ -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 ``, 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd12a1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +target/ +strfry-build/ +*.db +*.db-shm +*.db-wal +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..aaed913 --- /dev/null +++ b/README.md @@ -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= +``` + +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=` | 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) diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..4a4a7a4 --- /dev/null +++ b/deploy/Caddyfile @@ -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} + } + } +} diff --git a/deploy/landing/floonet-logo.svg b/deploy/landing/floonet-logo.svg new file mode 100644 index 0000000..c5a0add --- /dev/null +++ b/deploy/landing/floonet-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/deploy/landing/index.html b/deploy/landing/index.html new file mode 100644 index 0000000..d4d30c1 --- /dev/null +++ b/deploy/landing/index.html @@ -0,0 +1,25 @@ + + + + + +Floonet Relay + + + +
+Floonet +

Floonet Relay

+

A strfry Floonet relay for the Grin community Nostr network.

+

Please use a Nostr client to connect.

+
+ + diff --git a/deploy/strfry/Dockerfile b/deploy/strfry/Dockerfile new file mode 100644 index 0000000..b59c3db --- /dev/null +++ b/deploy/strfry/Dockerfile @@ -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"] diff --git a/deploy/strfry/apply-spec.sh b/deploy/strfry/apply-spec.sh new file mode 100755 index 0000000..2ea0554 --- /dev/null +++ b/deploy/strfry/apply-spec.sh @@ -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 < 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 diff --git a/deploy/systemd/floonet-mixexit.service b/deploy/systemd/floonet-mixexit.service new file mode 100644 index 0000000..f1dbcbb --- /dev/null +++ b/deploy/systemd/floonet-mixexit.service @@ -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 diff --git a/deploy/systemd/floonet-strfry.service b/deploy/systemd/floonet-strfry.service new file mode 100644 index 0000000..a30f599 --- /dev/null +++ b/deploy/systemd/floonet-strfry.service @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3b76fb6 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/mixexit/Cargo.toml b/mixexit/Cargo.toml new file mode 100644 index 0000000..61c5821 --- /dev/null +++ b/mixexit/Cargo.toml @@ -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 diff --git a/mixexit/Dockerfile b/mixexit/Dockerfile new file mode 100644 index 0000000..5aee7b1 --- /dev/null +++ b/mixexit/Dockerfile @@ -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"] diff --git a/mixexit/rustfmt.toml b/mixexit/rustfmt.toml new file mode 100644 index 0000000..7dbfd36 --- /dev/null +++ b/mixexit/rustfmt.toml @@ -0,0 +1,2 @@ +hard_tabs = true +edition = "2024" diff --git a/mixexit/src/main.rs b/mixexit/src/main.rs new file mode 100644 index 0000000..df0bc8d --- /dev/null +++ b/mixexit/src/main.rs @@ -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 + /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> { + 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; + } +} diff --git a/name-authority/.dockerignore b/name-authority/.dockerignore new file mode 100644 index 0000000..a7fbd00 --- /dev/null +++ b/name-authority/.dockerignore @@ -0,0 +1,4 @@ +target/ +tests/ +*.db +*.db-* diff --git a/name-authority/Cargo.lock b/name-authority/Cargo.lock new file mode 100644 index 0000000..73ffaba --- /dev/null +++ b/name-authority/Cargo.lock @@ -0,0 +1,1834 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" +dependencies = [ + "hex-conservative 0.3.2", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "floonet-name-authority" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "hex", + "hmac", + "http-body-util", + "nostr", + "parking_lot", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "ureq", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nostr" +version = "0.44.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cf5d15d70d1f8f4059e5f79923ac15891eb691d2843d01191e0585fb064d70" +dependencies = [ + "base64", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/name-authority/Cargo.toml b/name-authority/Cargo.toml new file mode 100644 index 0000000..dd641b6 --- /dev/null +++ b/name-authority/Cargo.toml @@ -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 diff --git a/name-authority/Dockerfile b/name-authority/Dockerfile new file mode 100644 index 0000000..0b3d983 --- /dev/null +++ b/name-authority/Dockerfile @@ -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"] diff --git a/name-authority/src/auth.rs b/name-authority/src/auth.rs new file mode 100644 index 0000000..623a17f --- /dev/null +++ b/name-authority/src/auth.rs @@ -0,0 +1,91 @@ +// NIP-98 HTTP authorization: verify a `Authorization: Nostr ` +// 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 = 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())) +} diff --git a/name-authority/src/config.rs b/name-authority/src/config.rs new file mode 100644 index 0000000..0bfa56e --- /dev/null +++ b/name-authority/src/config.rs @@ -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 { + 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, + /// 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, + + /// 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, + /// 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(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 { + 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 { + 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::>(); + 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, 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"); + } +} diff --git a/name-authority/src/db.rs b/name-authority/src/db.rs new file mode 100644 index 0000000..65f06dc --- /dev/null +++ b/name-authority/src/db.rs @@ -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, + pub rate: Mutex>>, + /// Seen NIP-98 auth event ids (one-time use within the freshness window). + pub seen_auth: Mutex>, + /// Resolved runtime config. + pub cfg: Config, + /// GoblinPay paywall; `None` when FLOONET_PAY_MODE=off (everything free). + pub paywall: Option, +} + +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 { + 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 { + 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); + } +} diff --git a/name-authority/src/handlers/misc.rs b/name-authority/src/handlers/misc.rs new file mode 100644 index 0000000..15a2256 --- /dev/null +++ b/name-authority/src/handlers/misc.rs @@ -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>) -> 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#" + +Floonet +
+

Floonet

+

A Floonet relay for the Grin community Nostr network.

+

Names here look like you@{domain}.

+

Relay: {relay}

+

Please use a Nostr client to connect.

+
"# + )) + .into_response() +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} diff --git a/name-authority/src/handlers/mod.rs b/name-authority/src/handlers/mod.rs new file mode 100644 index 0000000..aabfdc6 --- /dev/null +++ b/name-authority/src/handlers/mod.rs @@ -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) -> 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) +} diff --git a/name-authority/src/handlers/paidapi.rs b/name-authority/src/handlers/paidapi.rs new file mode 100644 index 0000000..55218e9 --- /dev/null +++ b/name-authority/src/handlers/paidapi.rs @@ -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>, + Path(pubkey): Path, + 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>, + 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=`). +/// 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>, + 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::::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::(&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() +} diff --git a/name-authority/src/handlers/profile.rs b/name-authority/src/handlers/profile.rs new file mode 100644 index 0000000..35ac970 --- /dev/null +++ b/name-authority/src/handlers/profile.rs @@ -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>, + Path(name): Path, + 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>, + Path(pubkey): Path, + 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(), + } +} diff --git a/name-authority/src/handlers/registry.rs b/name-authority/src/handlers/registry.rs new file mode 100644 index 0000000..0b8b35d --- /dev/null +++ b/name-authority/src/handlers/registry.rs @@ -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>, + headers: HeaderMap, + Path(name): Path, +) -> 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>, + 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>, + Path(name): Path, + 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(), + } +} diff --git a/name-authority/src/handlers/wellknown.rs b/name-authority/src/handlers/wellknown.rs new file mode 100644 index 0000000..fa56f57 --- /dev/null +++ b/name-authority/src/handlers/wellknown.rs @@ -0,0 +1,54 @@ +// NIP-05 resolution: `/.well-known/nostr.json?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, +} + +pub async fn well_known( + State(app): State>, + headers: axum::http::HeaderMap, + Query(params): Query, +) -> 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() +} diff --git a/name-authority/src/lib.rs b/name-authority/src/lib.rs new file mode 100644 index 0000000..ca320f4 --- /dev/null +++ b/name-authority/src/lib.rs @@ -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; diff --git a/name-authority/src/main.rs b/name-authority/src/main.rs new file mode 100644 index 0000000..0b1ebef --- /dev/null +++ b/name-authority/src/main.rs @@ -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= 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"); +} diff --git a/name-authority/src/names.rs b/name-authority/src/names.rs new file mode 100644 index 0000000..1694e70 --- /dev/null +++ b/name-authority/src/names.rs @@ -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 { + 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")); + } +} diff --git a/name-authority/src/paid.rs b/name-authority/src/paid.rs new file mode 100644 index 0000000..d592e20 --- /dev/null +++ b/name-authority/src/paid.rs @@ -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; + /// Current status of an invoice: `open`, `paid` or `expired`. + fn invoice_status(&self, invoice_id: &str) -> Result; +} + +/// 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 { + 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 { + 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 { + 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, + pub name_price_nanogrin: u64, + pub write_price_nanogrin: u64, +} + +impl Paywall { + pub fn from_config(cfg: &Config) -> Option { + 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 { + 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 { + 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, 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, + 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>, + pub created: Mutex>, + pub fail: Mutex, + counter: Mutex, + } + + impl PayBackend for std::sync::Arc { + fn create_invoice( + &self, + order_ref: &str, + _amount_nanogrin: u64, + _memo: &str, + ) -> Result { + 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 { + 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, std::sync::Arc) { + 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 { .. })); + } +} diff --git a/name-authority/src/ratelimit.rs b/name-authority/src/ratelimit.rs new file mode 100644 index 0000000..db087c3 --- /dev/null +++ b/name-authority/src/ratelimit.rs @@ -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)); + } +} diff --git a/name-authority/src/util.rs b/name-authority/src/util.rs new file mode 100644 index 0000000..c2dcf94 --- /dev/null +++ b/name-authority/src/util.rs @@ -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"")); + } +} diff --git a/name-authority/tests/http.rs b/name-authority/tests/http.rs new file mode 100644 index 0000000..683febf --- /dev/null +++ b/name-authority/tests/http.rs @@ -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 ` 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 { + 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, std::sync::Arc) { + 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, req: Request) -> (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 { + 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 { + 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::::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::::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"); +} diff --git a/plugin/floonet_writepolicy.py b/plugin/floonet_writepolicy.py new file mode 100755 index 0000000..702cf6c --- /dev/null +++ b/plugin/floonet_writepolicy.py @@ -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() diff --git a/plugin/test_policy.py b/plugin/test_policy.py new file mode 100644 index 0000000..fa5af2f --- /dev/null +++ b/plugin/test_policy.py @@ -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/ 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)