Compare commits

...

6 Commits

Author SHA1 Message Date
2ro 785d2008c8 docs(internal): Tor migration plan (retire mixexit, onion, relay-side Poisson) 2026-07-04 03:40:08 -04:00
2ro 37d067e825 docs(readme): call out name authority co-location in the piece table
Matches the detail already in the "Co-locating names on the relay
domain" section below — on by default via the Compose/Caddy stack.
2026-07-03 13:20:51 -04:00
2ro 7205ddbafd floonet-strfry: co-locate the name authority on the relay domain (toggle)
Serve the authority's NIP-05 lookup on the relay's own domain so
`name@relay.example` resolves, without giving the authority a second
vhost/cert. Live on us-east: relay.floonet.dev now answers
/.well-known/nostr.json from the co-located authority (127.0.0.1:8193)
while the WebSocket relay and NIP-11 stay untouched.

  * deploy/us-east/colocated-authority.conf
        The nginx opt-in: an exact-match `location = /.well-known/nostr.json`
        proxied to the authority ahead of the relay's WebSocket catch-all.
        Only the READ path is exposed; registration and the rest of /api/*
        stay on the authority's own domain. Sets X-Real-IP (the per-IP rate
        limiter keys off it). Same proxy shape as nm.floonet.dev.conf.
  * README.md — "Co-locating names on the relay domain": the Caddy/compose
        stack is co-located by default (single FLOONET_DOMAIN); a split nginx
        deploy opts in with the snippet. Documents FLOONET_AUTHORITY_COLOCATED.
  * deploy/Caddyfile, .env.example — note the single-domain stack is
        co-located by default and point split deploys at the snippet.

The box vhost mirrors this snippet exactly; applied with nginx -t + reload
(no restart), firewalld untouched.
2026-07-03 04:11:19 -04:00
2ro 868cc84b20 floonet-strfry: raise strfry maxEventSize to 131072
Match the advertised maxWebsocketPayloadSize so a maximum-size NIP-59
gift-wrapped slatepack payment (~66 KiB worst case) is accepted; the old
64 KiB cap could silently reject the largest payments on the money path.
2026-07-03 03:20:48 -04:00
2ro be15c78121 floonet-strfry: us-east production deploy bundle for the nm.floonet.dev name authority
Bundle that stands the bundled name authority up behind nginx at
https://nm.floonet.dev in paid-name mode wired to the on-box GoblinPay:

  * nm.floonet.dev.conf         nginx vhost mirroring the relay.floonet.dev
                                pattern (same listen IP + certbot webroot),
                                TLS -> 127.0.0.1:8193, sets X-Real-IP (which
                                the per-IP rate limiter keys off).
  * floonet-authority.service.d/10-us-east.conf
                                drop-in over the generic hardened unit: swaps
                                DynamicUser for the stable unprivileged goblin
                                account and relocates the DB into the
                                /opt/goblin tree (one backup root), inheriting
                                every other sandbox directive.
  * floonet-authority.env.example
                                FLOONET_PAY_MODE=name, GOBLINPAY_URL at the
                                loopback GoblinPay; the real GP token is filled
                                from goblinpay.env at deploy time, never here.
  * deploy.sh                   idempotent runbook: build on-box, install,
                                two-phase certbot (acme :80 -> cert -> :443),
                                start. Never touches goblin-nip05d or firewalld.
2026-07-03 03:15:38 -04:00
2ro fb62ed2bf2 floonet-strfry: add a NIP-98 header-minting example for the name authority
Operating a NIP-98-gated endpoint (register / unregister / quote) needs signed
kind-27235 Authorization headers, and there is no nak on the target hosts. This
example reuses the crate's existing nostr/base64/sha2 deps to mint a
"Nostr <base64-event>" header for curl/CI: generate a throwaway identity or
reuse one via NIP98_SK, sign over the method/path/body, print the header to
stdout. The u-tag is built from FLOONET_BASE_URL to match server verification.
2026-07-03 03:15:38 -04:00
11 changed files with 543 additions and 7 deletions
+7
View File
@@ -19,6 +19,13 @@ FLOONET_BASE_URL=https://floonet.example
# at your own wss:// URL (normally wss://FLOONET_DOMAIN).
FLOONET_RELAYS=wss://floonet.example
# Co-located names (FLOONET_AUTHORITY_COLOCATED): this compose stack is single
# domain, so names AND the relay are already served on FLOONET_DOMAIN
# (`name@FLOONET_DOMAIN` resolves) — on by default, nothing to set here. Only a
# SPLIT deploy that puts the relay and the authority on separate subdomains
# behind nginx needs to opt in; see "Co-locating names on the relay domain" in
# the README and deploy/us-east/colocated-authority.conf.
# --- The kind whitelist (the keystone) ---
# Comma-separated event kinds the relay stores. DEFAULT-DENY: anything not
+30 -1
View File
@@ -12,7 +12,7 @@ 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 |
| `name-authority/` | The bundled name authority (Rust/axum/SQLite): NIP-05 resolution, NIP-98 self-service registration, optional GoblinPay paywall — co-located on the relay's own domain by default |
| `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 |
@@ -155,6 +155,35 @@ NIP-98 requests are verified fully: signature, kind 27235, `u`/`method`/
`payload` tags against `FLOONET_BASE_URL`, a freshness window, and one-time
event ids (replay rejection).
## Co-locating names on the relay domain
`FLOONET_AUTHORITY_COLOCATED` controls whether the authority's NIP-05 lookup
(`/.well-known/nostr.json`) is served on the **relay's own domain**, so
`name@relay.example` resolves without the authority needing its own hostname.
- **Docker Compose / Caddy: on by default.** The whole stack lives on one
`FLOONET_DOMAIN`; `deploy/Caddyfile` routes `/.well-known/nostr.json` (and
`/api/*`) to the authority and everything else to the relay, so
`name@FLOONET_DOMAIN` just works. Nothing to configure.
- **Split nginx deploy: opt in.** When the relay and the authority run on
separate subdomains (the `deploy/us-east/` pattern — relay on
`relay.example`, the authority's own vhost on `nm.example`), enable it by
including the shipped snippet in the relay vhost's `:443` server block,
ahead of the WebSocket catch-all:
```nginx
# inside server { listen ...:443 ssl ...; server_name relay.example; }
# BEFORE location / { ...websocket... }
include /etc/nginx/snippets/floonet-colocated-authority.conf; # deploy/us-east/colocated-authority.conf
```
Then `nginx -t && nginx -s reload`, and
`https://relay.example/.well-known/nostr.json?name=<n>` returns the
authority's JSON. Only the exact-match read path is co-located; registration
and the rest of `/api/*` stay on the authority's own domain. The snippet sets
`X-Real-IP` (load-bearing — the authority's per-IP rate limiter keys off it).
## Mixnet exit (optional)
Uncomment `COMPOSE_PROFILES=exit` in `.env` and the package also runs
+222
View File
@@ -0,0 +1,222 @@
# Floonet Tor Migration Plan
**Status:** Ready to execute · **Date:** 2026-07-04 · **Scope:** the FLOONET side (relay, exit, name authority, deploy/ops) of Goblin's move from the Nym mixnet back to Tor.
**Companion document (read first):** `goblin/docs/PRIVACY-TRANSPORT-REDESIGN.md` — the wallet-side plan, and the source of truth for *why* we're doing this and how the wallet's transport is built. This document does not repeat that reasoning; it covers everything on the relay/infra side that the wallet plan depends on or triggers.
**The one line to carry through every decision below:** Tor hides the user's IP from the relay; the relay + protocol hide everything else (content, sender, timing).
---
## Decision & scope
Goblin is dropping the Nym mixnet and returning to Tor, for the reasons laid out in full in the wallet plan: the free bandwidth tier Goblin relied on is testnet scaffolding Nym is actively deleting on a schedule, and the only supported replacement requires holding NYM tokens — a foundation a payments wallet can't stand on. Tor has none of those failure modes.
This document is scoped to **Floonet** — the relay, the co-located mixnet exit, the name authority, and the deploy/ops layer around them. It does not cover the wallet's Tor client (arti, the transport trait, UI copy, locales) — that's entirely the wallet plan's job. Where this document depends on wallet work landing first, it says so and points back rather than re-describing it.
Floonet's job in this migration is small and mostly subtractive: the onion that replaces the old mixnet exit is **already live**. What's left is pinning it where the wallet looks for it, retiring the exit once the wallet no longer needs it, deleting the now-dead mixnet code from two repos, and a couple of deploy-layer additions (a second onion for the name authority, some ops hygiene). The one piece of real new work — relay-side Poisson delay — is called out separately as a fast-follow that nothing else here is blocked on.
---
## Where things stand today: the onion is already live
The keystone is done. A Tor onion service already fronts the production relay:
- Address: `m2ji5o6p6qapd4ies4wua64skjx2emd6lrp7hhvrib33ogveyihopryd.onion`
- It forwards plain `ws://` straight to the relay's local websocket listener (`127.0.0.1:8292`) — no TLS needed on that hop, since the onion transport is already encrypted and authenticated end to end.
- Proven working: a plain Nostr client can complete a handshake through it today.
This satisfies the wallet plan's "Phase 1 — Onion service on the relay" for Floonet's production relay. Everything in this document is what surrounds that fact: pinning it, cutting over to it, and retiring what it replaces.
---
## Orientation: two relay repos, one production deployment
Floonet ships two independent, open-source relay packages, and it matters which one this plan is really about:
- **floonet-strfry** — wraps stock, unpatched upstream [strfry](https://github.com/hoytech/strfry) (C++) with a write-policy plugin, a bundled name authority, and (until this migration) the mixnet exit. **This is production.** It runs today as the Docker container `floonet-relay-new` on the us-east box, serving `wss://relay.floonet.dev`, websocket listening locally on `127.0.0.1:8292`. The live onion, the Poisson work in Phase 7, and the exit retirement in Phase 4 all land here, for real.
- **floonet-rs** — a Rust fork of `nostr-rs-relay` with the same feature set (kind whitelist, NIP-42 auth, built-in name authority, the same optional co-located mixnet exit). It is not what's serving `relay.floonet.dev` traffic, but it's a maintained, publicly distributed sibling package that vendors the identical mixnet-exit design — so it needs the same cleanup (Phase 5) or it's left carrying a dead code path and a stale `nym-sdk` dependency that nobody is using.
- **goblin-nip05d** — the name authority behind `goblin.st`, a separate service entirely (deployed standalone, `127.0.0.1:8191`, its own nginx vhost). It has no mixnet/exit code to remove — it never had any — but wallet lookups against it currently leak the wallet's IP the same way relay traffic used to, so it gets its own onion in Phase 8.
Keep this straight while executing: "strip the exit" is real work in two repos (floonet-rs and floonet-strfry) for the sake of code hygiene and every future operator who deploys them, but only floonet-strfry's production instance is on the live money path.
---
## The phased checklist
- [x] **Phase 1** — Onion service live in front of the production relay (done, proven)
- [ ] **Phase 2** — Pin the onion in the relay-pool gist
- [ ] **Phase 3** — Gate: wait for the wallet's Tor build to ship and prove a payment over the onion
- [ ] **Phase 4** — Retire the Nym exit in production (`floonet-mixexit-fdev.service` + archive `floonet-mixexit`)
- [ ] **Phase 5** — Strip mixexit out of floonet-rs
- [ ] **Phase 6** — Strip mixexit out of floonet-strfry, document Tor as the replacement deploy option
- [ ] **Phase 7 (fast-follow, non-blocking)** — Relay-side Poisson delay
- [ ] **Phase 8** — goblin-nip05d gets its own onion
- [ ] **Phase 9** — Ops & health-probe follow-ups
---
## Phase 1 — Onion service live (done)
Nothing to execute here. Recorded for completeness and because Phases 2 onward assume it: the onion above is live, stable, and has already handled a proven handshake.
One thing worth a five-minute check before moving on, since it's cheap insurance and was part of the wallet plan's own Phase 1 recommendation: confirm **Vanguards** is enabled on the onion service's Tor configuration. It hardens the service side against guard-discovery attacks and costs nothing once set. If it isn't on yet, turning it on is a one-line torrc addition, not a re-architecture — fold it in whenever you're next touching that box's Tor config.
---
## Phase 2 — Pin the onion in the relay-pool gist
The wallet finds a relay's onion the same way it already finds a co-located Nym exit: a per-relay field in the relay-pool gist (`https://gist.github.com/2ro/79cd885540c88d074fe52f8388a3e5b4`).
**Verified state as of this writing** (fetched directly, not assumed):
- The wallet's parsing code is *already done*. `goblin/src/nostr/pool.rs` defines `PoolRelay.onion: Option<String>`, plus `onion_for()` / `has_onion()` accessors that mirror the existing `exit` plumbing exactly, and a unit test already pins this exact onion address for `wss://relay.floonet.dev`. The wallet's in-binary pinned fallback pool (the `PINNED_POOL` constant in that same file, dated `2026-07-02`) already carries the onion field too.
- The **live gist does not yet match.** A direct fetch of the raw gist just now shows `"updated": "2026-07-03"` with an `exit` field for `relay.floonet.dev` but **no `onion` field** — the gist is a revision behind what's already staged in the wallet's own pinned fallback.
So the action here is narrow and low-risk: sync the live gist to what the code already expects.
```json
{
"url": "wss://relay.floonet.dev",
"roles": ["dm", "discovery"],
"vetted": "2026-07-02",
"exit": "EqbUPt7aYkar2CTmjBVnyWaKzb2WT8NdojUGXU4mrfNG.AF5YCD8hgEUqByamrPqZz72h7GE599LbqQrhaew9bBip@HfyUPUv4z8uMQoZYuZGMWf6oe2vaKBVPrfgHk6WvwFPe",
"onion": "m2ji5o6p6qapd4ies4wua64skjx2emd6lrp7hhvrib33ogveyihopryd.onion"
}
```
- Edit via the existing path (`gh gist edit`), bump `updated`, keep `version: 1`.
- **Leave `exit` in place for now** — don't remove it until Phase 4. Older, still-live Nym-only wallet builds still read it; removing it early stops them from reaching this relay at all, for no benefit.
- This is safe to do **right now**, independent of the wallet Tor build shipping. The gist schema is deliberately tolerant (no `deny_unknown_fields`, `version` stays `1`), so any build that doesn't understand `onion` yet just ignores it — confirmed by the same test coverage in `pool.rs`. There's no flag day and no risk to today's traffic.
---
## Phase 3 — Gate: ship the wallet's Tor build
This phase is entirely the wallet plan's to execute (its Phases 02: copy GRIM's arti engine, implement the onion-dialing `WebSocketTransport`, re-point readiness/warm-up, keep the confirm-before-sent guard verbatim, no clearnet fallback). Nothing to do on the Floonet side here except wait for it — and not skip ahead.
**Everything from Phase 4 onward is gated on this.** Don't disable the Nym exit before the wallet's Tor build has shipped and been watched carrying a real payment over the onion (the wallet plan's own Phase 2 validation criterion). Disabling the exit early would cut off the only working path for any user still on an old, Nym-only build, before they have anywhere else to go.
---
## Phase 4 — Retire the Nym exit in production
Two separate things retire here, and they're independent enough to do one at a time.
**1. The running service.** `floonet-mixexit-fdev.service` is the live systemd unit on the us-east box — the unbonded Nym client piping mixnet streams to `relay.floonet.dev`. Once Phase 3's gate has cleared:
```sh
systemctl disable --now floonet-mixexit-fdev
```
Nothing else on the box depends on it. The relay's own websocket listener, its Docker container, and the nginx/TLS front for the clearnet hostname are untouched by this — the exit was always a side door, not part of the main path.
**2. The standalone repo.** `floonet-mixexit/` — the ~185-line unbonded Nym client this service is built from — gets archived, not deleted outright. Mark it read-only / archived wherever it's hosted. It's small and self-contained, and other Floonet operators may have taken their own copy to run their own exit; deleting it out from under them is unnecessary and unkind. Archiving communicates "retired, don't build new things on this" without breaking anyone already relying on the source being there.
**Housekeeping:** it's clean to pull the now-pointless `exit` field for `relay.floonet.dev` out of the gist in the same pass (nothing depends on it anymore once the service is down), but it isn't load-bearing either way — `exit_for()` on the wallet side already treats a missing or blank value as "no exit" and nothing breaks if it's just left to go stale.
**Before you flip the switch:** there is no clean "everyone's already moved off Nym" state to wait for — per the wallet plan, Nym itself is failing on its own schedule regardless of what Floonet does, so there's no working baseline being taken away out from under anyone. The bar is simply: the wallet's Tor build is out and proven (Phase 3), not that every last Nym-only install has upgraded.
---
## Phase 5 — Strip mixexit out of floonet-rs
Delete:
- `mixexit/` — the entire vendored subcrate (its own `Cargo.toml`, `Cargo.lock`, `rustfmt.toml`, `src/`). Being a separate crate rather than a dependency of the main binary, deleting the directory drops the `nym-sdk` dependency chain automatically — nothing else to chase.
- `src/exit.rs` — the in-process supervisor (`validate()`, which fails startup fast if `exit.enabled` is set but the binary is missing; `spawn()`, which forks the `floonet-mixexit` child and restarts it with a 10s backoff on exit).
Un-wire the remaining touch points, all small:
| File | What to remove |
|---|---|
| `src/lib.rs` | `pub mod exit;` |
| `src/config.rs` | The `MixnetExit` struct, the `pub exit: MixnetExit` field on `Settings`, and its entry in the `Default` impl |
| `src/server.rs` | The two call sites: `exit::validate(settings)?` and `exit::spawn(&settings)` |
| `config.toml` | The commented `[exit]` block |
End state: floonet-rs builds and runs as a fully-working public relay with none of this. The exit was always optional and default-off, so this is pure subtraction — no behavior changes for anyone not already running the exit.
---
## Phase 6 — Strip mixexit out of floonet-strfry
Same shape as Phase 5, different repo, plus one addition. Delete:
- `mixexit/` — its own `Cargo.toml`, `Dockerfile`, `src/`. (Worth knowing while you're in there: this is the same ~185-line program as floonet-rs's copy and the standalone `floonet-mixexit` repo, not a from-scratch reimplementation — the only real difference between the three copies is which hostname is baked in as the default upstream. "Vendored" is the right word for it.)
- `deploy/systemd/floonet-mixexit.service` — the hardened bare-metal unit template (the pattern `floonet-mixexit-fdev.service` on us-east was built from).
- The `mixexit` service block in `docker-compose.yml` (gated behind `COMPOSE_PROFILES=exit`) and its `mixexit-data` volume declaration; update the top-of-file comment, which currently lists the exit as one of the four things the compose file brings up.
- The "Mixnet exit (optional)" block in `.env.example` (`COMPOSE_PROFILES=exit`, `FLOONET_EXIT_UPSTREAM`).
- The "Mixnet exit (optional)" section in `README.md`, and the `COMPOSE_PROFILES` row in its configuration-reference table.
**Add Tor as the replacement, first-class deploy option** for anyone standing up their own Floonet relay from this package — this is documentation, not custom code, since the underlying mechanism (system Tor hosting an onion service in front of a local listener) is exactly what's already proven live on us-east:
- For the Docker Compose path: a `tor` compose service (a minimal image running `tor` against a mounted `torrc`), analogous to how `caddy` already fronts the stack for TLS.
- For the `apply-spec.sh` + systemd path: a `torrc` snippet — `HiddenServiceDir` + `HiddenServicePort <port> 127.0.0.1:<relay-port>` — documented alongside the existing `floonet-strfry.service` / `floonet-authority.service` units.
Write up the recipe that's already running on us-east rather than describing it in the abstract, so the next operator doesn't have to rediscover it from first principles.
End state: same as Phase 5 — a fully-working relay with no mixnet code, and a documented, supported way to front it with an onion instead of the retired exit.
---
## Phase 7 (fast-follow, non-blocking) — Relay-side Poisson delay
**This does not block anything above.** The wallet is fully functional and privacy-preserving over Tor without it — the gift-wrap already hides content, sender, and recipient from the relay's write policy; Tor already hides the user's IP. Poisson delay is the one property a real mixnet had that plain Tor doesn't: timing unlinkability between "sender uploaded" and "recipient downloaded." It's valuable, but it's an enhancement on top of a working system, not a dependency of one.
**Why it's real engineering and not a config toggle:** production is strfry, and strfry is C++. There's no `[poisson_delay]` key to flip. Scope it as its own piece of work, on its own timeline, separate from the rest of this migration.
**The two honest options:**
- **(a) Patch strfry itself** — modify the broadcast fan-out path so a newly-accepted event, once matched against a live subscription, is held for a randomized delay before being pushed to that subscriber. This is the most "native" answer, but it cuts against something floonet-strfry's own README states as a deliberate property: strfry ships **stock and unpatched**, pinned at an upstream commit, specifically so "updating strfry is bumping one hash." Patching the core relay trades that property away and means carrying a fork forward across every future strfry upgrade.
- **(b) A small delay-queue proxy in front of strfry** — a thin, NIP-01-aware process that sits between the public/onion listener and strfry, passing everything through immediately (publishes, `OK` acks, `REQ`/`EOSE` backfill, `CLOSE`) *except* one specific category: a live push of a newly-matching event to a subscription that's already past its initial `EOSE` (i.e., an already-open, already-caught-up listener receiving something new in real time). Only that category gets held for the randomized delay. This keeps strfry itself untouched, at the cost of one more small service to run and maintain — but it's a service in the same spirit as the exit it's replacing conceptually (a small, purpose-built piece Floonet already owns and runs), not a new kind of operational burden.
**A design question worth resolving before scoping either option**, based on how strfry is actually built (not a confirmed finding, just reasoning worth writing down): does "release" need to mean *only* delaying the live push to an already-open subscription, or does it also need to delay the event becoming visible to a **backfill** query (a recipient's own `REQ` after coming back online)? The wallet plan's own framing suggests the narrower reading is sufficient — the threat being closed is an observer correlating a live send with a live receive, and a recipient who was offline at send-time and catches up hours later was never at risk of that correlation regardless of when their catch-up query resolves. If that reading holds, option (b) above only ever needs to touch the live-push path, which is a meaningfully smaller and safer piece of surgery than anything that reaches into strfry's storage or negentropy-sync semantics. Confirm this reading explicitly before scoping the work — it changes the shape of the fix.
**Whichever option is chosen, the acceptance bar is unchanged from the wallet plan:** the sender's "Sent" must still fire the moment the relay confirms it holds the message, not when the recipient actually receives it — the delay has to land entirely inside the gap that's already invisible to the user. The wallet's confirm-before-sent read-back logic is not to be touched or waited on by this work.
---
## Phase 8 — goblin-nip05d gets its own onion
**No code change in goblin-nip05d.** This is a deploy-layer addition only, following the exact pattern already proven for the relay.
- Add a **second** Tor hidden service on the same box (us-east) as the relay's onion, pointed at the authority's local listener (`127.0.0.1:8191`) instead of the relay's (`127.0.0.1:8292`). One Tor daemon hosts any number of onion services — this is an additional `HiddenServiceDir`/`HiddenServicePort` stanza in the same `torrc`, not a second Tor install.
- It needs its own address (rather than riding the relay's) because it's a genuinely separate service: different local port, different nginx vhost, different backend entirely.
- Once live, this closes the same gap for name lookups that the relay's onion already closed for messages: today, resolving or claiming `name@goblin.st` over Tor still means dialing goblin.st's *clearnet* host from inside the Tor circuit (a Tor exit hop), which works but is one more hop than necessary and puts a Tor exit node in a position to see the destination. An onion goes straight there instead, matching the wallet plan's stated aim for this seam (`http_request`/`http_request_bytes`: "Tor→onion for the goblin.st name authority").
- Validate the same way the relay's onion was validated: `torify curl` (or equivalent) against `https://<new-onion>/.well-known/nostr.json?name=<test>` and confirm it resolves.
- Hand the resulting address to wherever the wallet pins its goblin.st onion once that lands (likely alongside the relay-pool gist, or a build-time constant — confirm the actual mechanism when the wallet side gets there, since it may not be the same gist the relay onion uses).
---
## Phase 9 — Ops & health-probe follow-ups
- **Back up the onion service's identity.** A hidden service's address is derived from a key inside its `HiddenServiceDir`; lose that directory and the address rotates, exactly like losing the old mixexit's data directory rotated its mixnet address and stranded pins. Both the relay's `HiddenServiceDir` and (once Phase 8 lands) the authority's need the same care the old exit's state directory got.
- **Extend health checks to the onion path, not just the clearnet hostname.** Today's monitoring only has to prove `relay.floonet.dev` answers. Once the onion is a real, wallet-facing path (not just a proven-once side channel), add a periodic probe — a torified websocket handshake or equivalent — so a regression there is caught the same day, not from a user report.
- **This matters more than usual on us-east specifically.** The box has a known failure mode: toggling `firewalld` breaks Docker's iptables chains, and any container that restarts afterward silently loses its port mapping until a full `systemctl restart docker` rebuilds them. An onion service pointed at a port that quietly stopped listening behind it would go dark exactly the same way, just as silently. A health probe on the onion itself is the only thing that catches that before a user does.
- **Monitor the Tor daemon as its own signal**, distinct from "is strfry up." A healthy relay behind a wedged or stopped system Tor process looks completely fine on clearnet and is simply unreachable over the onion — a new failure mode this migration introduces that didn't exist when the only path in was clearnet + nginx.
- **Documentation loose end:** `floonet-docs` (the mdBook site) has a `concepts/nym.md` page describing the mixnet exit. Once this migration lands, that page needs a Tor-equivalent written (or an explicit retirement notice pointing to one), so the published docs don't keep describing a path that no longer exists.
---
## Risks & notes
- **Sequencing is the main risk, and it's self-inflicted if skipped.** Don't disable the Nym exit (Phase 4) before the wallet's Tor build is shipped and proven (Phase 3). Don't strip the mixexit source out of either relay repo (Phases 56) before the production service is actually off (Phase 4) — otherwise you can end up with running infra whose source has already been deleted out from under it, which is a bad place to be if you need to roll back in a hurry.
- **Keep the public relay hostname working throughout.** Nothing in this migration touches `relay.floonet.dev`'s clearnet listener, its nginx/TLS front, or its DNS. The onion is additive in front of the same backend, not a replacement for the clearnet path — anything that still depends on clearnet (monitoring, NIP-11 probes, other clients, non-Goblin Nostr tooling) keeps working exactly as it does today, unaffected by any phase here.
- **The us-east Docker/firewall fragility is a standing gotcha, not new to this migration, but it now has a new victim if triggered.** Toggling `firewalld` or bouncing a single container can silently drop port mappings across the box; the fix is a full `systemctl restart docker`, which bounces everything (~17 containers, including this relay). Keep that in mind before touching firewalld or restarting containers on that box during any phase above, and see Phase 9 for making the onion's exposure to this failure mode actively monitored rather than a silent risk.
- **No clearnet fallback on the money path, ever.** This is primarily wallet-side discipline (the wallet plan is explicit: "fail loudly," never silently degrade to clearnet), but Floonet's job is to make sure that discipline is never actually tested by an outage — keep the onion solid (Phase 9's monitoring) so a wallet never has a reason to reach for a fallback that shouldn't exist.
- **Archive, don't delete, `floonet-mixexit`.** It's small, self-contained, and other Floonet operators may have their own copy or their own exit running against it. Archiving signals "retired" without pulling the rug out from under someone else's working deployment.
- **Phase 7 (Poisson) is explicitly not a gate on anything else.** Don't let its scoping or its C++-vs-proxy decision stall Phases 16, 8, or 9 — those are independent, lower-risk, and most of them are already de-risked by direct verification (see Phase 2's gist check).
---
## Quick reference: what to touch, per repo
| Repo | Touch |
|---|---|
| **floonet-strfry** (production) | Delete `mixexit/`, `deploy/systemd/floonet-mixexit.service`; remove the `mixexit` service + volume from `docker-compose.yml`; remove the exit block from `.env.example` and `README.md`; add a documented Tor deploy option (compose `tor` service or `torrc` snippet) |
| **floonet-rs** | Delete `mixexit/`, `src/exit.rs`; remove `pub mod exit;` (`lib.rs`), the `MixnetExit` struct + `Settings.exit` field + its `Default` entry (`config.rs`), the two `exit::validate`/`exit::spawn` call sites (`server.rs`), the commented `[exit]` block (`config.toml`) |
| **floonet-mixexit** (standalone) | Archive the repo once `floonet-mixexit-fdev.service` is disabled; do not delete |
| **goblin-nip05d** | No code change; add a second onion hidden service at the deploy layer, pointed at its local port |
| **us-east (ops)** | `systemctl disable --now floonet-mixexit-fdev`; add the authority's `HiddenServiceDir`/`HiddenServicePort`; extend health probes to the onion path and the Tor daemon itself; back up both `HiddenServiceDir`s |
| **relay-pool gist** (`2ro/79cd885540c88d074fe52f8388a3e5b4`) | Add `"onion": "m2ji5o6p6qapd4ies4wua64skjx2emd6lrp7hhvrib33ogveyihopryd.onion"` to the `relay.floonet.dev` entry now (Phase 2); remove its `exit` field once Phase 4 is done |
| **floonet-docs** | Add a Tor-equivalent of `concepts/nym.md`, or a retirement notice, once the migration lands |
+6 -3
View File
@@ -17,9 +17,12 @@
# 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.
# This single-domain stack is co-located by design: names AND the relay
# answer on FLOONET_DOMAIN (the FLOONET_AUTHORITY_COLOCATED=on default;
# a split nginx deploy opts in via deploy/us-east/colocated-authority.conf
# instead — see the README). 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/*
}
+5 -3
View File
@@ -21,9 +21,11 @@ dbParams {
}
events {
# Reject oversized events. 64 KiB comfortably fits profile metadata and
# large gift-wrapped payloads.
maxEventSize = 65536
# Reject oversized events. 128 KiB matches maxWebsocketPayloadSize below and
# fits a maximum-size NIP-59 gift-wrapped slatepack payment (~66 KiB worst
# case per the wallet's relay research), which the old 64 KiB cap could
# silently reject.
maxEventSize = 131072
# Clock-skew tolerance for future-dated events. NIP-59 gift wraps tweak
# created_at backwards (up to ~2 days), so they are unaffected by the
+30
View File
@@ -0,0 +1,30 @@
# Co-located Floonet name authority — the FLOONET_AUTHORITY_COLOCATED=on toggle.
#
# Serve NIP-05 names on the RELAY's own domain (so `name@relay.example`
# resolves) WITHOUT giving the authority its own vhost/cert. This is only
# needed for a split deploy where the relay and the authority live on separate
# subdomains behind nginx (the deploy/us-east/ pattern: relay.floonet.dev +
# nm.floonet.dev). The Docker Compose / Caddy stack is already co-located on a
# single FLOONET_DOMAIN, so it does not need this file.
#
# ENABLE (== FLOONET_AUTHORITY_COLOCATED=on): include this inside the relay's
# `:443` server block, BEFORE its `location /` WebSocket catch-all, e.g.
#
# include /etc/nginx/snippets/floonet-colocated-authority.conf;
#
# then `nginx -t && nginx -s reload`. DISABLE by removing the include.
#
# Only the exact-match READ lookup is exposed; registration and the rest of
# /api/* stay on the authority's own domain. The `location =` exact match wins
# over the relay catch-all regardless of file order, but keep it above
# `location /` for readability.
#
# Port 8193 is this box's authority bind (FLOONET_NAMES_BIND); the compose
# stack uses 8191 — match your own. X-Real-IP is SECURITY-CRITICAL: the
# authority keys ALL per-IP rate limiting off it, so a missing value collapses
# every client into one bucket and defeats the limiter.
location = /.well-known/nostr.json {
proxy_pass http://127.0.0.1:8193;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Deploy / re-deploy the Floonet name authority on the us-east box behind nginx
# at https://nm.floonet.dev, in paid-name mode wired to the local GoblinPay.
#
# Idempotent: safe to re-run. Run as root on the box. It does NOT touch the
# goblin.st production name service (goblin-nip05d) and never toggles firewalld.
#
# Prereqs already satisfied on us-east (documented for reproducibility):
# * DNS: nm.floonet.dev A -> 167.17.77.8 (pdnsutil add-record floonet.dev nm A 14400 167.17.77.8)
# * Rust 1.95 toolchain; GoblinPay live on 127.0.0.1:8192 (GP_API_TOKEN in goblinpay.env)
set -euo pipefail
REPO_DIR="${REPO_DIR:-/opt/goblin/gpbuild/floonet-name-authority}" # crate checkout
DATA_DIR="/opt/goblin/floonet-authority"
BIN=/usr/local/bin/floonet-name-authority
ENV_FILE=/etc/floonet-authority.env
UNIT_SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)" # deploy/
GP_ENV=/opt/goblin/goblinpay/goblinpay.env
DOMAIN=nm.floonet.dev
PORT=8193
echo "==> build (on-box, glibc-matched)"
( cd "$REPO_DIR" && . "$HOME/.cargo/env" 2>/dev/null || true; cargo build --release --locked )
install -m0755 "$REPO_DIR/target/release/floonet-name-authority" "$BIN"
echo "==> data dir"
install -d -m0750 -o goblin -g goblin "$DATA_DIR"
echo "==> env file (token pulled from GoblinPay's env, never echoed)"
if [ ! -f "$ENV_FILE" ]; then
GP_TOKEN="$(sed -n 's/^GP_API_TOKEN=//p' "$GP_ENV" | tr -d '\r\n')"
sed "s#__REPLACE_WITH_GP_API_TOKEN__#${GP_TOKEN}#" \
"$UNIT_SRC_DIR/us-east/floonet-authority.env.example" > "$ENV_FILE"
chown root:goblin "$ENV_FILE"; chmod 0640 "$ENV_FILE"
fi
echo "==> systemd unit + us-east drop-in"
install -m0644 "$UNIT_SRC_DIR/systemd/floonet-authority.service" /etc/systemd/system/
install -d -m0755 /etc/systemd/system/floonet-authority.service.d
install -m0644 "$UNIT_SRC_DIR/us-east/floonet-authority.service.d/10-us-east.conf" \
/etc/systemd/system/floonet-authority.service.d/
systemctl daemon-reload
echo "==> nginx: acme (:80) first, then certbot, then TLS (:443)"
VHOST=/etc/nginx/sites-available/$DOMAIN.conf
if [ ! -f /etc/letsencrypt/live/$DOMAIN/fullchain.pem ]; then
# Stand up a temporary :80-only vhost so the HTTP-01 webroot resolves.
cat > "$VHOST" <<EOF
server {
listen 167.17.77.8:80;
server_name $DOMAIN;
location /.well-known/acme-challenge/ { root /var/www/acme-challenge; }
location / { return 301 https://\$host\$request_uri; }
}
EOF
ln -sf ../sites-available/$DOMAIN.conf /etc/nginx/sites-enabled/$DOMAIN.conf
nginx -t && nginx -s reload
certbot certonly --webroot -w /var/www/acme-challenge -d $DOMAIN \
--key-type ecdsa --non-interactive --agree-tos -m hostmaster@floonet.dev
fi
# Full vhost (:80 redirect + :443 proxy).
install -m0644 "$UNIT_SRC_DIR/us-east/$DOMAIN.conf" "$VHOST"
ln -sf ../sites-available/$DOMAIN.conf /etc/nginx/sites-enabled/$DOMAIN.conf
nginx -t && nginx -s reload
echo "==> start the authority (paid-name mode)"
systemctl enable --now floonet-authority
sleep 1
systemctl --no-pager --full status floonet-authority | head -5
curl -fsS "http://127.0.0.1:$PORT/api/v1/health" && echo " <- local health ok"
echo "==> done: https://$DOMAIN"
@@ -0,0 +1,40 @@
# /etc/floonet-authority.env — us-east production template (nm.floonet.dev).
#
# Copy to /etc/floonet-authority.env, fill GOBLINPAY_TOKEN from
# /opt/goblin/goblinpay/goblinpay.env (GP_API_TOKEN), then:
# install -m0640 -o root -g goblin floonet-authority.env /etc/floonet-authority.env
# Keep the real file (with the token) OUT of git — this is only the template.
# --- Identity ---
# Names are name@nm.floonet.dev; the well-known is served at this host, so the
# @domain and BASE_URL host must both equal nm.floonet.dev (the authority
# refuses to start otherwise). BASE_URL is load-bearing: NIP-98 `u`-tags are
# verified against <BASE_URL><path>.
FLOONET_DOMAIN=nm.floonet.dev
FLOONET_BASE_URL=https://nm.floonet.dev
FLOONET_RELAYS=wss://relay.floonet.dev
FLOONET_NAMES_BIND=127.0.0.1:8193
# FLOONET_NAMES_DB is set by the systemd drop-in:
# /opt/goblin/floonet-authority/names.db
# --- Paid names via GoblinPay (Grin), admin-priced ---
# off | name | write. `name` = claiming name@domain costs FLOONET_NAME_PRICE_GRIN.
FLOONET_PAY_MODE=name
FLOONET_NAME_PRICE_GRIN=1
# GoblinPay runs on the same box (goblin-pay.service, 127.0.0.1:8192). The
# authority calls {GOBLINPAY_URL}/invoice and GET /invoice/{id} server-side
# with a Bearer token; payers still land on GoblinPay's public hosted pay_url
# (GP_PUBLIC_URL). Loopback avoids a needless public round-trip.
GOBLINPAY_URL=http://127.0.0.1:8192
GOBLINPAY_TOKEN=__REPLACE_WITH_GP_API_TOKEN__
# Optional: instant settlement instead of polling. If set, point a GoblinPay
# webhook at https://nm.floonet.dev/api/v1/goblinpay/webhook.
#GOBLINPAY_WEBHOOK_SECRET=
# --- Rate-limit ceilings (per X-Real-IP; nginx sets it from $remote_addr) ---
FLOONET_READ_RATE_MAX=120
FLOONET_READ_RATE_WINDOW_SECS=60
FLOONET_WRITE_RATE_MAX=10
FLOONET_WRITE_RATE_WINDOW_SECS=3600
RUST_LOG=info
@@ -0,0 +1,27 @@
# us-east production overrides for the generic hardened unit
# (deploy/systemd/floonet-authority.service).
#
# The box keeps every Goblin service's data under /opt/goblin (a single backup
# root) and runs them as the unprivileged `goblin` account, so we swap the
# generic unit's DynamicUser for a stable owner and relocate the writable path.
# The base unit's own comment sanctions exactly this ("If you need a stable
# owner for the data dir ... set User="). Every other hardening directive from
# the base unit (ProtectSystem=strict, NoNewPrivileges, ProtectHome, the
# @system-service syscall filter, RestrictAddressFamilies, ...) is inherited
# unchanged — goblin's home is /opt/goblin/nip05d, not under /home, so
# ProtectHome=yes stays safe.
[Service]
DynamicUser=no
User=goblin
Group=goblin
# The base unit ships StateDirectory=floonet-authority plus a matching
# /var/lib writable path. Clear both (empty assignment resets the list) and
# point everything at the /opt/goblin tree instead.
StateDirectory=
ReadWritePaths=
ReadWritePaths=/opt/goblin/floonet-authority
WorkingDirectory=/opt/goblin/floonet-authority
# Applied after the base unit, so this wins over the base Environment= line.
Environment=FLOONET_NAMES_DB=/opt/goblin/floonet-authority/names.db
+38
View File
@@ -0,0 +1,38 @@
# Floonet name authority — nm.floonet.dev
#
# TLS terminates here; the authority listens on 127.0.0.1:8193 and keys ALL
# per-IP rate limiting off X-Real-IP, so setting it from $remote_addr is
# SECURITY-CRITICAL: a missing value collapses every client into one bucket
# and defeats the limiter. Mirrors the relay.floonet.dev vhost (same listen
# IP, same certbot webroot, same header set) minus the WebSocket upgrade,
# since the authority is a plain JSON/REST service.
server {
listen 167.17.77.8:80;
server_name nm.floonet.dev;
location /.well-known/acme-challenge/ { root /var/www/acme-challenge; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 167.17.77.8:443 ssl http2;
server_name nm.floonet.dev;
ssl_certificate /etc/letsencrypt/live/nm.floonet.dev/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nm.floonet.dev/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header X-Content-Type-Options "nosniff" always;
access_log /var/log/nginx/nm.floonet.dev.access.log;
error_log /var/log/nginx/nm.floonet.dev.error.log;
# Registration / quote bodies are tiny JSON; cap to keep abuse cheap.
client_max_body_size 16k;
location / {
proxy_pass http://127.0.0.1:8193;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
}
+67
View File
@@ -0,0 +1,67 @@
//! Mint a NIP-98 `Authorization: Nostr <base64-event>` header for calling this
//! authority's authenticated endpoints (register / unregister / quote) with a
//! plain HTTP client like `curl`. Handy for operators and CI: no external
//! nostr tooling required, since the crate already depends on `nostr`.
//!
//! Usage:
//! # generate a throwaway identity (prints its secret+pubkey to stderr)
//! cargo run --example nip98 -- GET /api/v1/name/alice
//!
//! # reuse an identity and sign over a request body
//! FLOONET_BASE_URL=https://nm.floonet.dev NIP98_SK=<64-hex-secret> \
//! cargo run --example nip98 -- POST /api/v1/register '{"name":"alice","pubkey":"<hex>"}'
//!
//! The header value is printed to stdout (nothing else), so it can be captured
//! straight into a curl invocation:
//!
//! AUTH=$(NIP98_SK=$SK cargo run -q --example nip98 -- POST /api/v1/register "$BODY")
//! curl -H "Authorization: $AUTH" -d "$BODY" https://nm.floonet.dev/api/v1/register
//!
//! The `u` tag is built from FLOONET_BASE_URL (default https://nm.floonet.dev),
//! which MUST equal the authority's configured base URL — that is what the
//! server verifies the signature's `u` tag against.
use base64::Engine;
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
use sha2::{Digest, Sha256};
fn main() {
let mut args = std::env::args().skip(1);
let method = args
.next()
.expect("usage: nip98 <METHOD> <PATH> [BODY] (e.g. POST /api/v1/register '{...}')");
let path = args
.next()
.expect("usage: nip98 <METHOD> <PATH> [BODY] (e.g. POST /api/v1/register '{...}')");
let body = args.next().unwrap_or_default();
let base_url =
std::env::var("FLOONET_BASE_URL").unwrap_or_else(|_| "https://nm.floonet.dev".to_string());
let keys = match std::env::var("NIP98_SK") {
Ok(sk) if !sk.trim().is_empty() => Keys::parse(sk.trim()).expect("invalid NIP98_SK"),
_ => {
let k = Keys::generate();
eprintln!("generated secret (hex): {}", k.secret_key().to_secret_hex());
k
}
};
eprintln!("pubkey (hex): {}", keys.public_key().to_hex());
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.as_bytes()));
tags.push(Tag::parse(["payload", &payload]).unwrap());
}
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.custom_created_at(Timestamp::now())
.sign_with_keys(&keys)
.expect("sign NIP-98 event");
let b64 = base64::engine::general_purpose::STANDARD.encode(event.as_json());
println!("Nostr {b64}");
}