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 ¤t_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}