Skip to main content

rns_server/
config.rs

1use rns_ctl::state::{
2    LaunchProcessSnapshot, ProcessControlCommand, ServerConfigApplyPlan, ServerConfigChange,
3    ServerConfigFieldSchema, ServerConfigMutationMode, ServerConfigMutationResult,
4    ServerConfigSchemaSnapshot, ServerConfigSnapshot, ServerConfigValidationSnapshot,
5    ServerHttpConfigSnapshot, SharedState,
6};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use std::sync::mpsc;
10
11use crate::args::Args;
12use crate::supervisor::{
13    ProcessCommand, ProcessReadiness, ProcessSpec, ReadinessTarget, RnsdDrainConfig, Role,
14    SupervisorConfig,
15};
16use rns_net::RpcAddr;
17
18#[derive(Debug, Clone)]
19pub struct ServerConfig {
20    pub config_path: Option<PathBuf>,
21    pub resolved_config_dir: PathBuf,
22    pub server_config_file_path: PathBuf,
23    pub server_config_file_present: bool,
24    pub file_config: ServerConfigFile,
25    pub stats_db_path: PathBuf,
26    pub rnsd_bin: PathBuf,
27    pub sentineld_bin: PathBuf,
28    pub statsd_bin: PathBuf,
29    pub http: HttpConfig,
30    pub rnsd_rpc_addr: std::net::SocketAddr,
31}
32
33#[derive(Debug, Clone)]
34pub struct HttpConfig {
35    pub enabled: bool,
36    pub host: String,
37    pub port: u16,
38    pub auth_token: Option<String>,
39    pub disable_auth: bool,
40    pub daemon_mode: bool,
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct ServerConfigFile {
46    #[serde(default)]
47    pub stats_db_path: Option<String>,
48    #[serde(default)]
49    pub rnsd_bin: Option<String>,
50    #[serde(default)]
51    pub sentineld_bin: Option<String>,
52    #[serde(default)]
53    pub statsd_bin: Option<String>,
54    #[serde(default)]
55    pub http: ServerHttpConfigFile,
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct ServerHttpConfigFile {
61    #[serde(default)]
62    pub enabled: Option<bool>,
63    #[serde(default)]
64    pub host: Option<String>,
65    #[serde(default)]
66    pub port: Option<u16>,
67    #[serde(default)]
68    pub auth_token: Option<String>,
69    #[serde(default)]
70    pub disable_auth: Option<bool>,
71}
72
73impl ServerConfig {
74    pub fn from_args(args: &Args) -> Self {
75        let config_path = args.config_path().map(PathBuf::from);
76        let resolved_config_dir =
77            rns_net::storage::resolve_config_dir(args.config_path().map(Path::new));
78        let server_config_file_path = resolved_config_dir.join("rns-server.json");
79        let (file_cfg, file_present) = Self::load_config_file(&server_config_file_path)
80            .unwrap_or_else(|err| {
81                log::warn!(
82                    "failed to load server config file {}: {}",
83                    server_config_file_path.display(),
84                    err
85                );
86                (ServerConfigFile::default(), false)
87            });
88        Self::build(
89            config_path,
90            resolved_config_dir,
91            server_config_file_path,
92            file_present,
93            &file_cfg,
94            Some(args),
95        )
96    }
97
98    pub fn validate_json_with_current_context(
99        &self,
100        body: &[u8],
101    ) -> Result<ServerConfigValidationSnapshot, String> {
102        let candidate = Self::parse_config_json(body)?;
103        let mut warnings = Self::validate_file_config(&candidate)?;
104        let validated = self.with_file_config(&candidate, self.server_config_file_present);
105        warnings.push(format!(
106            "Validation used config dir {} and did not write any files.",
107            self.resolved_config_dir.display()
108        ));
109
110        Ok(ServerConfigValidationSnapshot {
111            valid: true,
112            config: validated.snapshot(),
113            warnings,
114        })
115    }
116
117    pub fn mutate_json_with_current_context(
118        &self,
119        mode: ServerConfigMutationMode,
120        body: &[u8],
121        control_tx: Option<mpsc::Sender<ProcessControlCommand>>,
122    ) -> Result<ServerConfigMutationResult, String> {
123        let candidate = Self::parse_config_json(body)?;
124        let warnings = Self::validate_file_config(&candidate)?;
125        let next = self.with_file_config(&candidate, true);
126        let apply_plan = self.apply_plan(&next);
127
128        std::fs::create_dir_all(&self.resolved_config_dir).map_err(|err| {
129            format!(
130                "failed to create config dir {}: {}",
131                self.resolved_config_dir.display(),
132                err
133            )
134        })?;
135        let serialized = serde_json::to_vec_pretty(&candidate)
136            .map_err(|err| format!("failed to serialize server config JSON: {}", err))?;
137        std::fs::write(&self.server_config_file_path, serialized).map_err(|err| {
138            format!(
139                "failed to write {}: {}",
140                self.server_config_file_path.display(),
141                err
142            )
143        })?;
144
145        if matches!(mode, ServerConfigMutationMode::Apply) {
146            if let Some(tx) = control_tx {
147                for process in &apply_plan.processes_to_restart {
148                    tx.send(ProcessControlCommand::Restart(process.clone()))
149                        .map_err(|_| {
150                            format!("failed to queue restart for process '{}'", process)
151                        })?;
152                }
153            }
154        }
155
156        Ok(ServerConfigMutationResult {
157            action: match mode {
158                ServerConfigMutationMode::Save => "save".into(),
159                ServerConfigMutationMode::Apply => "apply".into(),
160            },
161            config: next.snapshot(),
162            apply_plan,
163            warnings,
164        })
165    }
166
167    pub fn supervisor_config(
168        &self,
169        shared_state: Option<SharedState>,
170        control_rx: Option<mpsc::Receiver<ProcessControlCommand>>,
171    ) -> SupervisorConfig {
172        SupervisorConfig {
173            specs: self.process_specs(),
174            shared_state,
175            control_rx,
176            readiness: self.readiness_checks(),
177            log_dir: Some(self.resolved_config_dir.join("logs")),
178            rnsd_drain: self.rnsd_drain_config(),
179        }
180    }
181
182    pub fn ensure_runtime_bootstrap(&self) -> Result<(), String> {
183        let paths = rns_net::storage::ensure_storage_dirs(&self.resolved_config_dir)
184            .map_err(|err| format!("failed to create runtime storage dirs: {}", err))?;
185        rns_net::storage::load_or_create_identity(&paths.identities)
186            .map_err(|err| format!("failed to initialize node identity: {}", err))?;
187        Ok(())
188    }
189
190    pub fn process_specs(&self) -> Vec<ProcessSpec> {
191        #[cfg(not(feature = "rns-hooks-wasm"))]
192        {
193            return vec![ProcessSpec {
194                role: Role::Rnsd,
195                command: self.command_for_override(&self.rnsd_bin),
196                args: self.rnsd_args(),
197            }];
198        }
199
200        #[cfg(feature = "rns-hooks-wasm")]
201        {
202            let mut specs = vec![ProcessSpec {
203                role: Role::Rnsd,
204                command: self.command_for_override(&self.rnsd_bin),
205                args: self.rnsd_args(),
206            }];
207
208            #[cfg(feature = "rns-hooks-wasm")]
209            {
210                specs.push(ProcessSpec {
211                    role: Role::Sentineld,
212                    command: self.command_for_override(&self.sentineld_bin),
213                    args: self.sentineld_args(),
214                });
215                specs.push(ProcessSpec {
216                    role: Role::Statsd,
217                    command: self.command_for_override(&self.statsd_bin),
218                    args: self.statsd_args(),
219                });
220            }
221
222            specs
223        }
224    }
225
226    pub fn snapshot(&self) -> ServerConfigSnapshot {
227        ServerConfigSnapshot {
228            config_path: self
229                .config_path
230                .as_ref()
231                .map(|path| path.display().to_string()),
232            resolved_config_dir: self.resolved_config_dir.display().to_string(),
233            server_config_file_path: self.server_config_file_path.display().to_string(),
234            server_config_file_present: self.server_config_file_present,
235            server_config_file_json: self.editable_file_json(),
236            stats_db_path: self.stats_db_path.display().to_string(),
237            rnsd_bin: self.binary_mode_label(&self.rnsd_bin, "rnsd"),
238            sentineld_bin: self.binary_mode_label(&self.sentineld_bin, "rns-sentineld"),
239            statsd_bin: self.binary_mode_label(&self.statsd_bin, "rns-statsd"),
240            http: ServerHttpConfigSnapshot {
241                enabled: self.http.enabled,
242                host: self.http.host.clone(),
243                port: self.http.port,
244                auth_mode: if self.http.disable_auth {
245                    "disabled".into()
246                } else {
247                    "bearer-token".into()
248                },
249                token_configured: self.http.auth_token.is_some(),
250                daemon_mode: self.http.daemon_mode,
251            },
252            launch_plan: self
253                .process_specs()
254                .into_iter()
255                .map(|spec| LaunchProcessSnapshot {
256                    name: spec.role.display_name().to_string(),
257                    bin: spec.command.display(spec.role),
258                    args: spec.args.clone(),
259                    command_line: spec.command_line(),
260                })
261                .collect(),
262        }
263    }
264
265    pub fn schema_snapshot(&self) -> ServerConfigSchemaSnapshot {
266        ServerConfigSchemaSnapshot {
267            format: "rns-server.json".into(),
268            example_config_json: Self::example_config_json(),
269            notes: vec![
270                format!(
271                    "The config file is loaded from {}.",
272                    self.server_config_file_path.display()
273                ),
274                "Only fields present in rns-server.json are persisted; CLI flags still override them at startup.".into(),
275                "By default, child roles self-spawn from the running rns-server binary via /proc/self/exe with current_exe() fallback. Native hook builds manage rnsd; WASM sidecar builds also manage rns-sentineld and rns-statsd. Explicit child binary paths remain available as advanced overrides.".into(),
276                "Changing process launch settings restarts only the affected child processes. Embedded HTTP auth settings reload in place; bind host, port, and enablement changes still require restarting rns-server.".into(),
277            ],
278            fields: vec![
279                ServerConfigFieldSchema {
280                    field: "stats_db_path".into(),
281                    field_type: "string".into(),
282                    required: false,
283                    default_value: self.resolved_config_dir.join("stats.db").display().to_string(),
284                    description: "SQLite database path used by rns-statsd in WASM sidecar builds."
285                        .into(),
286                    effect: "Restarts rns-statsd when changed in WASM sidecar builds.".into(),
287                },
288                ServerConfigFieldSchema {
289                    field: "rnsd_bin".into(),
290                    field_type: "string".into(),
291                    required: false,
292                    default_value: "(self-spawn via /proc/self/exe)".into(),
293                    description: "Advanced override for the Reticulum daemon executable; unset uses self-spawn from rns-server.".into(),
294                    effect: "Restarts rnsd when changed.".into(),
295                },
296                ServerConfigFieldSchema {
297                    field: "sentineld_bin".into(),
298                    field_type: "string".into(),
299                    required: false,
300                    default_value: "(self-spawn via /proc/self/exe)".into(),
301                    description: "Advanced override for the sentinel sidecar executable in WASM sidecar builds; unset uses self-spawn from rns-server.".into(),
302                    effect: "Restarts rns-sentineld when changed in WASM sidecar builds.".into(),
303                },
304                ServerConfigFieldSchema {
305                    field: "statsd_bin".into(),
306                    field_type: "string".into(),
307                    required: false,
308                    default_value: "(self-spawn via /proc/self/exe)".into(),
309                    description: "Advanced override for the stats sidecar executable in WASM sidecar builds; unset uses self-spawn from rns-server.".into(),
310                    effect: "Restarts rns-statsd when changed in WASM sidecar builds.".into(),
311                },
312                ServerConfigFieldSchema {
313                    field: "http.enabled".into(),
314                    field_type: "boolean".into(),
315                    required: false,
316                    default_value: "true".into(),
317                    description: "Enable or disable the embedded HTTP control plane.".into(),
318                    effect: "Requires restarting rns-server.".into(),
319                },
320                ServerConfigFieldSchema {
321                    field: "http.host".into(),
322                    field_type: "string".into(),
323                    required: false,
324                    default_value: "127.0.0.1".into(),
325                    description: "Bind host for the embedded HTTP control plane.".into(),
326                    effect: "Requires restarting rns-server.".into(),
327                },
328                ServerConfigFieldSchema {
329                    field: "http.port".into(),
330                    field_type: "u16".into(),
331                    required: false,
332                    default_value: "8080".into(),
333                    description: "Bind port for the embedded HTTP control plane.".into(),
334                    effect: "Requires restarting rns-server.".into(),
335                },
336                ServerConfigFieldSchema {
337                    field: "http.auth_token".into(),
338                    field_type: "string".into(),
339                    required: false,
340                    default_value: "(generated if auth is enabled and no token is configured)".into(),
341                    description: "Optional fixed bearer token for the embedded HTTP control plane.".into(),
342                    effect: "Reloads embedded HTTP auth in place.".into(),
343                },
344                ServerConfigFieldSchema {
345                    field: "http.disable_auth".into(),
346                    field_type: "boolean".into(),
347                    required: false,
348                    default_value: "false".into(),
349                    description: "Disable bearer-token authentication on the embedded HTTP control plane.".into(),
350                    effect: "Reloads embedded HTTP auth in place.".into(),
351                },
352            ],
353        }
354    }
355
356    pub fn http_enabled(&self) -> bool {
357        self.http.enabled
358    }
359
360    pub fn ctl_args(&self, verbosity: u8) -> rns_ctl::args::Args {
361        let mut argv = vec!["--daemon".to_string()];
362        if let Some(config_path) = &self.config_path {
363            argv.push("--config".into());
364            argv.push(config_path.display().to_string());
365        }
366        argv.push("--host".into());
367        argv.push(self.http.host.clone());
368        argv.push("--port".into());
369        argv.push(self.http.port.to_string());
370        if let Some(token) = &self.http.auth_token {
371            argv.push("--token".into());
372            argv.push(token.clone());
373        }
374        if self.http.disable_auth {
375            argv.push("--disable-auth".into());
376        }
377        if verbosity > 0 {
378            argv.push(format!("-{}", "v".repeat(verbosity as usize)));
379        }
380        rns_ctl::args::Args::parse_from(argv)
381    }
382
383    pub fn control_http_command_line(&self) -> String {
384        let mut parts = vec!["embedded rns-ctl http".to_string(), "--daemon".to_string()];
385        if let Some(config) = &self.config_path {
386            parts.push("--config".to_string());
387            parts.push(config.display().to_string());
388        }
389        parts.push("--host".to_string());
390        parts.push(self.http.host.clone());
391        parts.push("--port".to_string());
392        parts.push(self.http.port.to_string());
393        if let Some(token) = &self.http.auth_token {
394            parts.push("--token".to_string());
395            parts.push(token.clone());
396        }
397        if self.http.disable_auth {
398            parts.push("--disable-auth".to_string());
399        }
400        parts.join(" ")
401    }
402
403    fn command_for_override(&self, path: &PathBuf) -> ProcessCommand {
404        if path.as_os_str().is_empty() {
405            ProcessCommand::SelfInvoke
406        } else {
407            ProcessCommand::External(path.clone())
408        }
409    }
410
411    fn binary_mode_label(&self, path: &PathBuf, role: &str) -> String {
412        if path.as_os_str().is_empty() {
413            format!("self-spawn ({role})")
414        } else {
415            path.display().to_string()
416        }
417    }
418
419    fn readiness_checks(&self) -> Vec<ProcessReadiness> {
420        #[cfg(not(feature = "rns-hooks-wasm"))]
421        {
422            return vec![ProcessReadiness {
423                role: Role::Rnsd,
424                target: ReadinessTarget::Tcp(self.rnsd_rpc_addr),
425            }];
426        }
427
428        #[cfg(feature = "rns-hooks-wasm")]
429        {
430            let mut readiness = vec![ProcessReadiness {
431                role: Role::Rnsd,
432                target: ReadinessTarget::Tcp(self.rnsd_rpc_addr),
433            }];
434
435            #[cfg(feature = "rns-hooks-wasm")]
436            {
437                readiness.push(ProcessReadiness {
438                    role: Role::Sentineld,
439                    target: ReadinessTarget::ReadyFile(self.sentineld_ready_file_path()),
440                });
441                readiness.push(ProcessReadiness {
442                    role: Role::Statsd,
443                    target: ReadinessTarget::ReadyFile(self.statsd_ready_file_path()),
444                });
445            }
446
447            readiness
448        }
449    }
450
451    fn rnsd_drain_config(&self) -> Option<RnsdDrainConfig> {
452        let paths = rns_net::storage::ensure_storage_dirs(&self.resolved_config_dir).ok()?;
453        let identity = rns_net::storage::load_or_create_identity(&paths.identities).ok()?;
454        let private_key = identity.get_private_key()?;
455        Some(RnsdDrainConfig {
456            rpc_addr: RpcAddr::Tcp(
457                self.rnsd_rpc_addr.ip().to_string(),
458                self.rnsd_rpc_addr.port(),
459            ),
460            auth_key: rns_net::rpc::derive_auth_key(&private_key),
461            timeout: std::time::Duration::from_secs(3),
462            poll_interval: std::time::Duration::from_millis(100),
463        })
464    }
465
466    fn rnsd_args(&self) -> Vec<String> {
467        let mut args = Vec::new();
468        if let Some(path) = &self.config_path {
469            args.push("--config".into());
470            args.push(path.display().to_string());
471        }
472        args
473    }
474
475    #[cfg(feature = "rns-hooks-wasm")]
476    fn sentineld_args(&self) -> Vec<String> {
477        let mut args = self.rnsd_args();
478        args.push("--ready-file".into());
479        args.push(self.sentineld_ready_file_path().display().to_string());
480        args
481    }
482
483    #[cfg(feature = "rns-hooks-wasm")]
484    fn statsd_args(&self) -> Vec<String> {
485        let mut args = self.rnsd_args();
486        args.push("--db".into());
487        args.push(self.stats_db_path.display().to_string());
488        args.push("--ready-file".into());
489        args.push(self.statsd_ready_file_path().display().to_string());
490        args
491    }
492
493    #[cfg(feature = "rns-hooks-wasm")]
494    fn sentineld_ready_file_path(&self) -> PathBuf {
495        self.resolved_config_dir.join("rns-sentineld.ready")
496    }
497
498    #[cfg(feature = "rns-hooks-wasm")]
499    fn statsd_ready_file_path(&self) -> PathBuf {
500        self.resolved_config_dir.join("rns-statsd.ready")
501    }
502
503    fn ctl_args_from_server_args(args: &Args) -> rns_ctl::args::Args {
504        let mut argv = vec!["--daemon".to_string()];
505        if let Some(config_path) = args.config_path() {
506            argv.push("--config".into());
507            argv.push(config_path.to_string());
508        }
509        if let Some(host) = args.get("http-host") {
510            argv.push("--host".into());
511            argv.push(host.to_string());
512        }
513        if let Some(port) = args.get("http-port") {
514            argv.push("--port".into());
515            argv.push(port.to_string());
516        }
517        if let Some(token) = args.get("http-token") {
518            argv.push("--token".into());
519            argv.push(token.to_string());
520        }
521        if args.has("disable-auth") {
522            argv.push("--disable-auth".into());
523        }
524        rns_ctl::args::Args::parse_from(argv)
525    }
526
527    fn resolve_rpc_port(config_dir: &Path) -> u16 {
528        let config_file = config_dir.join("config");
529        let parsed = if config_file.exists() {
530            rns_net::config::parse_file(&config_file).ok()
531        } else {
532            rns_net::config::parse("").ok()
533        };
534
535        parsed
536            .as_ref()
537            .map(|cfg| cfg.reticulum.instance_control_port)
538            .unwrap_or(37429)
539    }
540
541    fn build(
542        config_path: Option<PathBuf>,
543        resolved_config_dir: PathBuf,
544        server_config_file_path: PathBuf,
545        server_config_file_present: bool,
546        file_cfg: &ServerConfigFile,
547        args: Option<&Args>,
548    ) -> Self {
549        let ctl_cfg = args
550            .map(Self::ctl_args_from_server_args)
551            .map(|ctl_args| rns_ctl::config::from_args_and_env(&ctl_args));
552
553        let stats_db_path = args
554            .and_then(|args| args.get("stats-db"))
555            .map(PathBuf::from)
556            .or_else(|| file_cfg.stats_db_path.as_ref().map(PathBuf::from))
557            .unwrap_or_else(|| resolved_config_dir.join("stats.db"));
558        let rnsd_bin = args
559            .and_then(|args| args.get("rnsd-bin"))
560            .map(PathBuf::from)
561            .or_else(|| file_cfg.rnsd_bin.as_ref().map(PathBuf::from))
562            .unwrap_or_default();
563        let sentineld_bin = args
564            .and_then(|args| args.get("sentineld-bin"))
565            .map(PathBuf::from)
566            .or_else(|| file_cfg.sentineld_bin.as_ref().map(PathBuf::from))
567            .unwrap_or_default();
568        let statsd_bin = args
569            .and_then(|args| args.get("statsd-bin"))
570            .map(PathBuf::from)
571            .or_else(|| file_cfg.statsd_bin.as_ref().map(PathBuf::from))
572            .unwrap_or_default();
573
574        let http_enabled = if args.is_some_and(|args| args.has("no-http")) {
575            false
576        } else {
577            file_cfg.http.enabled.unwrap_or(true)
578        };
579        let http_host = ctl_cfg
580            .as_ref()
581            .map(|cfg| cfg.host.clone())
582            .filter(|_| {
583                args.is_some_and(|args| args.get("http-host").is_some())
584                    || env_present("RNSCTL_HOST")
585            })
586            .or_else(|| file_cfg.http.host.clone())
587            .unwrap_or_else(|| "127.0.0.1".into());
588        let http_port = ctl_cfg
589            .as_ref()
590            .map(|cfg| cfg.port)
591            .filter(|_| {
592                args.is_some_and(|args| args.get("http-port").is_some())
593                    || env_present("RNSCTL_HTTP_PORT")
594            })
595            .or(file_cfg.http.port)
596            .unwrap_or(8080);
597        let http_auth_token = ctl_cfg
598            .as_ref()
599            .and_then(|cfg| cfg.auth_token.clone())
600            .filter(|_| {
601                args.is_some_and(|args| args.get("http-token").is_some())
602                    || env_present("RNSCTL_AUTH_TOKEN")
603            })
604            .or_else(|| file_cfg.http.auth_token.clone());
605        let http_disable_auth = if args.is_some_and(|args| args.has("disable-auth"))
606            || env_true("RNSCTL_DISABLE_AUTH")
607        {
608            true
609        } else {
610            file_cfg.http.disable_auth.unwrap_or(false)
611        };
612
613        let rpc_port = Self::resolve_rpc_port(&resolved_config_dir);
614
615        Self {
616            config_path,
617            resolved_config_dir,
618            server_config_file_path,
619            server_config_file_present,
620            file_config: file_cfg.clone(),
621            stats_db_path,
622            rnsd_bin,
623            sentineld_bin,
624            statsd_bin,
625            http: HttpConfig {
626                enabled: http_enabled,
627                host: http_host,
628                port: http_port,
629                auth_token: http_auth_token,
630                disable_auth: http_disable_auth,
631                daemon_mode: true,
632            },
633            rnsd_rpc_addr: format!("127.0.0.1:{rpc_port}")
634                .parse()
635                .unwrap_or(std::net::SocketAddr::from(([127, 0, 0, 1], 37429))),
636        }
637    }
638
639    fn load_config_file(path: &Path) -> Result<(ServerConfigFile, bool), String> {
640        if !path.exists() {
641            return Ok((ServerConfigFile::default(), false));
642        }
643        let body = std::fs::read(path)
644            .map_err(|err| format!("failed to read {}: {}", path.display(), err))?;
645        let cfg = Self::parse_config_json(&body)?;
646        Self::validate_file_config(&cfg)?;
647        Ok((cfg, true))
648    }
649
650    fn parse_config_json(body: &[u8]) -> Result<ServerConfigFile, String> {
651        serde_json::from_slice(body).map_err(|err| format!("invalid server config JSON: {}", err))
652    }
653
654    fn validate_file_config(file_cfg: &ServerConfigFile) -> Result<Vec<String>, String> {
655        let mut warnings = Vec::new();
656
657        validate_optional_nonempty("stats_db_path", file_cfg.stats_db_path.as_deref())?;
658        validate_optional_nonempty("rnsd_bin", file_cfg.rnsd_bin.as_deref())?;
659        validate_optional_nonempty("sentineld_bin", file_cfg.sentineld_bin.as_deref())?;
660        validate_optional_nonempty("statsd_bin", file_cfg.statsd_bin.as_deref())?;
661        validate_optional_nonempty("http.host", file_cfg.http.host.as_deref())?;
662        validate_optional_nonempty("http.auth_token", file_cfg.http.auth_token.as_deref())?;
663
664        if matches!(file_cfg.http.enabled, Some(true)) && matches!(file_cfg.http.port, Some(0)) {
665            return Err("http.port must be greater than 0 when HTTP is enabled".into());
666        }
667
668        if matches!(file_cfg.http.enabled, Some(false))
669            && (file_cfg.http.host.is_some()
670                || file_cfg.http.port.is_some()
671                || file_cfg.http.auth_token.is_some()
672                || file_cfg.http.disable_auth.is_some())
673        {
674            warnings.push(
675                "HTTP config fields are present while http.enabled=false; they will be saved but remain inactive until HTTP is re-enabled."
676                    .into(),
677            );
678        }
679
680        if matches!(file_cfg.http.disable_auth, Some(true)) && file_cfg.http.auth_token.is_some() {
681            warnings.push(
682                "http.auth_token is set but disable_auth=true, so the token will be ignored until auth is enabled again."
683                    .into(),
684            );
685        }
686
687        Ok(warnings)
688    }
689
690    fn with_file_config(&self, file_cfg: &ServerConfigFile, file_present: bool) -> Self {
691        Self::build(
692            self.config_path.clone(),
693            self.resolved_config_dir.clone(),
694            self.server_config_file_path.clone(),
695            file_present,
696            file_cfg,
697            None,
698        )
699    }
700
701    fn apply_plan(&self, next: &Self) -> ServerConfigApplyPlan {
702        let current_specs = self.process_specs();
703        let next_specs = next.process_specs();
704        let mut processes_to_restart = Vec::new();
705        let mut changes = Vec::new();
706
707        for current in &current_specs {
708            let Some(next_spec) = next_specs.iter().find(|spec| spec.role == current.role) else {
709                continue;
710            };
711            if current.command != next_spec.command || current.args != next_spec.args {
712                let name = current.role.display_name().to_string();
713                processes_to_restart.push(name.clone());
714                if current.command != next_spec.command {
715                    changes.push(ServerConfigChange {
716                        field: format!("{name}.bin"),
717                        before: current.command.display(current.role),
718                        after: next_spec.command.display(next_spec.role),
719                        effect: format!("restart {name}"),
720                    });
721                }
722                if current.args != next_spec.args {
723                    changes.push(ServerConfigChange {
724                        field: format!("{name}.args"),
725                        before: if current.args.is_empty() {
726                            "(none)".into()
727                        } else {
728                            current.args.join(" ")
729                        },
730                        after: if next_spec.args.is_empty() {
731                            "(none)".into()
732                        } else {
733                            next_spec.args.join(" ")
734                        },
735                        effect: format!("restart {name}"),
736                    });
737                }
738            }
739        }
740
741        let control_plane_reload_required = self.http.auth_token != next.http.auth_token
742            || self.http.disable_auth != next.http.disable_auth;
743        let control_plane_restart_required = self.http.enabled != next.http.enabled
744            || self.http.host != next.http.host
745            || self.http.port != next.http.port;
746
747        if self.stats_db_path != next.stats_db_path {
748            changes.push(ServerConfigChange {
749                field: "stats_db_path".into(),
750                before: self.stats_db_path.display().to_string(),
751                after: next.stats_db_path.display().to_string(),
752                effect: "restart rns-statsd".into(),
753            });
754        }
755        if self.http.enabled != next.http.enabled {
756            changes.push(ServerConfigChange {
757                field: "http.enabled".into(),
758                before: self.http.enabled.to_string(),
759                after: next.http.enabled.to_string(),
760                effect: "restart rns-server".into(),
761            });
762        }
763        if self.http.host != next.http.host {
764            changes.push(ServerConfigChange {
765                field: "http.host".into(),
766                before: self.http.host.clone(),
767                after: next.http.host.clone(),
768                effect: "restart rns-server".into(),
769            });
770        }
771        if self.http.port != next.http.port {
772            changes.push(ServerConfigChange {
773                field: "http.port".into(),
774                before: self.http.port.to_string(),
775                after: next.http.port.to_string(),
776                effect: "restart rns-server".into(),
777            });
778        }
779        if self.http.disable_auth != next.http.disable_auth {
780            changes.push(ServerConfigChange {
781                field: "http.disable_auth".into(),
782                before: self.http.disable_auth.to_string(),
783                after: next.http.disable_auth.to_string(),
784                effect: "reload embedded HTTP auth".into(),
785            });
786        }
787        if self.http.auth_token != next.http.auth_token {
788            changes.push(ServerConfigChange {
789                field: "http.auth_token".into(),
790                before: mask_token(&self.http.auth_token),
791                after: mask_token(&next.http.auth_token),
792                effect: "reload embedded HTTP auth".into(),
793            });
794        }
795
796        let mut notes = Vec::new();
797        if processes_to_restart.is_empty() {
798            notes.push("No supervised child restart is required for this config.".into());
799        } else {
800            notes.push(format!(
801                "Restart required for: {}.",
802                processes_to_restart.join(", ")
803            ));
804        }
805        if control_plane_restart_required {
806            notes.push(
807                "Embedded control-plane HTTP settings changed and will only take effect after restarting rns-server."
808                    .into(),
809            );
810        }
811        if control_plane_reload_required && !control_plane_restart_required {
812            notes.push("Embedded control-plane auth settings will be reloaded in place.".into());
813        }
814        let overall_action = match (
815            processes_to_restart.is_empty(),
816            control_plane_reload_required,
817            control_plane_restart_required,
818        ) {
819            (true, false, false) => "none",
820            (false, false, false) => "restart_children",
821            (true, true, false) => "reload_control_plane",
822            (false, true, false) => "restart_children_and_reload_control_plane",
823            (true, _, true) => "restart_server",
824            (false, _, true) => "restart_children_and_server",
825        };
826
827        ServerConfigApplyPlan {
828            overall_action: overall_action.into(),
829            processes_to_restart,
830            control_plane_reload_required,
831            control_plane_restart_required,
832            notes,
833            changes,
834        }
835    }
836
837    fn editable_file_json(&self) -> String {
838        serde_json::to_string_pretty(&self.file_config)
839            .unwrap_or_else(|_| "{\n  \"http\": {}\n}".into())
840    }
841
842    fn example_config_json() -> String {
843        serde_json::to_string_pretty(&ServerConfigFile {
844            stats_db_path: Some("stats.db".into()),
845            rnsd_bin: None,
846            sentineld_bin: None,
847            statsd_bin: None,
848            http: ServerHttpConfigFile {
849                enabled: Some(true),
850                host: Some("127.0.0.1".into()),
851                port: Some(8080),
852                auth_token: None,
853                disable_auth: Some(false),
854            },
855        })
856        .unwrap_or_else(|_| "{\n  \"http\": {}\n}".into())
857    }
858}
859
860fn validate_optional_nonempty(field: &str, value: Option<&str>) -> Result<(), String> {
861    if value.is_some_and(|raw| raw.trim().is_empty()) {
862        return Err(format!("{field} cannot be empty"));
863    }
864    Ok(())
865}
866
867fn env_present(name: &str) -> bool {
868    std::env::var_os(name).is_some()
869}
870
871fn env_true(name: &str) -> bool {
872    std::env::var(name)
873        .map(|value| value == "true" || value == "1")
874        .unwrap_or(false)
875}
876
877fn mask_token(token: &Option<String>) -> String {
878    match token {
879        Some(value) if !value.is_empty() => format!("set({} chars)", value.len()),
880        _ => "unset".into(),
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use super::{HttpConfig, ServerConfig, ServerConfigFile, ServerHttpConfigFile};
887    use crate::supervisor::{ProcessCommand, ReadinessTarget, Role};
888    use std::path::PathBuf;
889
890    fn test_config() -> ServerConfig {
891        ServerConfig {
892            config_path: Some(PathBuf::from("/tmp/rns")),
893            resolved_config_dir: PathBuf::from("/tmp/rns"),
894            server_config_file_path: PathBuf::from("/tmp/rns/rns-server.json"),
895            server_config_file_present: true,
896            file_config: ServerConfigFile::default(),
897            stats_db_path: PathBuf::from("/tmp/rns/stats.db"),
898            rnsd_bin: PathBuf::new(),
899            sentineld_bin: PathBuf::new(),
900            statsd_bin: PathBuf::new(),
901            http: HttpConfig {
902                enabled: true,
903                host: "127.0.0.1".into(),
904                port: 8080,
905                auth_token: None,
906                disable_auth: false,
907                daemon_mode: true,
908            },
909            rnsd_rpc_addr: "127.0.0.1:37429".parse().unwrap(),
910        }
911    }
912
913    #[cfg(feature = "rns-hooks-wasm")]
914    #[test]
915    fn apply_plan_restarts_statsd_when_db_changes() {
916        let current = test_config();
917        let next = current.with_file_config(
918            &ServerConfigFile {
919                stats_db_path: Some("/tmp/rns/other-stats.db".into()),
920                ..ServerConfigFile::default()
921            },
922            true,
923        );
924
925        let plan = current.apply_plan(&next);
926
927        assert_eq!(plan.overall_action, "restart_children");
928        assert_eq!(plan.processes_to_restart, vec!["rns-statsd".to_string()]);
929        assert!(plan
930            .changes
931            .iter()
932            .any(|change| change.field == "stats_db_path"));
933    }
934
935    #[cfg(not(feature = "rns-hooks-wasm"))]
936    #[test]
937    fn apply_plan_ignores_stats_db_when_hooks_are_disabled() {
938        let current = test_config();
939        let next = current.with_file_config(
940            &ServerConfigFile {
941                stats_db_path: Some("/tmp/rns/other-stats.db".into()),
942                ..ServerConfigFile::default()
943            },
944            true,
945        );
946
947        let plan = current.apply_plan(&next);
948
949        assert_eq!(plan.overall_action, "none");
950        assert!(plan.processes_to_restart.is_empty());
951    }
952
953    #[test]
954    fn apply_plan_requires_server_restart_for_http_port_change() {
955        let current = test_config();
956        let next = current.with_file_config(
957            &ServerConfigFile {
958                http: ServerHttpConfigFile {
959                    port: Some(9090),
960                    ..ServerHttpConfigFile::default()
961                },
962                ..ServerConfigFile::default()
963            },
964            true,
965        );
966
967        let plan = current.apply_plan(&next);
968
969        assert_eq!(plan.overall_action, "restart_server");
970        assert!(!plan.control_plane_reload_required);
971        assert!(plan.control_plane_restart_required);
972        assert!(plan.processes_to_restart.is_empty());
973        assert!(plan
974            .changes
975            .iter()
976            .any(|change| change.field == "http.port" && change.after == "9090"));
977    }
978
979    #[test]
980    fn apply_plan_is_noop_when_config_does_not_change() {
981        let current = test_config();
982        let next = current.with_file_config(&ServerConfigFile::default(), true);
983
984        let plan = current.apply_plan(&next);
985
986        assert_eq!(plan.overall_action, "none");
987        assert!(plan.processes_to_restart.is_empty());
988        assert!(!plan.control_plane_reload_required);
989        assert!(!plan.control_plane_restart_required);
990    }
991
992    #[test]
993    fn apply_plan_reloads_auth_when_token_changes() {
994        let current = test_config();
995        let next = current.with_file_config(
996            &ServerConfigFile {
997                http: ServerHttpConfigFile {
998                    auth_token: Some("new-token".into()),
999                    ..ServerHttpConfigFile::default()
1000                },
1001                ..ServerConfigFile::default()
1002            },
1003            true,
1004        );
1005
1006        let plan = current.apply_plan(&next);
1007
1008        assert_eq!(plan.overall_action, "reload_control_plane");
1009        assert!(plan.control_plane_reload_required);
1010        assert!(!plan.control_plane_restart_required);
1011    }
1012
1013    #[test]
1014    fn validation_rejects_unknown_fields() {
1015        let err = ServerConfig::parse_config_json(br#"{"unknown":true}"#).unwrap_err();
1016        assert!(err.contains("unknown field"));
1017    }
1018
1019    #[test]
1020    fn validation_rejects_empty_strings() {
1021        let err = ServerConfig::validate_file_config(&ServerConfigFile {
1022            stats_db_path: Some("   ".into()),
1023            ..ServerConfigFile::default()
1024        })
1025        .unwrap_err();
1026        assert_eq!(err, "stats_db_path cannot be empty");
1027    }
1028
1029    #[test]
1030    fn validation_warns_when_http_fields_are_disabled() {
1031        let warnings = ServerConfig::validate_file_config(&ServerConfigFile {
1032            http: ServerHttpConfigFile {
1033                enabled: Some(false),
1034                port: Some(8080),
1035                ..ServerHttpConfigFile::default()
1036            },
1037            ..ServerConfigFile::default()
1038        })
1039        .unwrap();
1040        assert_eq!(warnings.len(), 1);
1041        assert!(warnings[0].contains("http.enabled=false"));
1042    }
1043
1044    #[cfg(feature = "rns-hooks-wasm")]
1045    #[test]
1046    fn process_specs_include_sidecar_ready_file_args() {
1047        let config = test_config();
1048        let specs = config.process_specs();
1049        assert!(matches!(specs[0].command, ProcessCommand::SelfInvoke));
1050
1051        let sentineld = specs
1052            .iter()
1053            .find(|spec| spec.role == Role::Sentineld)
1054            .unwrap();
1055        assert!(sentineld.args.windows(2).any(|pair| {
1056            pair[0] == "--ready-file" && pair[1] == "/tmp/rns/rns-sentineld.ready"
1057        }));
1058
1059        let statsd = specs.iter().find(|spec| spec.role == Role::Statsd).unwrap();
1060        assert!(statsd
1061            .args
1062            .windows(2)
1063            .any(|pair| { pair[0] == "--ready-file" && pair[1] == "/tmp/rns/rns-statsd.ready" }));
1064    }
1065
1066    #[cfg(not(feature = "rns-hooks-wasm"))]
1067    #[test]
1068    fn process_specs_include_only_rnsd_without_hooks() {
1069        let config = test_config();
1070        let specs = config.process_specs();
1071
1072        assert_eq!(specs.len(), 1);
1073        assert_eq!(specs[0].role, Role::Rnsd);
1074        assert!(matches!(specs[0].command, ProcessCommand::SelfInvoke));
1075    }
1076
1077    #[cfg(feature = "rns-hooks-wasm")]
1078    #[test]
1079    fn readiness_checks_use_ready_files_for_sidecars() {
1080        let config = test_config();
1081        let readiness = config.readiness_checks();
1082
1083        let sentineld = readiness
1084            .iter()
1085            .find(|entry| entry.role == Role::Sentineld)
1086            .unwrap();
1087        match &sentineld.target {
1088            ReadinessTarget::ReadyFile(path) => {
1089                assert_eq!(path, &PathBuf::from("/tmp/rns/rns-sentineld.ready"));
1090            }
1091            _ => panic!("unexpected sentineld readiness target"),
1092        }
1093
1094        let statsd = readiness
1095            .iter()
1096            .find(|entry| entry.role == Role::Statsd)
1097            .unwrap();
1098        match &statsd.target {
1099            ReadinessTarget::ReadyFile(path) => {
1100                assert_eq!(path, &PathBuf::from("/tmp/rns/rns-statsd.ready"));
1101            }
1102            _ => panic!("unexpected statsd readiness target"),
1103        }
1104    }
1105
1106    #[cfg(not(feature = "rns-hooks-wasm"))]
1107    #[test]
1108    fn readiness_checks_include_only_rnsd_without_hooks() {
1109        let config = test_config();
1110        let readiness = config.readiness_checks();
1111
1112        assert_eq!(readiness.len(), 1);
1113        assert_eq!(readiness[0].role, Role::Rnsd);
1114        match readiness[0].target {
1115            ReadinessTarget::Tcp(addr) => assert_eq!(addr, "127.0.0.1:37429".parse().unwrap()),
1116            _ => panic!("unexpected rnsd readiness target"),
1117        }
1118    }
1119
1120    #[test]
1121    fn explicit_child_override_uses_external_binary_command() {
1122        let current = test_config();
1123        let next = current.with_file_config(
1124            &ServerConfigFile {
1125                rnsd_bin: Some("/opt/custom-rnsd".into()),
1126                ..ServerConfigFile::default()
1127            },
1128            true,
1129        );
1130
1131        let specs = next.process_specs();
1132        assert!(matches!(
1133            specs[0].command,
1134            ProcessCommand::External(ref path) if path == &PathBuf::from("/opt/custom-rnsd")
1135        ));
1136    }
1137
1138    #[test]
1139    fn ensure_runtime_bootstrap_creates_identity_once() {
1140        let config_dir =
1141            std::env::temp_dir().join(format!("rns-server-bootstrap-test-{}", std::process::id()));
1142        let _ = std::fs::remove_dir_all(&config_dir);
1143        let config = ServerConfig {
1144            config_path: Some(config_dir.clone()),
1145            resolved_config_dir: config_dir.clone(),
1146            server_config_file_path: config_dir.join("rns-server.json"),
1147            server_config_file_present: false,
1148            file_config: ServerConfigFile::default(),
1149            stats_db_path: config_dir.join("stats.db"),
1150            rnsd_bin: PathBuf::new(),
1151            sentineld_bin: PathBuf::new(),
1152            statsd_bin: PathBuf::new(),
1153            http: HttpConfig {
1154                enabled: true,
1155                host: "127.0.0.1".into(),
1156                port: 8080,
1157                auth_token: None,
1158                disable_auth: false,
1159                daemon_mode: true,
1160            },
1161            rnsd_rpc_addr: "127.0.0.1:37429".parse().unwrap(),
1162        };
1163
1164        config.ensure_runtime_bootstrap().unwrap();
1165        config.ensure_runtime_bootstrap().unwrap();
1166
1167        let identity_path = config_dir.join("storage/identities/identity");
1168        assert!(identity_path.exists());
1169        assert_eq!(std::fs::metadata(identity_path).unwrap().len(), 64);
1170
1171        let _ = std::fs::remove_dir_all(&config_dir);
1172    }
1173}