Skip to main content

prns_config/plan/
node.rs

1use std::path::PathBuf;
2use std::time::Duration;
3
4use prns_core::identity::IdentityHash;
5use prns_core::interface_discovery::{
6    AutoConnectPolicy, AutoConnectRoutingPolicy, DiscoverySourcePolicy, InterfaceDiscoveryPolicy,
7    DEFAULT_STAMP_COST,
8};
9use prns_core::interfaces::{BitrateBps, InterfaceGravity};
10
11use super::error::{GlobalPlanError, PlanError, PlanningError};
12use super::interface::{
13    global_announce_rate, global_common_policy, plan_interface, PlanErrorKind, PlannedInterface,
14};
15use super::reference_globals::{
16    decode_hex, global_bool, global_i64, global_string, global_u16, global_u64,
17};
18use super::rnode_multi;
19use crate::reference::keys::{
20    global as global_key, interface as interface_key, logging as logging_key,
21    section as section_key,
22};
23use crate::reference::{ReferenceConfig, ReferenceConfigParams, ReferenceRemoteManagement};
24use crate::{ConfigDiagnostic, ConfigDiagnosticCode, ConfigErrors, ConfigReport, SourceLocations};
25
26/// The complete, host-agnostic description of a node to stand up, projected from a stock RNS config.
27#[derive(Debug, Clone, PartialEq)]
28pub struct DaemonPlan {
29    pub transport: TransportPlan,
30    pub shared_instance: SharedInstance,
31    pub remote_management: RemoteManagementPlan,
32    pub probe_responder: ProbeResponderPlan,
33    pub blackhole_exchange: BlackholeExchangePlan,
34    pub protocol: ProtocolPlan,
35    pub logging: LoggingPlan,
36    pub panic_on_interface_error: bool,
37    pub network_identity_path: Option<PathBuf>,
38    pub discovery: InterfaceDiscoveryPolicy,
39    pub interfaces: Vec<PlannedInterface>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct BlackholeExchangePlan {
44    publication: BlackholePublicationPlan,
45    sources: BlackholeSources,
46    update_interval: BlackholeUpdateInterval,
47}
48
49impl BlackholeExchangePlan {
50    pub const fn publication(&self) -> BlackholePublicationPlan {
51        self.publication
52    }
53
54    pub fn sources(&self) -> &[IdentityHash] {
55        self.sources.as_slice()
56    }
57
58    pub const fn update_interval(&self) -> BlackholeUpdateInterval {
59        self.update_interval
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum BlackholePublicationPlan {
65    Disabled,
66    Enabled,
67}
68
69impl BlackholePublicationPlan {
70    pub const fn is_enabled(self) -> bool {
71        matches!(self, Self::Enabled)
72    }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct BlackholeSources(Vec<IdentityHash>);
77
78impl BlackholeSources {
79    fn from_identities(identities: &[IdentityHash]) -> Self {
80        let mut sources = Vec::new();
81        for identity in identities {
82            if !sources.contains(identity) {
83                sources.push(*identity);
84            }
85        }
86        Self(sources)
87    }
88
89    pub fn as_slice(&self) -> &[IdentityHash] {
90        &self.0
91    }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct BlackholeUpdateInterval(Duration);
96
97impl BlackholeUpdateInterval {
98    pub const DEFAULT: Self = Self(Duration::from_secs(60 * 60));
99    pub const MINIMUM: Self = Self(Duration::from_secs(2 * 60));
100
101    fn from_configured_minutes(minutes: f64) -> Option<Self> {
102        if !minutes.is_finite() {
103            return None;
104        }
105        Duration::try_from_secs_f64(minutes.max(2.0) * 60.0)
106            .ok()
107            .map(Self)
108    }
109
110    pub const fn duration(self) -> Duration {
111        self.0
112    }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum ProbeResponderPlan {
117    Disabled,
118    Enabled,
119}
120
121impl ProbeResponderPlan {
122    pub const fn is_enabled(self) -> bool {
123        matches!(self, Self::Enabled)
124    }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum RemoteManagementPlan {
129    Disabled,
130    Enabled(RemoteManagementAccessControlList),
131}
132
133impl RemoteManagementPlan {
134    pub fn allowed(&self) -> Option<&[IdentityHash]> {
135        match self {
136            Self::Disabled => None,
137            Self::Enabled(acl) => Some(acl.as_slice()),
138        }
139    }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct RemoteManagementAccessControlList(Vec<IdentityHash>);
144
145impl RemoteManagementAccessControlList {
146    pub fn from_identities(identities: Vec<IdentityHash>) -> Self {
147        Self(identities)
148    }
149
150    pub fn as_slice(&self) -> &[IdentityHash] {
151        &self.0
152    }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum TransportPlan {
157    Routing,
158    Leaf(TransportIdentityPolicy),
159}
160
161impl TransportPlan {
162    pub const fn routing_enabled(self) -> bool {
163        matches!(self, Self::Routing)
164    }
165
166    pub const fn identity_policy(self) -> TransportIdentityPolicy {
167        match self {
168            Self::Routing => TransportIdentityPolicy::Persistent,
169            Self::Leaf(identity) => identity,
170        }
171    }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum TransportIdentityPolicy {
176    Persistent,
177    Ephemeral,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct ProtocolPlan {
182    pub randomize_local_hop_count: bool,
183    pub link_mtu_discovery: bool,
184    pub use_implicit_proof: bool,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub struct LoggingPlan {
189    pub level: LogLevel,
190    pub timestamps: bool,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
194pub struct LogLevel(u8);
195
196impl LogLevel {
197    pub const DEFAULT: Self = Self(4);
198
199    pub const fn new(level: u8) -> Option<Self> {
200        if level <= 7 {
201            Some(Self(level))
202        } else {
203            None
204        }
205    }
206
207    pub const fn get(self) -> u8 {
208        self.0
209    }
210}
211
212/// Whether the node hosts a shared instance, and on which ports if so.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum SharedInstance {
215    /// The local data bus and its control RPC are served.
216    Enabled {
217        name: String,
218        transport: SharedInstanceTransport,
219        instance_port: u16,
220        control_port: u16,
221        rpc_key: Option<Vec<u8>>,
222        forced_bitrate: Option<BitrateBps>,
223    },
224    Disabled,
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum SharedInstanceTransport {
229    Tcp,
230    Unix,
231}
232
233pub fn parse_and_plan(input: &str) -> Result<ConfigReport<DaemonPlan>, ConfigErrors> {
234    parse_and_plan_named("config", input)
235}
236
237pub fn parse_and_plan_named(
238    source: impl Into<String>,
239    input: &str,
240) -> Result<ConfigReport<DaemonPlan>, ConfigErrors> {
241    let report = crate::reference::parse_named(source, input)?;
242    let ConfigReport {
243        value,
244        warnings,
245        source,
246        locations,
247    } = report;
248    match build_plan(&value) {
249        Ok(value) => Ok(ConfigReport {
250            value,
251            warnings,
252            source,
253            locations,
254        }),
255        Err(errors) => {
256            let mut diagnostics = errors
257                .iter()
258                .map(|error| planning_diagnostic(&source, &locations, error))
259                .collect::<Vec<_>>();
260            diagnostics.extend(warnings);
261            Err(ConfigErrors::new(diagnostics))
262        }
263    }
264}
265
266pub fn plan_reference_config(config: &ReferenceConfig) -> Result<DaemonPlan, ConfigErrors> {
267    build_plan(config).map_err(|errors| {
268        let locations = SourceLocations::default();
269        ConfigErrors::new(
270            errors
271                .iter()
272                .map(|error| planning_diagnostic("typed config", &locations, error))
273                .collect(),
274        )
275    })
276}
277
278pub(super) fn build_plan(config: &ReferenceConfig) -> Result<DaemonPlan, Vec<PlanningError>> {
279    let mut interfaces = Vec::new();
280    let mut errors = Vec::new();
281    let transport = transport_plan(config);
282    let blackhole_exchange =
283        blackhole_exchange(config).map_err(|error| vec![PlanningError::Global(error)])?;
284    let common =
285        global_common_policy(config).map_err(|error| vec![PlanningError::Global(error)])?;
286    let announce_rate =
287        global_announce_rate(config).map_err(|error| vec![PlanningError::Global(error)])?;
288    let default_gravity = InterfaceGravity::new(
289        global_i64(&config.globals, global_key::DEFAULT_GRAVITY).unwrap_or(0),
290    );
291    for interface in &config.interfaces {
292        if matches!(interface.params, ReferenceConfigParams::RnodeMulti { .. }) {
293            match rnode_multi::plan(
294                interface,
295                common,
296                announce_rate,
297                default_gravity,
298                transport.routing_enabled(),
299            ) {
300                Ok(planned) => interfaces.extend(planned),
301                Err(failure) => errors.push(PlanningError::Interface(PlanError {
302                    interface_name: interface.name.clone(),
303                    interface_type: interface.type_name.clone(),
304                    subinterface_name: failure.subinterface_name,
305                    kind: failure.kind,
306                })),
307            }
308            continue;
309        }
310        match plan_interface(
311            interface,
312            common,
313            announce_rate,
314            default_gravity,
315            transport.routing_enabled(),
316        ) {
317            Ok(planned) => interfaces.push(planned),
318            Err(kind) => errors.push(PlanningError::Interface(PlanError {
319                interface_name: interface.name.clone(),
320                interface_type: interface.type_name.clone(),
321                subinterface_name: None,
322                kind,
323            })),
324        }
325    }
326    if !errors.is_empty() {
327        return Err(errors);
328    }
329    Ok(DaemonPlan {
330        transport,
331        shared_instance: shared_instance(config),
332        remote_management: remote_management(config),
333        probe_responder: if global_bool(&config.globals, global_key::RESPOND_TO_PROBES, false) {
334            ProbeResponderPlan::Enabled
335        } else {
336            ProbeResponderPlan::Disabled
337        },
338        blackhole_exchange,
339        protocol: ProtocolPlan {
340            randomize_local_hop_count: global_bool(
341                &config.globals,
342                global_key::LOCAL_HOPS_DELTA,
343                false,
344            ),
345            link_mtu_discovery: global_bool(&config.globals, global_key::LINK_MTU_DISCOVERY, true),
346            use_implicit_proof: global_bool(&config.globals, global_key::USE_IMPLICIT_PROOF, true),
347        },
348        logging: logging_plan(config),
349        panic_on_interface_error: global_bool(
350            &config.globals,
351            global_key::PANIC_ON_INTERFACE_ERROR,
352            false,
353        ),
354        network_identity_path: config.network_identity_path.as_deref().map(PathBuf::from),
355        discovery: discovery_policy(config),
356        interfaces,
357    })
358}
359
360fn blackhole_exchange(config: &ReferenceConfig) -> Result<BlackholeExchangePlan, GlobalPlanError> {
361    let update_interval = match config.blackhole_exchange.update_interval_minutes {
362        Some(minutes) => {
363            BlackholeUpdateInterval::from_configured_minutes(minutes).ok_or(GlobalPlanError {
364                key: global_key::BLACKHOLE_UPDATE_INTERVAL,
365            })?
366        }
367        None => BlackholeUpdateInterval::DEFAULT,
368    };
369    Ok(BlackholeExchangePlan {
370        publication: if config.blackhole_exchange.publish == Some(true) {
371            BlackholePublicationPlan::Enabled
372        } else {
373            BlackholePublicationPlan::Disabled
374        },
375        sources: BlackholeSources::from_identities(&config.blackhole_exchange.sources),
376        update_interval,
377    })
378}
379
380fn remote_management(config: &ReferenceConfig) -> RemoteManagementPlan {
381    match &config.remote_management {
382        ReferenceRemoteManagement::Disabled => RemoteManagementPlan::Disabled,
383        ReferenceRemoteManagement::Enabled { allowed } => RemoteManagementPlan::Enabled(
384            RemoteManagementAccessControlList::from_identities(allowed.clone()),
385        ),
386    }
387}
388
389pub(super) fn planning_diagnostic(
390    source: &str,
391    locations: &SourceLocations,
392    error: &PlanningError,
393) -> ConfigDiagnostic {
394    match error {
395        PlanningError::Global(error) => global_planning_diagnostic(source, locations, *error),
396        PlanningError::Interface(error) => interface_planning_diagnostic(source, locations, error),
397    }
398}
399
400fn global_planning_diagnostic(
401    source: &str,
402    locations: &SourceLocations,
403    error: GlobalPlanError,
404) -> ConfigDiagnostic {
405    ConfigDiagnostic::new(
406        ConfigDiagnosticCode::InvalidValue,
407        source,
408        locations
409            .line([section_key::RETICULUM, error.key])
410            .unwrap_or(1),
411        format!("[reticulum] > {}", error.key),
412        None,
413        format!(
414            "global setting {:?} cannot be represented by this build",
415            error.key
416        ),
417        Some("a non-negative value within the documented range".to_string()),
418        format!(
419            "replace `{}` under [reticulum] with a smaller value",
420            error.key
421        ),
422    )
423}
424
425fn interface_planning_diagnostic(
426    source: &str,
427    locations: &SourceLocations,
428    error: &PlanError,
429) -> ConfigDiagnostic {
430    let display_section = error.subinterface_name.as_ref().map_or_else(
431        || format!("[interfaces] > [[{}]]", error.interface_name),
432        |name| format!("[interfaces] > [[{}]] > [[[{name}]]]", error.interface_name),
433    );
434    let correction_section = error.subinterface_name.as_ref().map_or_else(
435        || format!("[[{}]]", error.interface_name),
436        |name| format!("[[[{name}]]]"),
437    );
438    let configured_subject = if error.subinterface_name.is_some() {
439        "enabled RNodeMulti subinterface"
440    } else {
441        "enabled interface"
442    };
443    let (code, key, message, accepted, correction) = match error.kind {
444        PlanErrorKind::UnsupportedKind => (
445            ConfigDiagnosticCode::UnsupportedInterface,
446            interface_key::TYPE,
447            format!(
448                "interface type {:?} is not available in this build",
449                error.interface_type
450            ),
451            "an interface type supported by this build".to_string(),
452            format!(
453                "set `{}` = No for [[{}]]",
454                interface_key::ENABLED,
455                error.interface_name
456            ),
457        ),
458        PlanErrorKind::MissingRequiredField { key } => (
459            ConfigDiagnosticCode::MissingRequiredKey,
460            key,
461            format!("{configured_subject} is missing required setting {key:?}"),
462            format!("a valid {key} value"),
463            format!("add `{key} = value` under {correction_section}"),
464        ),
465        PlanErrorKind::InvalidSetting { key } => (
466            ConfigDiagnosticCode::InvalidValue,
467            key,
468            format!("setting {key:?} cannot be represented by this build"),
469            format!("a valid, representable {key} value"),
470            format!("replace `{key}` under {correction_section}"),
471        ),
472    };
473    let mut path = vec![section_key::INTERFACES, error.interface_name.as_str()];
474    if let Some(subinterface) = &error.subinterface_name {
475        path.push(subinterface);
476    }
477    let section_path = path.clone();
478    path.push(key);
479    let line = locations
480        .line(path.iter().copied())
481        .or_else(|| locations.line(section_path.iter().copied()));
482    ConfigDiagnostic::new(
483        code,
484        source,
485        line.unwrap_or(1),
486        format!("{display_section} > {key}"),
487        None,
488        message,
489        Some(accepted),
490        correction,
491    )
492}
493
494fn discovery_policy(config: &ReferenceConfig) -> InterfaceDiscoveryPolicy {
495    if config.discovery.discover_interfaces != Some(true) {
496        return InterfaceDiscoveryPolicy::Disabled;
497    }
498    InterfaceDiscoveryPolicy::enabled(
499        config
500            .discovery
501            .required_stamp_cost
502            .unwrap_or(DEFAULT_STAMP_COST),
503        DiscoverySourcePolicy::from_sources(config.discovery.interface_sources.clone()),
504        AutoConnectPolicy::from_maximum(config.discovery.auto_connect_limit.unwrap_or(0)),
505        AutoConnectRoutingPolicy {
506            gravity: InterfaceGravity::new(config.discovery.auto_connect_gravity.unwrap_or(0)),
507            announces_to_internal: config
508                .discovery
509                .auto_connect_announces_to_internal
510                .unwrap_or(false),
511        },
512    )
513}
514
515fn shared_instance(config: &ReferenceConfig) -> SharedInstance {
516    if global_bool(&config.globals, global_key::SHARE_INSTANCE, true) {
517        SharedInstance::Enabled {
518            name: global_string(&config.globals, global_key::INSTANCE_NAME)
519                .unwrap_or_else(|| "default".to_string()),
520            transport: match global_string(&config.globals, global_key::SHARED_INSTANCE_TYPE)
521                .map(|value| value.trim().to_ascii_lowercase())
522                .as_deref()
523            {
524                Some("tcp") => SharedInstanceTransport::Tcp,
525                Some("unix") | None => SharedInstanceTransport::Unix,
526                Some(_) => SharedInstanceTransport::Unix,
527            },
528            instance_port: global_u16(&config.globals, global_key::SHARED_INSTANCE_PORT)
529                .unwrap_or(37_428),
530            control_port: global_u16(&config.globals, global_key::INSTANCE_CONTROL_PORT)
531                .unwrap_or(37_429),
532            rpc_key: global_string(&config.globals, global_key::RPC_KEY)
533                .and_then(|value| decode_hex(&value)),
534            forced_bitrate: global_u64(&config.globals, global_key::FORCE_SHARED_INSTANCE_BITRATE)
535                .and_then(BitrateBps::new),
536        }
537    } else {
538        SharedInstance::Disabled
539    }
540}
541
542fn transport_plan(config: &ReferenceConfig) -> TransportPlan {
543    let routing = global_bool(&config.globals, global_key::ENABLE_TRANSPORT, false);
544    if routing {
545        TransportPlan::Routing
546    } else {
547        TransportPlan::Leaf(
548            if global_bool(
549                &config.globals,
550                global_key::STATIC_TRANSPORT_IDENTITY,
551                false,
552            ) {
553                TransportIdentityPolicy::Persistent
554            } else {
555                TransportIdentityPolicy::Ephemeral
556            },
557        )
558    }
559}
560
561fn logging_plan(config: &ReferenceConfig) -> LoggingPlan {
562    let logging = config.other_sections.get(section_key::LOGGING);
563    LoggingPlan {
564        level: logging
565            .and_then(|section| global_u64(section, logging_key::LEVEL))
566            .and_then(|level| u8::try_from(level).ok())
567            .and_then(LogLevel::new)
568            .unwrap_or(LogLevel::DEFAULT),
569        timestamps: logging
570            .map(|section| global_bool(section, logging_key::TIMESTAMPS, true))
571            .unwrap_or(true),
572    }
573}