fips_core/discovery/
local.rs1use 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#[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
39pub const DEFAULT_LOCAL_RENDEZVOUS_ADDR: SocketAddrV4 =
41 SocketAddrV4::new(Ipv4Addr::LOCALHOST, 21_211);
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49#[non_exhaustive]
50pub struct LocalInstanceDiscoveryConfig {
51 #[serde(default)]
54 pub enabled: bool,
55 #[serde(default = "default_rendezvous_addr")]
58 pub rendezvous_addr: SocketAddrV4,
59 #[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#[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#[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 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#[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 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 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
220pub 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
232pub 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;