mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
discovery: add opt-in mDNS LAN discovery
Add scoped mDNS / DNS-SD discovery for peers on the same local link, giving sub-second pairing without a relay or NAT-traversal roundtrip. A node advertises its npub, protocol version, and an optional network scope over link-local multicast, and browses for matching adverts to initiate Noise handshakes against same-LAN peers. LAN discovery is disabled by default; operators enable it with node.discovery.lan.enabled: true. Default-off avoids reintroducing a per-LAN identity broadcast on nodes that have deliberately disabled other discovery channels, and avoids any multicast surprise on upgrade. The startup advertised-port picker now excludes bootstrap transports and selects a non-bootstrap operational UDP transport with a stable lowest-id selector, so the advertised port is deterministic across restarts rather than dependent on HashMap iteration order. This matches the per-dial transport selection used for discovered peers. Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
This commit is contained in:
committed by
Johnathan Corgan
co-authored by
Johnathan Corgan
parent
da0d9d39a0
commit
7d7b551ca1
@@ -15,6 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
without dropping links to peers that remain in the set.
|
without dropping links to peers that remain in the set.
|
||||||
`PeerAddress` gains a `seen_at_ms` recency field (with
|
`PeerAddress` gains a `seen_at_ms` recency field (with
|
||||||
`with_seen_at_ms`) used to prefer more recently observed addresses.
|
`with_seen_at_ms`) used to prefer more recently observed addresses.
|
||||||
|
- Opt-in mDNS / DNS-SD LAN discovery for sub-second pairing of peers on
|
||||||
|
the same local link, without a relay or NAT-traversal roundtrip.
|
||||||
|
Disabled by default; operators enable it with
|
||||||
|
`node.discovery.lan.enabled: true`. Configurable service type and an
|
||||||
|
optional `node.discovery.lan.scope` that isolates discovery to peers
|
||||||
|
sharing the same private-network scope. The advertised UDP port is
|
||||||
|
chosen from a non-bootstrap operational UDP transport using a stable
|
||||||
|
selector, so it is deterministic across restarts.
|
||||||
- `pool_inbound` and `pool_outbound` counters on the TCP and Tor
|
- `pool_inbound` and `pool_outbound` counters on the TCP and Tor
|
||||||
transport stats (`TcpStats`, `TorStats`). Per-direction accounting
|
transport stats (`TcpStats`, `TorStats`). Per-direction accounting
|
||||||
is updated at every pool-insert and receive-loop-exit site, plus on
|
is updated at every pool-insert and receive-loop-exit site, plus on
|
||||||
|
|||||||
Generated
+141
-10
@@ -1073,6 +1073,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"libc",
|
"libc",
|
||||||
|
"mdns-sd",
|
||||||
"nostr",
|
"nostr",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
@@ -1106,6 +1107,17 @@ version = "0.4.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flume"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"spin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -1505,6 +1517,16 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "if-addrs"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -1764,6 +1786,21 @@ dependencies = [
|
|||||||
"regex-automata",
|
"regex-automata",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdns-sd"
|
||||||
|
version = "0.19.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2bb8ce26633738d98ffcef71ec58bff967c6675be50229823c2835f6316e67e"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"flume",
|
||||||
|
"if-addrs",
|
||||||
|
"log",
|
||||||
|
"mio",
|
||||||
|
"socket-pktinfo",
|
||||||
|
"socket2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -3008,6 +3045,17 @@ version = "1.15.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket-pktinfo"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"socket2",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -3018,6 +3066,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spin"
|
||||||
|
version = "0.9.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -3914,7 +3971,7 @@ version = "0.52.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3923,7 +3980,16 @@ version = "0.59.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.53.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3941,14 +4007,31 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu",
|
"windows_i686_gnu 0.52.6",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm 0.52.6",
|
||||||
"windows_i686_msvc",
|
"windows_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc",
|
"windows_x86_64_msvc 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.53.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows_aarch64_gnullvm 0.53.1",
|
||||||
|
"windows_aarch64_msvc 0.53.1",
|
||||||
|
"windows_i686_gnu 0.53.1",
|
||||||
|
"windows_i686_gnullvm 0.53.1",
|
||||||
|
"windows_i686_msvc 0.53.1",
|
||||||
|
"windows_x86_64_gnu 0.53.1",
|
||||||
|
"windows_x86_64_gnullvm 0.53.1",
|
||||||
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3957,48 +4040,96 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.55.0"
|
version = "0.55.0"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process", "io-util"] }
|
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process", "io-util"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
simple-dns = "0.11.2"
|
simple-dns = "0.11.2"
|
||||||
|
mdns-sd = "0.19"
|
||||||
socket2 = { version = "0.6.2", features = ["all"] }
|
socket2 = { version = "0.6.2", features = ["all"] }
|
||||||
tokio-socks = "0.5"
|
tokio-socks = "0.5"
|
||||||
portable-atomic = { version = "1", features = ["std"] }
|
portable-atomic = { version = "1", features = ["std"] }
|
||||||
|
|||||||
@@ -219,6 +219,12 @@ pub struct DiscoveryConfig {
|
|||||||
/// Nostr-mediated overlay endpoint discovery.
|
/// Nostr-mediated overlay endpoint discovery.
|
||||||
#[serde(default = "DiscoveryConfig::default_nostr")]
|
#[serde(default = "DiscoveryConfig::default_nostr")]
|
||||||
pub nostr: NostrDiscoveryConfig,
|
pub nostr: NostrDiscoveryConfig,
|
||||||
|
/// mDNS / DNS-SD peer discovery on the local link. Identity surface
|
||||||
|
/// is a strict subset of what `nostr.advertise` already publishes
|
||||||
|
/// publicly, so there's no marginal privacy cost; the latency win
|
||||||
|
/// for same-LAN peers is large (sub-second pairing, no relay).
|
||||||
|
#[serde(default = "DiscoveryConfig::default_lan")]
|
||||||
|
pub lan: crate::discovery::lan::LanDiscoveryConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DiscoveryConfig {
|
impl Default for DiscoveryConfig {
|
||||||
@@ -231,6 +237,7 @@ impl Default for DiscoveryConfig {
|
|||||||
backoff_max_secs: 0,
|
backoff_max_secs: 0,
|
||||||
forward_min_interval_secs: 2,
|
forward_min_interval_secs: 2,
|
||||||
nostr: NostrDiscoveryConfig::default(),
|
nostr: NostrDiscoveryConfig::default(),
|
||||||
|
lan: crate::discovery::lan::LanDiscoveryConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,6 +264,9 @@ impl DiscoveryConfig {
|
|||||||
fn default_nostr() -> NostrDiscoveryConfig {
|
fn default_nostr() -> NostrDiscoveryConfig {
|
||||||
NostrDiscoveryConfig::default()
|
NostrDiscoveryConfig::default()
|
||||||
}
|
}
|
||||||
|
fn default_lan() -> crate::discovery::lan::LanDiscoveryConfig {
|
||||||
|
crate::discovery::lan::LanDiscoveryConfig::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nostr advert discovery policy.
|
/// Nostr advert discovery policy.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! it hands the live socket and selected remote endpoint to FIPS so the
|
//! it hands the live socket and selected remote endpoint to FIPS so the
|
||||||
//! existing Noise/FMP transport path can take over.
|
//! existing Noise/FMP transport path can take over.
|
||||||
|
|
||||||
|
pub mod lan;
|
||||||
pub mod nostr;
|
pub mod nostr;
|
||||||
|
|
||||||
use crate::config::UdpConfig;
|
use crate::config::UdpConfig;
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
//! LAN peer discovery via mDNS / DNS-SD (RFC 6762 / RFC 6763).
|
||||||
|
//!
|
||||||
|
//! Publishes a `_fips._udp.local.` service advert carrying our `npub` and
|
||||||
|
//! optional discovery scope on the local link, and concurrently browses for the
|
||||||
|
//! same service type to learn peers reachable on the same broadcast
|
||||||
|
//! domain. The result is sub-second peer pairing without any Nostr-relay
|
||||||
|
//! roundtrip, STUN observation, or NAT traversal — the observed
|
||||||
|
//! endpoint is by construction routable from the consumer's LAN.
|
||||||
|
//!
|
||||||
|
//! ## Trust model
|
||||||
|
//!
|
||||||
|
//! mDNS adverts are unauthenticated: anyone on the LAN can multicast a
|
||||||
|
//! TXT carrying `npub=...`. Identity is still proven end-to-end by the
|
||||||
|
//! Noise XX handshake the Node initiates against the observed endpoint
|
||||||
|
//! — a spoofed advert with another peer's npub fails the handshake and
|
||||||
|
//! is silently dropped. Treat the mDNS advert as a routing hint, not as
|
||||||
|
//! identity. LAN discovery is link-local mDNS only. It is not a Nostr advert
|
||||||
|
//! and does not leave the broadcast domain unless the operator's LAN bridges
|
||||||
|
//! mDNS.
|
||||||
|
//!
|
||||||
|
//! ## Scope filtering
|
||||||
|
//!
|
||||||
|
//! When a `discovery_scope` is configured, the advert carries it in a
|
||||||
|
//! `scope=<name>` TXT entry and the browser only surfaces peers with a
|
||||||
|
//! matching scope. Nodes on the same physical LAN but configured for
|
||||||
|
//! different mesh networks don't cross-feed each other.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::{SocketAddr, SocketAddrV4, SocketAddrV6};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use mdns_sd::{ScopedIp, ServiceDaemon, ServiceEvent, ServiceInfo};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::Identity;
|
||||||
|
|
||||||
|
/// DNS-SD service type for the FIPS LAN advert. RFC 6763 §4.1.2: must
|
||||||
|
/// end with `.local.`. The `_udp` is the IP transport, not the upper
|
||||||
|
/// protocol — both UDP and TCP FIPS endpoints announce under the same
|
||||||
|
/// service type because the link-layer punch/handshake travels over UDP
|
||||||
|
/// either way.
|
||||||
|
pub const SERVICE_TYPE: &str = "_fips._udp.local.";
|
||||||
|
|
||||||
|
/// TXT key carrying the bech32-encoded npub of the publishing node.
|
||||||
|
pub const TXT_KEY_NPUB: &str = "npub";
|
||||||
|
|
||||||
|
/// TXT key carrying the publishing node's `discovery_scope`, if any.
|
||||||
|
pub const TXT_KEY_SCOPE: &str = "scope";
|
||||||
|
|
||||||
|
/// TXT key carrying the FIPS protocol version (matches the Nostr advert
|
||||||
|
/// `PROTOCOL_VERSION`).
|
||||||
|
pub const TXT_KEY_VERSION: &str = "v";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum LanDiscoveryError {
|
||||||
|
#[error("mDNS daemon init failed: {0}")]
|
||||||
|
Daemon(String),
|
||||||
|
#[error("mDNS register failed: {0}")]
|
||||||
|
Register(String),
|
||||||
|
#[error("mDNS browse failed: {0}")]
|
||||||
|
Browse(String),
|
||||||
|
#[error("no advertised UDP port — start a UDP transport first")]
|
||||||
|
NoAdvertisedPort,
|
||||||
|
#[error("LAN discovery disabled in config")]
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A peer we learned about via mDNS. Identity is unverified at this
|
||||||
|
/// point; the Node initiates a Noise XX handshake against `addr` to
|
||||||
|
/// confirm `npub` actually controls the matching private key.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LanDiscoveredPeer {
|
||||||
|
pub npub: String,
|
||||||
|
pub scope: Option<String>,
|
||||||
|
pub addr: SocketAddr,
|
||||||
|
pub observed_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browser-side events surfaced by `LanDiscovery::drain_events`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum LanEvent {
|
||||||
|
Discovered(LanDiscoveredPeer),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime configuration for the mDNS responder + browser.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct LanDiscoveryConfig {
|
||||||
|
/// Master switch. Default: `false` — LAN discovery is opt-in. Operators
|
||||||
|
/// who want sub-second same-LAN pairing enable it via
|
||||||
|
/// `node.discovery.lan.enabled: true`. Default-off avoids reintroducing
|
||||||
|
/// a per-LAN identity broadcast on nodes that have deliberately disabled
|
||||||
|
/// other discovery channels, and avoids any multicast surprise on upgrade.
|
||||||
|
#[serde(default = "LanDiscoveryConfig::default_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Overridable service type, primarily so integration tests can run
|
||||||
|
/// multiple isolated services on the same loopback interface.
|
||||||
|
#[serde(default = "LanDiscoveryConfig::default_service_type")]
|
||||||
|
pub service_type: String,
|
||||||
|
/// Optional application/network scope carried in the LAN-only TXT
|
||||||
|
/// record. Browsers that set a scope ignore adverts for other scopes.
|
||||||
|
///
|
||||||
|
/// This is intentionally separate from Nostr discovery's public `app`
|
||||||
|
/// tag so applications can keep relay-visible adverts generic while
|
||||||
|
/// still isolating LAN discovery per private network.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub scope: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LanDiscoveryConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: Self::default_enabled(),
|
||||||
|
service_type: Self::default_service_type(),
|
||||||
|
scope: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LanDiscoveryConfig {
|
||||||
|
fn default_enabled() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn default_service_type() -> String {
|
||||||
|
SERVICE_TYPE.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Running mDNS responder + browser bound to the node's UDP advert port.
|
||||||
|
pub struct LanDiscovery {
|
||||||
|
daemon: ServiceDaemon,
|
||||||
|
own_npub: String,
|
||||||
|
instance_fullname: String,
|
||||||
|
events_rx: Mutex<tokio::sync::mpsc::UnboundedReceiver<LanEvent>>,
|
||||||
|
event_pump: tokio::task::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LanDiscovery {
|
||||||
|
/// Start the mDNS responder and browser.
|
||||||
|
///
|
||||||
|
/// `advertised_port` is the UDP port the operational UDP transport
|
||||||
|
/// is bound to — peers receiving our advert will initiate Noise XX
|
||||||
|
/// against that port. `scope` mirrors the Nostr discovery scope and
|
||||||
|
/// is used to filter the browser stream.
|
||||||
|
pub async fn start(
|
||||||
|
identity: &Identity,
|
||||||
|
scope: Option<String>,
|
||||||
|
advertised_port: u16,
|
||||||
|
config: LanDiscoveryConfig,
|
||||||
|
) -> Result<Arc<Self>, LanDiscoveryError> {
|
||||||
|
if !config.enabled {
|
||||||
|
return Err(LanDiscoveryError::Disabled);
|
||||||
|
}
|
||||||
|
if advertised_port == 0 {
|
||||||
|
return Err(LanDiscoveryError::NoAdvertisedPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
let daemon = ServiceDaemon::new().map_err(|e| LanDiscoveryError::Daemon(e.to_string()))?;
|
||||||
|
|
||||||
|
let npub = identity.npub();
|
||||||
|
// mDNS DNS labels are capped at 63 bytes. 16 bech32 chars of npub
|
||||||
|
// give 80 bits of effective entropy — collisions on a single LAN
|
||||||
|
// are vanishingly unlikely. Prefixed for human-readable logs.
|
||||||
|
let label_npub = &npub[..16.min(npub.len())];
|
||||||
|
let instance_name = format!("fips-{label_npub}");
|
||||||
|
let host_name = format!("{instance_name}.local.");
|
||||||
|
|
||||||
|
let mut props: HashMap<String, String> = HashMap::new();
|
||||||
|
props.insert(TXT_KEY_NPUB.to_string(), npub.clone());
|
||||||
|
if let Some(s) = scope.as_deref()
|
||||||
|
&& !s.is_empty()
|
||||||
|
{
|
||||||
|
props.insert(TXT_KEY_SCOPE.to_string(), s.to_string());
|
||||||
|
}
|
||||||
|
props.insert(
|
||||||
|
TXT_KEY_VERSION.to_string(),
|
||||||
|
super::nostr::PROTOCOL_VERSION.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// host_ipv4 is set to "127.0.0.1" *and* enable_addr_auto() is
|
||||||
|
// called: the loopback seed makes the advert resolve for
|
||||||
|
// same-host peers (and same-host integration tests) while the
|
||||||
|
// auto-flag still appends every non-loopback interface address
|
||||||
|
// mdns-sd discovers. Belt-and-braces because addr_auto alone
|
||||||
|
// skips loopback by default on some platforms.
|
||||||
|
let service_info = ServiceInfo::new(
|
||||||
|
&config.service_type,
|
||||||
|
&instance_name,
|
||||||
|
&host_name,
|
||||||
|
"127.0.0.1",
|
||||||
|
advertised_port,
|
||||||
|
Some(props),
|
||||||
|
)
|
||||||
|
.map_err(|e| LanDiscoveryError::Register(e.to_string()))?
|
||||||
|
.enable_addr_auto();
|
||||||
|
|
||||||
|
let instance_fullname = service_info.get_fullname().to_string();
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.register(service_info)
|
||||||
|
.map_err(|e| LanDiscoveryError::Register(e.to_string()))?;
|
||||||
|
|
||||||
|
let browse_rx = daemon
|
||||||
|
.browse(&config.service_type)
|
||||||
|
.map_err(|e| LanDiscoveryError::Browse(e.to_string()))?;
|
||||||
|
|
||||||
|
let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let own_npub = npub.clone();
|
||||||
|
let scope_filter = scope.clone().filter(|s| !s.is_empty());
|
||||||
|
let event_pump = tokio::spawn(async move {
|
||||||
|
// mdns-sd browse returns a flume::Receiver; pump until the
|
||||||
|
// daemon shuts down and the channel closes.
|
||||||
|
loop {
|
||||||
|
let event = match browse_rx.recv_async().await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
match event {
|
||||||
|
ServiceEvent::ServiceResolved(info) => {
|
||||||
|
let mut peer_npub: Option<String> = None;
|
||||||
|
let mut peer_scope: Option<String> = None;
|
||||||
|
for prop in info.get_properties().iter() {
|
||||||
|
match prop.key() {
|
||||||
|
TXT_KEY_NPUB => {
|
||||||
|
peer_npub = Some(prop.val_str().to_string());
|
||||||
|
}
|
||||||
|
TXT_KEY_SCOPE => {
|
||||||
|
peer_scope = Some(prop.val_str().to_string());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(peer_npub) = peer_npub else {
|
||||||
|
debug!(
|
||||||
|
instance = info.get_fullname(),
|
||||||
|
"lan: skip advert without npub TXT"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if peer_npub == own_npub {
|
||||||
|
// Our own advert echoed back on a loopback
|
||||||
|
// or multi-homed interface.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if scope_filter.is_some() && scope_filter != peer_scope {
|
||||||
|
debug!(
|
||||||
|
npub = %short(&peer_npub),
|
||||||
|
their_scope = ?peer_scope,
|
||||||
|
our_scope = ?scope_filter,
|
||||||
|
"lan: skip cross-scope advert"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let port = info.get_port();
|
||||||
|
if port == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let observed_at = Instant::now();
|
||||||
|
// mdns-sd may report multiple interface IPs for
|
||||||
|
// a multi-homed responder. Surface all routable
|
||||||
|
// candidates — the Node side filters/dedups and
|
||||||
|
// only dials addresses compatible with an active
|
||||||
|
// UDP socket family. IPv6 link-local addresses
|
||||||
|
// require an interface scope; preserve it when
|
||||||
|
// mdns-sd provides one, and skip unusable
|
||||||
|
// scope-less link-local records.
|
||||||
|
for scoped in info.get_addresses() {
|
||||||
|
let Some(addr) = socket_addr_from_scoped_ip(scoped, port) else {
|
||||||
|
debug!(
|
||||||
|
npub = %short(&peer_npub),
|
||||||
|
addr = %scoped.to_ip_addr(),
|
||||||
|
"lan: skip scope-less IPv6 link-local advert"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if events_tx
|
||||||
|
.send(LanEvent::Discovered(LanDiscoveredPeer {
|
||||||
|
npub: peer_npub.clone(),
|
||||||
|
scope: peer_scope.clone(),
|
||||||
|
addr,
|
||||||
|
observed_at,
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ServiceEvent::ServiceRemoved(_, fullname) => {
|
||||||
|
debug!(fullname = %fullname, "lan: service removed");
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
debug!(?other, "lan: mDNS event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
info!(
|
||||||
|
instance = %instance_fullname,
|
||||||
|
port = advertised_port,
|
||||||
|
scope = ?scope,
|
||||||
|
"lan: mDNS discovery started"
|
||||||
|
);
|
||||||
|
Ok(Arc::new(Self {
|
||||||
|
daemon,
|
||||||
|
own_npub: npub,
|
||||||
|
instance_fullname,
|
||||||
|
events_rx: Mutex::new(events_rx),
|
||||||
|
event_pump,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bech32 npub published by this node.
|
||||||
|
pub fn own_npub(&self) -> &str {
|
||||||
|
&self.own_npub
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain pending browser events. Called once per Node tick.
|
||||||
|
pub async fn drain_events(&self) -> Vec<LanEvent> {
|
||||||
|
let mut rx = self.events_rx.lock().await;
|
||||||
|
let mut events = Vec::new();
|
||||||
|
while let Ok(event) = rx.try_recv() {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear down the responder, browser, and event pump.
|
||||||
|
pub async fn shutdown(self: &Arc<Self>) {
|
||||||
|
if let Err(e) = self.daemon.unregister(&self.instance_fullname) {
|
||||||
|
warn!(error = %e, "lan: unregister failed");
|
||||||
|
}
|
||||||
|
if let Err(e) = self.daemon.shutdown() {
|
||||||
|
warn!(error = %e, "lan: daemon shutdown failed");
|
||||||
|
}
|
||||||
|
self.event_pump.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn short(npub: &str) -> &str {
|
||||||
|
let end = 16.min(npub.len());
|
||||||
|
&npub[..end]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn socket_addr_from_scoped_ip(scoped: &ScopedIp, port: u16) -> Option<SocketAddr> {
|
||||||
|
match scoped {
|
||||||
|
ScopedIp::V4(v4) => Some(SocketAddr::V4(SocketAddrV4::new(*v4.addr(), port))),
|
||||||
|
ScopedIp::V6(v6) => {
|
||||||
|
let ip = *v6.addr();
|
||||||
|
let scope_id = v6.scope_id().index;
|
||||||
|
if ipv6_is_unicast_link_local(ip) && scope_id == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(SocketAddr::V6(SocketAddrV6::new(ip, port, 0, scope_id)))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ipv6_is_unicast_link_local(ip: std::net::Ipv6Addr) -> bool {
|
||||||
|
(ip.segments()[0] & 0xffc0) == 0xfe80
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::Identity;
|
||||||
|
use mdns_sd::ScopedIp;
|
||||||
|
|
||||||
|
use super::{LanDiscovery, LanDiscoveryConfig, LanEvent};
|
||||||
|
|
||||||
|
/// Distinct service type per test run so concurrent cargo-test workers
|
||||||
|
/// on the same machine don't cross-feed each other's adverts via the
|
||||||
|
/// shared 224.0.0.251 multicast group. The trailing `.local.` is
|
||||||
|
/// required by RFC 6763 — mdns-sd will reject anything else.
|
||||||
|
fn isolated_service_type(tag: &str) -> String {
|
||||||
|
let rand: u32 = rand::random();
|
||||||
|
format!("_fipstest-{tag}-{rand:08x}._udp.local.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_for(service_type: String) -> LanDiscoveryConfig {
|
||||||
|
LanDiscoveryConfig {
|
||||||
|
enabled: true,
|
||||||
|
service_type,
|
||||||
|
scope: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scoped_ipv4_advert_becomes_socket_addr() {
|
||||||
|
let scoped = ScopedIp::from(IpAddr::V4(Ipv4Addr::new(192, 168, 178, 91)));
|
||||||
|
let addr = super::socket_addr_from_scoped_ip(&scoped, 51820);
|
||||||
|
|
||||||
|
assert_eq!(addr, Some(SocketAddr::from(([192, 168, 178, 91], 51820))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_less_ipv6_link_local_advert_is_skipped() {
|
||||||
|
let scoped = ScopedIp::from(IpAddr::V6("fe80::32c5:99ff:fea7:5fe9".parse().unwrap()));
|
||||||
|
|
||||||
|
assert!(super::socket_addr_from_scoped_ip(&scoped, 51820).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_link_local_ipv6_advert_is_preserved() {
|
||||||
|
let scoped = ScopedIp::from(IpAddr::V6(Ipv6Addr::LOCALHOST));
|
||||||
|
let addr = super::socket_addr_from_scoped_ip(&scoped, 51820);
|
||||||
|
|
||||||
|
assert_eq!(addr, Some("[::1]:51820".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_peer(
|
||||||
|
discovery: &LanDiscovery,
|
||||||
|
expected_npub: &str,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Option<super::LanDiscoveredPeer> {
|
||||||
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
for event in discovery.drain_events().await {
|
||||||
|
let LanEvent::Discovered(peer) = event;
|
||||||
|
if peer.npub == expected_npub {
|
||||||
|
return Some(peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two LanDiscovery instances on isolated service types — `a` browses
|
||||||
|
/// only its own type and never sees `b`, and vice versa. Sanity check
|
||||||
|
/// that the scope-isolation defense works (we'd lose isolation if mdns-
|
||||||
|
/// sd ever leaked across service types).
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn isolated_service_types_do_not_cross_feed() {
|
||||||
|
let identity_a = Identity::generate();
|
||||||
|
let identity_b = Identity::generate();
|
||||||
|
|
||||||
|
let service_a = isolated_service_type("isolated-a");
|
||||||
|
let service_b = isolated_service_type("isolated-b");
|
||||||
|
|
||||||
|
let lan_a = LanDiscovery::start(
|
||||||
|
&identity_a,
|
||||||
|
Some("scope-x".to_string()),
|
||||||
|
61001,
|
||||||
|
config_for(service_a.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("start a");
|
||||||
|
let lan_b = LanDiscovery::start(
|
||||||
|
&identity_b,
|
||||||
|
Some("scope-x".to_string()),
|
||||||
|
61002,
|
||||||
|
config_for(service_b.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("start b");
|
||||||
|
|
||||||
|
// Give mDNS multicast time to settle, then confirm neither side saw
|
||||||
|
// the other (different service type isolates them).
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
let saw_b_from_a = wait_for_peer(
|
||||||
|
&lan_a,
|
||||||
|
identity_b.npub().as_str(),
|
||||||
|
Duration::from_millis(500),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_some();
|
||||||
|
let saw_a_from_b = wait_for_peer(
|
||||||
|
&lan_b,
|
||||||
|
identity_a.npub().as_str(),
|
||||||
|
Duration::from_millis(500),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
lan_a.shutdown().await;
|
||||||
|
lan_b.shutdown().await;
|
||||||
|
|
||||||
|
assert!(!saw_b_from_a, "isolated service types must not cross-feed");
|
||||||
|
assert!(!saw_a_from_b, "isolated service types must not cross-feed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two LanDiscovery instances on the same service type and the same
|
||||||
|
/// scope: each should observe the other's advert within a few seconds.
|
||||||
|
/// Exercises the responder + browser + TXT plumbing end-to-end.
|
||||||
|
///
|
||||||
|
/// Ignored by default: relies on multicast-loopback semantics that
|
||||||
|
/// vary across macOS/Linux/Windows when two `ServiceDaemon` instances
|
||||||
|
/// run in the same process. Real cross-host LAN deployment exercises
|
||||||
|
/// the same code path correctly — verify with `cargo test -- --ignored
|
||||||
|
/// matched_scope_peers_observe_each_other` on a setup where this
|
||||||
|
/// matters, or via end-to-end integration with two daemons.
|
||||||
|
#[ignore]
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn matched_scope_peers_observe_each_other() {
|
||||||
|
let identity_a = Identity::generate();
|
||||||
|
let identity_b = Identity::generate();
|
||||||
|
|
||||||
|
let service = isolated_service_type("matched");
|
||||||
|
|
||||||
|
let lan_a = LanDiscovery::start(
|
||||||
|
&identity_a,
|
||||||
|
Some("scope-shared".to_string()),
|
||||||
|
61101,
|
||||||
|
config_for(service.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("start a");
|
||||||
|
let lan_b = LanDiscovery::start(
|
||||||
|
&identity_b,
|
||||||
|
Some("scope-shared".to_string()),
|
||||||
|
61102,
|
||||||
|
config_for(service.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("start b");
|
||||||
|
|
||||||
|
// Loopback mDNS resolution on macOS/Linux takes a moment.
|
||||||
|
let observed_b =
|
||||||
|
wait_for_peer(&lan_a, identity_b.npub().as_str(), Duration::from_secs(10)).await;
|
||||||
|
let observed_a =
|
||||||
|
wait_for_peer(&lan_b, identity_a.npub().as_str(), Duration::from_secs(10)).await;
|
||||||
|
|
||||||
|
lan_a.shutdown().await;
|
||||||
|
lan_b.shutdown().await;
|
||||||
|
|
||||||
|
let observed_b = observed_b.expect("a must see b");
|
||||||
|
let observed_a = observed_a.expect("b must see a");
|
||||||
|
|
||||||
|
assert_eq!(observed_b.scope.as_deref(), Some("scope-shared"));
|
||||||
|
assert_eq!(observed_a.scope.as_deref(), Some("scope-shared"));
|
||||||
|
assert_eq!(observed_b.addr.port(), 61102);
|
||||||
|
assert_eq!(observed_a.addr.port(), 61101);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Different scopes on the same service type must be filtered out by
|
||||||
|
/// the browser — peer in scope X does not surface to a browser in
|
||||||
|
/// scope Y, even if both adverts arrive on the same multicast group.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn cross_scope_advert_is_filtered() {
|
||||||
|
let identity_a = Identity::generate();
|
||||||
|
let identity_b = Identity::generate();
|
||||||
|
|
||||||
|
let service = isolated_service_type("cross-scope");
|
||||||
|
|
||||||
|
let lan_a = LanDiscovery::start(
|
||||||
|
&identity_a,
|
||||||
|
Some("scope-a".to_string()),
|
||||||
|
61201,
|
||||||
|
config_for(service.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("start a");
|
||||||
|
let lan_b = LanDiscovery::start(
|
||||||
|
&identity_b,
|
||||||
|
Some("scope-b".to_string()),
|
||||||
|
61202,
|
||||||
|
config_for(service.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("start b");
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
let saw_b = wait_for_peer(
|
||||||
|
&lan_a,
|
||||||
|
identity_b.npub().as_str(),
|
||||||
|
Duration::from_millis(500),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
lan_a.shutdown().await;
|
||||||
|
lan_b.shutdown().await;
|
||||||
|
|
||||||
|
assert!(saw_b.is_none(), "cross-scope advert must be filtered");
|
||||||
|
}
|
||||||
@@ -252,6 +252,7 @@ impl Node {
|
|||||||
self.reload_peer_acl();
|
self.reload_peer_acl();
|
||||||
self.poll_pending_connects().await;
|
self.poll_pending_connects().await;
|
||||||
self.poll_nostr_discovery().await;
|
self.poll_nostr_discovery().await;
|
||||||
|
self.poll_lan_discovery().await;
|
||||||
self.resend_pending_handshakes(now_ms).await;
|
self.resend_pending_handshakes(now_ms).await;
|
||||||
self.resend_pending_rekeys(now_ms).await;
|
self.resend_pending_rekeys(now_ms).await;
|
||||||
self.resend_pending_session_handshakes(now_ms).await;
|
self.resend_pending_session_handshakes(now_ms).await;
|
||||||
|
|||||||
+190
-9
@@ -15,6 +15,7 @@ use crate::transport::{Link, LinkDirection, LinkId, TransportAddr, TransportId,
|
|||||||
use crate::upper::tun::{TunDevice, TunState, run_tun_reader, shutdown_tun_interface};
|
use crate::upper::tun::{TunDevice, TunState, run_tun_reader, shutdown_tun_interface};
|
||||||
use crate::{NodeAddr, PeerIdentity};
|
use crate::{NodeAddr, PeerIdentity};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
@@ -23,6 +24,13 @@ const OPEN_DISCOVERY_RETRY_LIFETIME_MULTIPLIER: u64 = 2;
|
|||||||
const MAX_PARALLEL_PATH_CANDIDATES_PER_PEER: usize = 4;
|
const MAX_PARALLEL_PATH_CANDIDATES_PER_PEER: usize = 4;
|
||||||
const MAX_DISCOVERY_CONNECTS_PER_TICK: usize = 16;
|
const MAX_DISCOVERY_CONNECTS_PER_TICK: usize = 16;
|
||||||
|
|
||||||
|
fn socket_addr_families_compatible(local: SocketAddr, remote: SocketAddr) -> bool {
|
||||||
|
matches!(
|
||||||
|
(local, remote),
|
||||||
|
(SocketAddr::V4(_), SocketAddr::V4(_)) | (SocketAddr::V6(_), SocketAddr::V6(_))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
impl Node {
|
impl Node {
|
||||||
/// Replace the runtime peer list.
|
/// Replace the runtime peer list.
|
||||||
///
|
///
|
||||||
@@ -324,6 +332,31 @@ impl Node {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find a UDP transport whose bound socket can send to `remote_addr`.
|
||||||
|
///
|
||||||
|
/// LAN discovery can surface both IPv4 and IPv6 addresses for the same
|
||||||
|
/// service. A wildcard IPv4 socket cannot send to an IPv6 link-local
|
||||||
|
/// target, and vice versa, so callers must choose by socket family rather
|
||||||
|
/// than by transport type alone.
|
||||||
|
fn find_udp_transport_for_remote_addr(
|
||||||
|
&self,
|
||||||
|
remote_addr: SocketAddr,
|
||||||
|
) -> Option<(TransportId, SocketAddr)> {
|
||||||
|
self.transports
|
||||||
|
.iter()
|
||||||
|
.filter(|(id, handle)| {
|
||||||
|
handle.transport_type().name == "udp"
|
||||||
|
&& handle.is_operational()
|
||||||
|
&& !self.bootstrap_transports.contains(id)
|
||||||
|
})
|
||||||
|
.filter_map(|(id, handle)| {
|
||||||
|
let local_addr = handle.local_addr()?;
|
||||||
|
socket_addr_families_compatible(local_addr, remote_addr)
|
||||||
|
.then_some((*id, local_addr))
|
||||||
|
})
|
||||||
|
.min_by_key(|(id, _)| id.as_u32())
|
||||||
|
}
|
||||||
|
|
||||||
/// Initiate a connection to a peer on a specific transport and address.
|
/// Initiate a connection to a peer on a specific transport and address.
|
||||||
///
|
///
|
||||||
/// For connectionless transports (UDP, Ethernet): allocates a link, starts
|
/// For connectionless transports (UDP, Ethernet): allocates a link, starts
|
||||||
@@ -829,6 +862,90 @@ impl Node {
|
|||||||
self.queue_open_discovery_retries(&bootstrap).await;
|
self.queue_open_discovery_retries(&bootstrap).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the LAN-only discovery scope. Applications with explicit
|
||||||
|
/// connectivity config can set `node.discovery.lan.scope` without
|
||||||
|
/// changing the public Nostr discovery `app` tag. The older fallback
|
||||||
|
/// extracts a scope from the Nostr app tag used by default scoped
|
||||||
|
/// discovery.
|
||||||
|
pub(super) fn lan_discovery_scope(&self) -> Option<String> {
|
||||||
|
if let Some(scope) = self.config.node.discovery.lan.scope.as_deref() {
|
||||||
|
let scope = scope.trim();
|
||||||
|
if !scope.is_empty() {
|
||||||
|
return Some(scope.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = self.config.node.discovery.nostr.app.trim();
|
||||||
|
if app.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(rest) = app.strip_prefix("fips-overlay-v1:") {
|
||||||
|
let scope = rest.trim();
|
||||||
|
if scope.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(scope.to_string())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(app.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain mDNS-discovered peers and initiate Noise XX handshakes.
|
||||||
|
/// The handshake itself is the authentication — a spoofed mDNS advert
|
||||||
|
/// with someone else's npub fails the XX exchange and is dropped.
|
||||||
|
pub(super) async fn poll_lan_discovery(&mut self) {
|
||||||
|
let Some(runtime) = self.lan_discovery.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let events = runtime.drain_events().await;
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for event in events {
|
||||||
|
let crate::discovery::lan::LanEvent::Discovered(peer) = event;
|
||||||
|
let Some((transport_id, local_addr)) =
|
||||||
|
self.find_udp_transport_for_remote_addr(peer.addr)
|
||||||
|
else {
|
||||||
|
debug!(
|
||||||
|
addr = %peer.addr,
|
||||||
|
"lan: skip discovered peer with no compatible UDP transport"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let identity = match crate::PeerIdentity::from_npub(&peer.npub) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(err) => {
|
||||||
|
debug!(npub = %peer.npub, error = %err, "lan: skip bad npub");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let peer_node_addr = *identity.node_addr();
|
||||||
|
let remote_addr = crate::transport::TransportAddr::from_string(&peer.addr.to_string());
|
||||||
|
if self.peers.contains_key(&peer_node_addr)
|
||||||
|
|| self.is_connecting_to_peer(&peer_node_addr)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
npub = %identity.short_npub(),
|
||||||
|
addr = %peer.addr,
|
||||||
|
local_addr = %local_addr,
|
||||||
|
"lan: initiating handshake to discovered peer"
|
||||||
|
);
|
||||||
|
if let Err(err) = self
|
||||||
|
.initiate_connection(transport_id, remote_addr, identity)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
debug!(
|
||||||
|
npub = %peer.npub,
|
||||||
|
error = %err,
|
||||||
|
"lan: failed to initiate connection to discovered peer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Poll pending transport connects and initiate handshakes for ready ones.
|
/// Poll pending transport connects and initiate handshakes for ready ones.
|
||||||
///
|
///
|
||||||
/// Called from the tick handler. For each pending connect, queries the
|
/// Called from the tick handler. For each pending connect, queries the
|
||||||
@@ -1029,6 +1146,47 @@ impl Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mDNS / DNS-SD LAN discovery. Independent of Nostr — runs even
|
||||||
|
// when Nostr is disabled, since it gives us sub-second pairing
|
||||||
|
// on the same link without any relay or NAT-traversal roundtrip.
|
||||||
|
if self.config.node.discovery.lan.enabled {
|
||||||
|
// Advertise the port of a non-bootstrap operational UDP transport.
|
||||||
|
// Bootstrap transports must be excluded (they are not the node's
|
||||||
|
// listening data-plane socket), and a stable selector (lowest
|
||||||
|
// TransportId) is used so the advertised port is deterministic
|
||||||
|
// across restarts rather than dependent on HashMap iteration
|
||||||
|
// order. This mirrors find_udp_transport_for_remote_addr.
|
||||||
|
let advertised_udp_port = self
|
||||||
|
.transports
|
||||||
|
.iter()
|
||||||
|
.filter(|(id, h)| {
|
||||||
|
h.transport_type().name == "udp"
|
||||||
|
&& h.is_operational()
|
||||||
|
&& !self.bootstrap_transports.contains(id)
|
||||||
|
})
|
||||||
|
.filter_map(|(id, h)| h.local_addr().map(|addr| (*id, addr.port())))
|
||||||
|
.min_by_key(|(id, _)| id.as_u32())
|
||||||
|
.map(|(_, port)| port)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let scope = self.lan_discovery_scope();
|
||||||
|
match crate::discovery::lan::LanDiscovery::start(
|
||||||
|
&self.identity,
|
||||||
|
scope,
|
||||||
|
advertised_udp_port,
|
||||||
|
self.config.node.discovery.lan.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(runtime) => {
|
||||||
|
self.lan_discovery = Some(runtime);
|
||||||
|
info!("LAN mDNS discovery enabled");
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
debug!(error = %err, "LAN mDNS discovery not started");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to static peers before TUN is active
|
// Connect to static peers before TUN is active
|
||||||
// This allows handshake messages to be sent before we start accepting packets
|
// This allows handshake messages to be sent before we start accepting packets
|
||||||
self.initiate_peer_connections().await;
|
self.initiate_peer_connections().await;
|
||||||
@@ -1314,6 +1472,13 @@ impl Node {
|
|||||||
warn!(error = %e, "Failed to shutdown Nostr overlay discovery");
|
warn!(error = %e, "Failed to shutdown Nostr overlay discovery");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tear down LAN mDNS responder + browser. Best-effort: the
|
||||||
|
// OS will eventually time the advert out via its TTL even if
|
||||||
|
// we don't get a clean unregister out before the daemon exits.
|
||||||
|
if let Some(lan) = self.lan_discovery.take() {
|
||||||
|
lan.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
// Shutdown transports (they're packet producers)
|
// Shutdown transports (they're packet producers)
|
||||||
let transport_ids: Vec<_> = self.transports.keys().cloned().collect();
|
let transport_ids: Vec<_> = self.transports.keys().cloned().collect();
|
||||||
for transport_id in transport_ids {
|
for transport_id in transport_ids {
|
||||||
@@ -1578,15 +1743,31 @@ impl Node {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let tid = match self.find_transport_for_type(&addr.transport) {
|
let tid = if addr.transport == "udp"
|
||||||
Some(id) => id,
|
&& let Ok(remote_socket_addr) = addr.addr.parse::<SocketAddr>()
|
||||||
None => {
|
{
|
||||||
debug!(
|
match self.find_udp_transport_for_remote_addr(remote_socket_addr) {
|
||||||
transport = %addr.transport,
|
Some((id, _)) => id,
|
||||||
addr = %addr.addr,
|
None => {
|
||||||
"No operational transport for address type"
|
debug!(
|
||||||
);
|
transport = %addr.transport,
|
||||||
continue;
|
addr = %addr.addr,
|
||||||
|
"No compatible operational UDP transport for address"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match self.find_transport_for_type(&addr.transport) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
debug!(
|
||||||
|
transport = %addr.transport,
|
||||||
|
addr = %addr.addr,
|
||||||
|
"No operational transport for address type"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
(tid, TransportAddr::from_string(&addr.addr))
|
(tid, TransportAddr::from_string(&addr.addr))
|
||||||
|
|||||||
@@ -463,6 +463,11 @@ pub struct Node {
|
|||||||
|
|
||||||
/// Optional Nostr/STUN overlay discovery coordinator for `udp:nat` peers.
|
/// Optional Nostr/STUN overlay discovery coordinator for `udp:nat` peers.
|
||||||
nostr_discovery: Option<Arc<crate::discovery::nostr::NostrDiscovery>>,
|
nostr_discovery: Option<Arc<crate::discovery::nostr::NostrDiscovery>>,
|
||||||
|
/// mDNS / DNS-SD responder + browser for local-link peer discovery.
|
||||||
|
/// Identity is unverified at this layer — the Noise XX handshake
|
||||||
|
/// initiated against an mDNS-observed endpoint is what proves the
|
||||||
|
/// peer holds the matching private key.
|
||||||
|
lan_discovery: Option<Arc<crate::discovery::lan::LanDiscovery>>,
|
||||||
/// Wall-clock ms when Nostr discovery successfully started, used to
|
/// Wall-clock ms when Nostr discovery successfully started, used to
|
||||||
/// schedule the one-shot startup advert sweep after a settle delay.
|
/// schedule the one-shot startup advert sweep after a settle delay.
|
||||||
/// `None` until discovery comes up; remains `None` if discovery is
|
/// `None` until discovery comes up; remains `None` if discovery is
|
||||||
@@ -682,6 +687,7 @@ impl Node {
|
|||||||
retry_pending: HashMap::new(),
|
retry_pending: HashMap::new(),
|
||||||
nostr_discovery: None,
|
nostr_discovery: None,
|
||||||
nostr_discovery_started_at_ms: None,
|
nostr_discovery_started_at_ms: None,
|
||||||
|
lan_discovery: None,
|
||||||
startup_open_discovery_sweep_done: false,
|
startup_open_discovery_sweep_done: false,
|
||||||
bootstrap_transports: HashSet::new(),
|
bootstrap_transports: HashSet::new(),
|
||||||
bootstrap_transport_npubs: HashMap::new(),
|
bootstrap_transport_npubs: HashMap::new(),
|
||||||
@@ -827,6 +833,7 @@ impl Node {
|
|||||||
retry_pending: HashMap::new(),
|
retry_pending: HashMap::new(),
|
||||||
nostr_discovery: None,
|
nostr_discovery: None,
|
||||||
nostr_discovery_started_at_ms: None,
|
nostr_discovery_started_at_ms: None,
|
||||||
|
lan_discovery: None,
|
||||||
startup_open_discovery_sweep_done: false,
|
startup_open_discovery_sweep_done: false,
|
||||||
bootstrap_transports: HashSet::new(),
|
bootstrap_transports: HashSet::new(),
|
||||||
bootstrap_transport_npubs: HashMap::new(),
|
bootstrap_transport_npubs: HashMap::new(),
|
||||||
|
|||||||
Reference in New Issue
Block a user