Skip to main content

fips_core/discovery/
local.rs

1//! Same-host FIPS rendezvous and authenticated capability directory.
2//!
3//! One process holds a well-known loopback UDP socket as the sticky local
4//! rendezvous anchor. A tiny nonce exchange returns its untrusted public-key
5//! hint; the client then uses the ordinary Noise IK discovered-peer path to
6//! prove ownership. Capabilities are exchanged only through encrypted FSP and
7//! kept in memory; authenticated announcements refresh short leases so forced
8//! process death expires without filesystem state. Loopback grants no trust
9//! exception.
10
11use std::collections::BTreeMap;
12use std::io;
13use std::net::{Ipv4Addr, SocketAddrV4};
14use std::path::PathBuf;
15use std::sync::{Arc, RwLock};
16
17use serde::{Deserialize, Serialize};
18use thiserror::Error;
19
20/// Legacy filesystem-discovery error retained for endpoint API compatibility.
21/// In-memory loopback capability snapshots are infallible and return none of
22/// these variants.
23#[derive(Debug, Error)]
24pub enum LocalInstanceRegistryError {
25    #[error("same-host FIPS discovery disabled")]
26    Disabled,
27    #[error("could not resolve FIPS local instance registry directory")]
28    NoRegistryDir,
29    #[error("local instance registry IO failed at {path}: {source}")]
30    Io {
31        path: PathBuf,
32        #[source]
33        source: io::Error,
34    },
35    #[error("local instance registry serialization failed: {0}")]
36    Json(#[from] serde_json::Error),
37}
38
39/// Host-global loopback UDP address used by the local rendezvous anchor.
40pub const DEFAULT_LOCAL_RENDEZVOUS_ADDR: SocketAddrV4 =
41    SocketAddrV4::new(Ipv4Addr::LOCALHOST, 21_211);
42
43/// Runtime configuration for same-host rendezvous over loopback UDP.
44///
45/// `LocalInstanceDiscoveryConfig` is retained as the public configuration
46/// name for compatibility. It no longer describes filesystem discovery.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49#[non_exhaustive]
50pub struct LocalInstanceDiscoveryConfig {
51    /// Participate in same-host rendezvous. Disabled generic nodes neither
52    /// bind the fixed anchor address nor connect to it.
53    #[serde(default)]
54    pub enabled: bool,
55    /// Fixed IPv4 loopback address. This is configurable so isolated tests can
56    /// use distinct ports; production callers should keep the default.
57    #[serde(default = "default_rendezvous_addr")]
58    pub rendezvous_addr: SocketAddrV4,
59    /// Delay between attempts to contact or bind the sticky anchor.
60    #[serde(default = "default_retry_interval_ms")]
61    pub retry_interval_ms: u64,
62}
63
64impl Default for LocalInstanceDiscoveryConfig {
65    fn default() -> Self {
66        Self {
67            enabled: false,
68            rendezvous_addr: DEFAULT_LOCAL_RENDEZVOUS_ADDR,
69            retry_interval_ms: default_retry_interval_ms(),
70        }
71    }
72}
73
74impl LocalInstanceDiscoveryConfig {
75    pub(crate) fn has_valid_rendezvous_addr(&self) -> bool {
76        self.rendezvous_addr.ip().is_loopback() && self.rendezvous_addr.port() != 0
77    }
78}
79
80const fn default_rendezvous_addr() -> SocketAddrV4 {
81    DEFAULT_LOCAL_RENDEZVOUS_ADDR
82}
83
84const fn default_retry_interval_ms() -> u64 {
85    1_000
86}
87
88pub(crate) fn lan_discovery_scope(config: &crate::Config) -> Option<String> {
89    normalized_scope(config.node.discovery.lan.scope.as_deref()).or_else(|| {
90        let app = config.node.discovery.nostr.app.trim();
91        normalized_scope(Some(app.strip_prefix("fips-overlay-v1:").unwrap_or(app)))
92    })
93}
94
95fn normalized_scope(scope: Option<&str>) -> Option<String> {
96    scope
97        .map(str::trim)
98        .filter(|scope| !scope.is_empty())
99        .map(str::to_string)
100}
101
102/// One reusable capability advertised by an authenticated same-host peer.
103///
104/// Priority ranks providers of this capability only. It has no bearing on
105/// which process owns the loopback rendezvous anchor or on outbound links.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct LocalInstanceCapability {
109    pub name: String,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub fsp_port: Option<u16>,
112    #[serde(default, skip_serializing_if = "is_zero_priority")]
113    pub priority: i16,
114}
115
116impl LocalInstanceCapability {
117    pub fn service(name: impl Into<String>, fsp_port: u16) -> Self {
118        Self {
119            name: name.into(),
120            fsp_port: Some(fsp_port),
121            priority: 0,
122        }
123    }
124
125    pub fn role(name: impl Into<String>) -> Self {
126        Self {
127            name: name.into(),
128            fsp_port: None,
129            priority: 0,
130        }
131    }
132
133    pub fn with_priority(mut self, priority: i16) -> Self {
134        self.priority = priority;
135        self
136    }
137}
138
139fn is_zero_priority(priority: &i16) -> bool {
140    *priority == 0
141}
142
143/// Direct in-memory provider record learned over an authenticated local FIPS
144/// link. A changed startup epoch denotes a restarted provider even when its
145/// long-lived identity is unchanged.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct LocalInstanceAdvertisement {
148    pub npub: String,
149    pub startup_epoch: [u8; 8],
150    pub capabilities: Vec<LocalInstanceCapability>,
151}
152
153impl LocalInstanceAdvertisement {
154    /// Return this provider's preferred advert for one capability name.
155    pub fn capability(&self, name: &str) -> Option<&LocalInstanceCapability> {
156        self.capabilities
157            .iter()
158            .filter(|capability| capability.name == name)
159            .min_by(|left, right| {
160                right
161                    .priority
162                    .cmp(&left.priority)
163                    .then_with(|| left.fsp_port.cmp(&right.fsp_port))
164            })
165    }
166}
167
168/// Cloneable snapshot handle shared by the node and its endpoint facade.
169///
170/// The directory keeps at most one process incarnation per authenticated
171/// npub. Removal is epoch-checked so a delayed disconnect from an old process
172/// cannot erase the record installed after that process restarted.
173#[derive(Debug, Clone, Default)]
174pub(crate) struct LocalCapabilityDirectory {
175    providers: Arc<RwLock<BTreeMap<String, LocalInstanceAdvertisement>>>,
176}
177
178impl LocalCapabilityDirectory {
179    pub(crate) fn new() -> Self {
180        Self::default()
181    }
182
183    pub(crate) fn snapshot(&self) -> Vec<LocalInstanceAdvertisement> {
184        self.read().values().cloned().collect()
185    }
186
187    /// Atomically replace the complete authenticated provider snapshot.
188    /// When input repeats an npub, its last record wins.
189    pub(crate) fn replace(&self, providers: impl IntoIterator<Item = LocalInstanceAdvertisement>) {
190        let mut replacement = BTreeMap::new();
191        for provider in providers {
192            replacement.insert(provider.npub.clone(), provider);
193        }
194        *self.write() = replacement;
195    }
196
197    /// Insert or replace one authenticated process incarnation.
198    pub(crate) fn upsert(
199        &self,
200        provider: LocalInstanceAdvertisement,
201    ) -> Option<LocalInstanceAdvertisement> {
202        self.write().insert(provider.npub.clone(), provider)
203    }
204
205    fn read(&self) -> std::sync::RwLockReadGuard<'_, BTreeMap<String, LocalInstanceAdvertisement>> {
206        self.providers
207            .read()
208            .unwrap_or_else(|poisoned| poisoned.into_inner())
209    }
210
211    fn write(
212        &self,
213    ) -> std::sync::RwLockWriteGuard<'_, BTreeMap<String, LocalInstanceAdvertisement>> {
214        self.providers
215            .write()
216            .unwrap_or_else(|poisoned| poisoned.into_inner())
217    }
218}
219
220/// Choose one provider deterministically. Higher capability priority wins;
221/// ties are ordered by authenticated identity and then process epoch.
222pub fn select_capability_provider<'a>(
223    adverts: &'a [LocalInstanceAdvertisement],
224    capability_name: &str,
225) -> Option<&'a LocalInstanceAdvertisement> {
226    adverts
227        .iter()
228        .filter(|advert| advert.capability(capability_name).is_some())
229        .min_by(|left, right| capability_provider_order(left, right, capability_name))
230}
231
232/// Rank every provider so a consumer can try the next one after a service
233/// failure. Highest capability priority sorts first.
234pub fn rank_capability_providers<'a>(
235    adverts: &'a [LocalInstanceAdvertisement],
236    capability_name: &str,
237) -> Vec<&'a LocalInstanceAdvertisement> {
238    let mut providers = adverts
239        .iter()
240        .filter(|advert| advert.capability(capability_name).is_some())
241        .collect::<Vec<_>>();
242    providers.sort_by(|left, right| capability_provider_order(left, right, capability_name));
243    providers
244}
245
246fn capability_provider_order(
247    left: &LocalInstanceAdvertisement,
248    right: &LocalInstanceAdvertisement,
249    capability_name: &str,
250) -> std::cmp::Ordering {
251    right
252        .capability(capability_name)
253        .map(|capability| capability.priority)
254        .cmp(
255            &left
256                .capability(capability_name)
257                .map(|capability| capability.priority),
258        )
259        .then_with(|| left.npub.cmp(&right.npub))
260        .then_with(|| left.startup_epoch.cmp(&right.startup_epoch))
261}
262
263#[cfg(test)]
264#[path = "local_tests.rs"]
265mod tests;