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#[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#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum SharedInstance {
215 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}