Walk through reviewer feedback on the Nostr-discovery docs and land 18 items. Bulk patterns: - `external_addr` / `public: true` semantics consistently misdescribed. The advert path is gated on `cfg.is_public()`; inside that branch the daemon picks an address by precedence (`external_addr`, non-wildcard `bind_addr`, STUN). The docs treated `public: true` and `external_addr` as alternatives when they are stacked: `public: true` is the master switch and `external_addr` populates the address inside it. Reconciled across `enable-nostr-discovery.md` and `advertise-your-node.md`: add `public: true` to the `external_addr` examples; replace "STUN as a logging cross-check" with "STUN is skipped entirely"; fix "neither flag is needed" for direct public bind (both flags still required); make the publish-tutorial Step 3 conditional on the chosen Step 2 path (STUN runs only on the `public: true` path); rewrite the troubleshooting "wrong public IP advertised" bullet with two coherent fixes. - `udp:nat` overpromised as a symmetric-NAT solution. Symmetric NAT on either side typically defeats the punch. Reframe `udp:nat` as best-effort hole-punching for nodes without a directly reachable UDP endpoint in the how-to, the publish tutorial (intro, callout, section heading rewrite from "If you're behind symmetric NAT" to "If your direct UDP advert isn't reachable"), the consume tutorial's "What's next" pointer, and `tutorials/README.md`. Promote reachability over named NAT classes: STUN can confirm the public IP but not that the listener-port mapping is open. - YAML "silently ignores unknown keys" is wrong. Config parser rejects unknown fields via `serde(deny_unknown_fields)` on the per-section structs; misspelled fields refuse the daemon's start with a parse-error line in the journal. Fixed in the publish tutorial's troubleshooting and the open-discovery tutorial's `policy` typo bullet. Mechanical fixes: - Repoint stale anchors. `getting-started.md` and `configuration.md` linked to `#installation` / `#inspect` on the README; the README has no such headings. Repoint to `#quick-start` and `cli-fipsctl.md`. Two stale anchors in the publish tutorial pointing at non-existent sub-scenarios in the how-to (`#sub-scenario-2c-...`, `#sub-scenario-2b-tor-onion-node`) repointed to the correct anchors. - Drop the `fipsctl show status` claim from the open-discovery troubleshooting bullet (`show_status` doesn't include `discovery.nostr.policy`). Replace with daemon startup logs. - Fix the `advertise: false` parenthetical in the consume-only tutorial (`default_advertise()` returns `true`; we set `false` explicitly for the consume-only path). - Drop the "supplies a relay list" overstatement in two activation paragraphs (the how-to and the design doc). Default relay / STUN-server lists ship in the config; both are optional overrides. - Add the missing `transports.udp.public` entry to the open-discovery tutorial's prerequisites checklist. Tutorial users coming out of advertise-your-node could be on either the direct-UDP (`public: true`) or `udp:nat` (`public: false`) path; list both. Files: docs/getting-started.md, docs/reference/configuration.md, docs/how-to/enable-nostr-discovery.md, docs/tutorials/README.md, docs/tutorials/advertise-your-node.md, docs/tutorials/resolve-peers-via-nostr.md, docs/tutorials/open-discovery.md, docs/design/fips-nostr-discovery.md.
13 KiB
Enable Nostr-Mediated Discovery and NAT Traversal
Nostr-mediated discovery lets FIPS nodes find each other (and punch
through UDP NAT) using public Nostr relays as the signaling channel.
The feature ships in every stock packaging artifact but is off by
default — it activates when an operator sets
node.discovery.nostr.enabled: true. Default relay and STUN-server
lists ship in the config; both are optional overrides. See
../design/fips-nostr-discovery.md
for the design and rationale; see
../reference/configuration.md for the
full knob inventory.
Nostr discovery provides three independent capabilities. They can be enabled separately; most deployments end up using two or three of them together.
- Resolve a known peer's address by npub. Your daemon consumes adverts from the relays to look up the current network endpoint for a peer you have configured by npub. You don't have to know their IP / port / transport in advance.
- Publish your own endpoint so others can resolve you. Your daemon publishes a signed advert listing the transports it will accept connections on. Has two sub-shapes depending on your network topology: UDP (using NAT traversal if needed) or TCP. Running a Tor onion service is a separate deployment mode, covered in its own section below.
- Discover peers without prior configuration. Your daemon subscribes to all adverts on a chosen application namespace and treats any publisher as a connection candidate. The most permissive posture; useful for ambient mesh participation.
Each capability is covered below as one or more scenarios with the minimal YAML fragment that enables it. Only keys relevant to Nostr discovery are shown; surrounding node, transport, TUN, DNS, and peer configuration follows the usual shape.
All scenarios assume node.identity is set to a persistent key — an
ephemeral identity would invalidate any advert the moment the node
restarts. See persistent-identity.md for
the persistent-key setup.
For hand-held walkthroughs of each capability, see the resolve-peers-via-nostr, advertise-your-node, and open-discovery tutorials.
Capability 1: Resolve a known peer's address by npub
The node does not publish any advert of its own. It only consumes
adverts for peers it has explicitly listed with via_nostr: true.
This is the right shape for a client that wants Nostr-mediated
resolution without becoming a rendezvous target itself.
node:
identity:
persistent: true
discovery:
nostr:
enabled: true
advertise: false
policy: configured_only
transports:
udp:
bind_addr: "0.0.0.0:2121"
peers:
- npub: "npub1peer..."
alias: "remote-node"
via_nostr: true
connect_policy: auto_connect
What this achieves: dial endpoints for this peer are taken from
the peer's published Nostr advert. configured_only is the
default — it is shown here for clarity.
Note: You can also supply a static address alongside
via_nostr: true(for example, while testing, or as a known-good fallback if the advert is stale). Add anaddressesblock to the peer entry; static addresses are tried first on dial and Nostr-resolved endpoints are appended as additional candidates.
Capability 2: Publish your own endpoint so others can resolve you
This capability has three sub-scenarios depending on the network shape your node sits behind.
Sub-scenario 2a: UDP (using NAT traversal if needed)
The node has a public IP (or a stable port-forward) and binds UDP on
a known port. It publishes udp:host:port to the advert relays. Any
peer that knows this node's npub and has Nostr discovery enabled can
dial it without knowing the address out-of-band.
When UDP is wildcard-bound (0.0.0.0:2121, the default), the daemon
needs help knowing what IP to put in the advert. There are two ways:
STUN auto-discovery (public: true) or an explicit override
(external_addr). Both are first-class options; pick the one that
fits the deployment.
node:
identity:
persistent: true
discovery:
nostr:
enabled: true
advertise: true
transports:
udp:
bind_addr: "0.0.0.0:2121"
advertise_on_nostr: true
public: true # ← STUN auto-discovery
Or, when the public IP is known up front (static residential IP, cloud Elastic IP behind 1:1 NAT, etc.):
transports:
udp:
bind_addr: "0.0.0.0:2121"
advertise_on_nostr: true
public: true # ← required, master switch
external_addr: "203.0.113.45:2121" # ← explicit address
external_addr accepts a bare IP (combined with the bind port) or a
full host:port. public: true is the master switch that gates UDP
advertisement; inside that branch, the daemon picks the advertised
address in precedence order: explicit external_addr (no STUN
observation), a non-wildcard bind_addr, or STUN auto-discovery.
Setting external_addr alongside public: true skips STUN entirely
— there is no logging cross-check. If UDP is bound directly to a
public IP rather than to a wildcard, neither external_addr nor STUN
is needed — but advertise_on_nostr: true and public: true are
still both required for the daemon to publish the endpoint.
What this achieves: the node publishes a single
udp:<public-ip>:2121 endpoint to the three default advert relays
(wss://relay.damus.io, wss://nos.lol, wss://offchain.pub).
What the other side needs: either a static addresses entry for this
peer, or a peer entry with via_nostr: true and an empty (or
omitted) addresses list — the advert-resolved endpoint will be used
at dial time. Static and Nostr-resolved addresses can also be
combined: when both are present, static addresses are tried first and
Nostr-resolved endpoints are appended as fallback.
When the node is behind NAT
If this node doesn't have a stable public UDP endpoint, advertise
udp:nat. The daemon runs the STUN + offer/answer exchange with
the peer and punches through the NAT to establish a direct UDP
link. The peer can either have a public endpoint of its own or
also be behind NAT — both shapes work, as long as at least one
side has a NAT type compatible with hole-punching.
node:
identity:
persistent: true
discovery:
nostr:
enabled: true
advertise: true
dm_relays: # overrides the default three-relay
- "wss://relay.damus.io" # set with two for demonstration;
- "wss://nos.lol" # omit this block to keep the defaults
stun_servers:
- "stun:stun.l.google.com:19302"
- "stun:stun.cloudflare.com:3478"
transports:
udp:
bind_addr: "0.0.0.0:2121"
advertise_on_nostr: true
public: false
peers:
- npub: "npub1peer..."
alias: "nat-peer"
addresses:
- transport: udp
addr: "nat"
via_nostr: true
connect_policy: auto_connect
What this achieves: the node publishes a udp:nat endpoint plus its
signaling relays in the advert. When either side initiates, an
encrypted offer is sealed to the peer's npub, a matching answer
comes back, and both sides punch at the negotiated time. On success,
the punch socket is adopted as an FMP UDP transport and Noise IK
proceeds normally.
Validation:
advertise_on_nostr: truewithpublic: falseon UDP requiresdm_relaysandstun_serversto be non-empty. Both ship with non-empty defaults (three relays and three STUN servers respectively), so the default config passes. The node fails startup only if the operator has explicitly emptied either list — audp:natadvert without signaling relays or STUN servers is unreachable by construction.
Hole-punching is best-effort. It works reliably when both sides are
full-cone or port-restricted NATs. Symmetric NAT on either side
typically defeats the punch — the public port a peer sees varies per
remote endpoint, so the address learned via STUN does not match the
mapping the peer actually needs. The punch attempt times out after
punch_duration_ms. udp:nat is the only NAT-traversal mechanism
in FIPS; when it can't succeed, there's no in-protocol substitute.
Being reachable then becomes a deployment-prerequisite question
rather than a transport question — a publicly reachable port (UDP
or TCP — both require the same kind of network resource) published
as a direct advert per Sub-scenario 2a or 2b.
Sub-scenario 2b: TCP
The node has a public IP (or a stable port-forward) and accepts
inbound TCP. It publishes tcp:host:port to the advert relays.
TCP endpoints exist to serve peers whose networks filter outbound UDP (corporate LANs, restrictive guest WiFi). NAT traversal does not apply: the publishing node is publicly reachable on TCP, and the dialing peer's network only needs to permit outbound TCP to the advertised port.
node:
identity:
persistent: true
discovery:
nostr:
enabled: true
advertise: true
transports:
tcp:
bind_addr: "0.0.0.0:8443"
advertise_on_nostr: true
external_addr: "203.0.113.45:8443"
external_addr is typically required on cloud setups (AWS Elastic
IP, etc.) where binding directly to the public IP returns
EADDRNOTAVAIL. When TCP is bound directly to a public IP, the
override is unnecessary.
What this achieves: the node publishes a tcp:<public-ip>:8443
endpoint to the advert relays. Peers with Nostr discovery enabled
dial by npub without out-of-band address exchange.
Tor onion node
A separate deployment mode for nodes that want anonymity and censorship-resistance properties on the data plane. Functionally this still uses Capability 2 (publishing an endpoint to advert relays) — the difference is that the published endpoint is a Tor hidden service rather than a public IP.
The node runs a Tor onion service in directory mode (Tor-managed
HiddenServiceDir) and advertises the .onion address. Peers dial
via their local Tor SOCKS5 proxy without ever knowing the onion
string out-of-band. For the Tor daemon side of this setup, including the inbound-mode
trade-offs and the torrc directives each requires, see
deploy-tor-onion.md.
node:
identity:
persistent: true
discovery:
nostr:
enabled: true
advertise: true
transports:
tor:
mode: directory
socks5_addr: "127.0.0.1:9050"
directory_service:
hostname_file: "/var/lib/tor/fips/hostname"
bind_addr: "127.0.0.1:8444"
advertise_on_nostr: true
What this achieves: the node publishes a tor:<hash>.onion:8443
endpoint alongside any other advertised transports. The advert itself
is still published over clearnet WebSocket relays — Tor protects the
data plane, not the discovery plane. See the security and threat
model section in
../design/fips-nostr-discovery.md
for the trade-off and how to route relay traffic through Tor as well.
Capability 3: Discover peers without prior configuration
Under policy: open, any node that publishes an advert under the
same app namespace becomes a candidate. Discovered peers are queued
for connection attempts subject to open_discovery_max_pending.
node:
identity:
persistent: true
discovery:
nostr:
enabled: true
advertise: true
policy: open
open_discovery_max_pending: 64
app: "my-experiment.v1"
transports:
udp:
bind_addr: "0.0.0.0:2121"
advertise_on_nostr: true
public: true
peers: []
What this achieves: peers are discovered entirely through ambient
advert traffic on the configured relays. Setting a non-default app
value (replacing fips-overlay-v1) scopes the discovery set to
participants who opt into the same experiment and avoids being joined
to unrelated overlays that happen to share the default namespace.
Scope warning: Open discovery is an admission-free mode. Any node that publishes on the same
appname and passes the peer-ACL check becomes a connection candidate. If you rely on peer ACLs for admission control, verify that list is set correctly before enabling this mode. See ../reference/security.md for the peer ACL format.
See also
- ../tutorials/resolve-peers-via-nostr.md — hand-held walkthrough of capability 1
- ../tutorials/advertise-your-node.md
— hand-held walkthrough of capability 2 (publish, plus a
short section on
udp:natNAT traversal) - ../tutorials/open-discovery.md — hand-held walkthrough of capability 3 (open ambient discovery, the additive policy: open mode)
- ../design/fips-nostr-discovery.md — discovery runtime design, security model
- ../reference/configuration.md —
full
node.discovery.nostr.*and per-transportadvertise_on_nostr/publictable - ../reference/nostr-events.md — Kind 37195 advert format, Kind 21059 traversal signaling, Kind 10050 inbox relay list
- deploy-tor-onion.md — Tor daemon-side setup for advertising onion endpoints