mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-11 09:07:44 +00:00
nostr: public-IP discovery for UDP/TCP advert publication
When a UDP transport had `advertise_on_nostr: true` + `public: true` + `bind_addr: 0.0.0.0:NNNN`, the advert builder previously read the kernel's `local_addr()`, found `0.0.0.0`, filtered it out (correctly — wildcard isn't a valid advertised endpoint), and silently emitted no UDP endpoint in the published advert. Operators on AWS EIP / GCP / Azure setups (where binding to the public IP directly is impossible because 1:1 NAT does the address translation off-host) had no way to advertise UDP without binding to a specific local IP — and no log explaining what was happening. TCP had the same shape, with no `public: true` precondition. Three pieces, layered. UDP gets zero-config autodiscovery via STUN; both UDP and TCP get an explicit operator-supplied override; the fall-through path now logs loudly instead of silently skipping. UDP public-IP autodiscovery (STUN) ---------------------------------- In the UDP `is_public()` + wildcard-bind branch, run a one-shot STUN observation against an ephemeral UDP socket on the daemon's configured `stun_servers`. Take the reflexive IPv4 (the STUN-reported port is the ephemeral source port and is discarded), combine with the configured listener port for the advert (`udp:<reflexive-ip>:<port>`). Works on AWS EIP / GCP / Azure 1:1-NAT setups because STUN sees the public-Internet egress IP and the bind port is preserved through 1:1 NAT. Result is cached per-transport on a new `public_udp_addr_cache` field on `NostrDiscovery` (keyed by `TransportId.as_u32()`). Asymmetric cache TTL: a successful observation is cached for `advert_refresh_secs` (default 30 min) so we don't STUN every refresh tick. A failed observation is cached for only 60s (`PUBLIC_UDP_ADDR_FAILURE_TTL`) so a transient STUN flake at startup retries within ~a minute and the advert grows its UDP endpoint as soon as STUN starts working — rather than waiting the full 30-min cycle. The shared `observe_traversal_addresses` STUN helper had a hard-coded 2s per-server response wait, right for the per-traversal flow (latency-sensitive — 3 STUN servers worst-case = 6s) but too short for the one-shot advert-publish startup discovery. Parameterized `per_server_timeout` on the helper, with two named constants in `stun.rs`: `TRAVERSAL_STUN_TIMEOUT = 2s` (existing call sites) and `ADVERT_STUN_TIMEOUT = 5s` (new public-UDP discovery path). Both use `tokio::time::timeout_at` under the hood, so success returns immediately — the timeout is only the worst case. `external_addr` override (UDP + TCP) ------------------------------------ New `external_addr: Option<String>` field on `transports.udp.*` and `transports.tcp.*` for explicit advertise-as override. Takes precedence over both the bound `local_addr` and (for UDP) the STUN-derived autodiscovery. Required for TCP on cloud-NAT setups (AWS EIP, GCP/Azure external IPs) where binding to the public IP directly fails with `EADDRNOTAVAIL` because the public IP isn't on a host interface — the network fabric does 1:1 NAT off-host. Without this field the operator's only TCP path was "leave advert off" or "find a way to make the public IP locally bindable." For UDP, `external_addr` is optional but useful as a deterministic alternative to STUN. Operators who want to skip STUN egress, whose STUN servers are blocked, or who want the daemon to not depend on external services for advert content can specify it explicitly. The accessor parses two shapes: - Bare IP (`"54.183.70.180"` or `"2001:db8::1"`): combines with the configured `bind_addr` port. - Full host:port (`"54.183.70.180:8443"` or `"[2001:db8::1]:443"`): used verbatim — useful for port-forward setups where the externally-visible port differs from the bind port. Final precedence in `Node::build_overlay_advert` (now async, only caller `refresh_overlay_advert` was already async): - UDP: `external_addr` → non-wildcard `local_addr` → STUN → loud warn - TCP: `external_addr` → non-wildcard `local_addr` → loud warn Loud warns instead of silent skips ---------------------------------- The wildcard-bind fall-through paths now log a `warn!` pointing at the operator-side fixes: - UDP: "set transports.udp.external_addr, bind to a specific public IP, or ensure node.discovery.nostr.stun_servers is reachable" - TCP: "Either set external_addr to the public IP (recommended for cloud 1:1-NAT setups) or bind explicitly to the public IP" Replaces the silent skip that previously cost operators a debugging session when the advert mysteriously contained only the Tor onion endpoint. Tests ----- 11 new unit tests in `src/config/transport.rs` covering the parser (IPv4/IPv6, bare/full, malformed) and the accessor (UDP with default bind, UDP with explicit port override, UDP unset, TCP without bind_addr, TCP with bind_addr, TCP with full socket-addr override, parse_bind_port for IPv4/IPv6/malformed). The 38-test nostr suite still passes. CHANGELOG entries under `[Unreleased]` Fixed.
This commit is contained in:
+74
-10
@@ -1487,7 +1487,10 @@ impl Node {
|
||||
)
|
||||
}
|
||||
|
||||
fn build_overlay_advert(&self) -> Option<OverlayAdvert> {
|
||||
async fn build_overlay_advert(
|
||||
&self,
|
||||
bootstrap: &std::sync::Arc<NostrDiscovery>,
|
||||
) -> Option<OverlayAdvert> {
|
||||
if !self.config.node.discovery.nostr.enabled {
|
||||
return None;
|
||||
}
|
||||
@@ -1509,13 +1512,49 @@ impl Node {
|
||||
continue;
|
||||
}
|
||||
if cfg.is_public() {
|
||||
if let Some(addr) = handle.local_addr()
|
||||
&& !addr.ip().is_unspecified()
|
||||
{
|
||||
// Precedence:
|
||||
// 1. operator-supplied `external_addr` (skips STUN)
|
||||
// 2. non-wildcard `local_addr` (operator bound to
|
||||
// a specific public IP directly)
|
||||
// 3. STUN auto-discovery against ephemeral socket
|
||||
// 4. loud warn + omit endpoint
|
||||
if let Some(explicit) = cfg.external_advert_addr() {
|
||||
endpoints.push(OverlayEndpointAdvert {
|
||||
transport: OverlayTransportKind::Udp,
|
||||
addr: addr.to_string(),
|
||||
addr: explicit.to_string(),
|
||||
});
|
||||
} else {
|
||||
match handle.local_addr() {
|
||||
Some(addr) if !addr.ip().is_unspecified() => {
|
||||
endpoints.push(OverlayEndpointAdvert {
|
||||
transport: OverlayTransportKind::Udp,
|
||||
addr: addr.to_string(),
|
||||
});
|
||||
}
|
||||
Some(addr) => {
|
||||
let key = handle.transport_id().as_u32();
|
||||
let port = addr.port();
|
||||
if let Some(public) =
|
||||
bootstrap.learn_public_udp_addr(key, port).await
|
||||
{
|
||||
endpoints.push(OverlayEndpointAdvert {
|
||||
transport: OverlayTransportKind::Udp,
|
||||
addr: public.to_string(),
|
||||
});
|
||||
} else {
|
||||
warn!(
|
||||
transport_id = key,
|
||||
bind_addr = %addr,
|
||||
"advert: udp public=true bound to wildcard but \
|
||||
STUN observation failed; advertising no UDP \
|
||||
endpoint. Either set transports.udp.external_addr, \
|
||||
bind to a specific public IP, or ensure \
|
||||
node.discovery.nostr.stun_servers is reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
endpoints.push(OverlayEndpointAdvert {
|
||||
@@ -1532,13 +1571,38 @@ impl Node {
|
||||
if !cfg.advertise_on_nostr() {
|
||||
continue;
|
||||
}
|
||||
if let Some(addr) = handle.local_addr()
|
||||
&& !addr.ip().is_unspecified()
|
||||
{
|
||||
// Precedence:
|
||||
// 1. operator-supplied `external_addr` (only path that
|
||||
// works on cloud-NAT setups where the public IP is
|
||||
// not on a host interface).
|
||||
// 2. non-wildcard `local_addr` (operator bound to a
|
||||
// specific public IP directly).
|
||||
// 3. loud warn + omit endpoint (no TCP STUN equivalent).
|
||||
if let Some(explicit) = cfg.external_advert_addr() {
|
||||
endpoints.push(OverlayEndpointAdvert {
|
||||
transport: OverlayTransportKind::Tcp,
|
||||
addr: addr.to_string(),
|
||||
addr: explicit.to_string(),
|
||||
});
|
||||
} else {
|
||||
match handle.local_addr() {
|
||||
Some(addr) if !addr.ip().is_unspecified() => {
|
||||
endpoints.push(OverlayEndpointAdvert {
|
||||
transport: OverlayTransportKind::Tcp,
|
||||
addr: addr.to_string(),
|
||||
});
|
||||
}
|
||||
Some(addr) => {
|
||||
warn!(
|
||||
bind_addr = %addr,
|
||||
"advert: tcp advertise_on_nostr=true bound to wildcard \
|
||||
and no transports.tcp.external_addr set; advertising no \
|
||||
TCP endpoint. Either set external_addr to the public \
|
||||
IP (recommended for cloud 1:1-NAT setups) or bind \
|
||||
explicitly to the public IP"
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"tor" => {
|
||||
@@ -1577,7 +1641,7 @@ impl Node {
|
||||
&self,
|
||||
bootstrap: &std::sync::Arc<NostrDiscovery>,
|
||||
) -> Result<(), crate::discovery::nostr::BootstrapError> {
|
||||
let advert = self.build_overlay_advert();
|
||||
let advert = self.build_overlay_advert(bootstrap).await;
|
||||
bootstrap.update_local_advert(advert).await
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user