From fb25ffa04ba5a7fa00391c13e8bef8ffa38a3bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 30 Sep 2026 20:28:42 +0500 Subject: [PATCH] Module confs get newly documented keys from their .dist; sharing also allows remote CoA clients Co-Authored-By: Claude Sonnet 5.5 --- crates/coa-core/src/config/mod.rs | 20 ++++++++++++++++---- crates/coa-core/src/friends.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/coa-core/src/config/mod.rs b/crates/coa-core/src/config/mod.rs index 5f53037..1698841 100644 --- a/crates/coa-core/src/config/mod.rs +++ b/crates/coa-core/src/config/mod.rs @@ -122,7 +122,9 @@ pub struct SettingsView { /// Modules read `Core/configs/modules/.conf`, but packages ship only the documented `.conf.dist` (the active /// files are the server owner's). Create every missing active file from its `.dist`, so a fresh server runs with the /// documented defaults (for example `CoA.Enable = 1`, without which the Ascension client is dropped after login). -/// Existing files are never touched. Files the launcher writes itself are skipped. Returns the created file names. +/// An existing file only receives keys its `.dist` gained since (added with their documentation, values never changed), +/// so a server updated to a build with a new setting gets that setting's documented default. Files the launcher writes +/// itself are skipped. Returns what was created (`name.conf`) or extended (`name.conf (+n keys)`). pub fn materialize_module_configs(root: &Path) -> Result> { let dir = root.join("Core").join("configs").join("modules"); let mut created = Vec::new(); @@ -137,6 +139,14 @@ pub fn materialize_module_configs(root: &Path) -> Result> { if !active.exists() { fs::copy(e.path(), &active)?; created.push(format!("{stem}.conf")); + } else if let (Ok(have), Ok(dist)) = (fs::read(&active), fs::read(e.path())) { + if let (Ok(mut conf), Ok(dist)) = (parser::ConfFile::parse_bytes(&have), parser::ConfFile::parse_bytes(&dist)) { + let plan = merge::apply(&mut conf, &dist); + if !plan.added.is_empty() { + fsx::atomic_write(&active, conf.to_text().as_bytes())?; + created.push(format!("{stem}.conf (+{} keys)", plan.added.len())); + } + } } } created.sort(); @@ -495,16 +505,18 @@ mod tests { fs::write(m.join("coa.conf.dist"), "CoA.Enable = 1 ").unwrap(); fs::write(m.join("spellbook.conf.dist"), "Spellbook.Enable = 1 +Spellbook.New = 5 ").unwrap(); fs::write(m.join("spellbook.conf"), "Spellbook.Enable = 0 ").unwrap(); fs::write(m.join("coa_bugreport.conf.dist"), "x = 1 ").unwrap(); - assert_eq!(materialize_module_configs(&root).unwrap(), vec!["coa.conf".to_string()]); + assert_eq!(materialize_module_configs(&root).unwrap(), vec!["coa.conf".to_string(), "spellbook.conf (+1 keys)".to_string()]); assert_eq!(fs::read_to_string(m.join("coa.conf")).unwrap(), "CoA.Enable = 1 "); - assert_eq!(fs::read_to_string(m.join("spellbook.conf")).unwrap(), "Spellbook.Enable = 0 -"); + let sb = fs::read_to_string(m.join("spellbook.conf")).unwrap(); + assert!(sb.contains("Spellbook.Enable = 0"), "existing values are never changed: {sb}"); + assert!(sb.contains("Spellbook.New = 5"), "new documented key was added: {sb}"); assert!(!m.join("coa_bugreport.conf").exists()); assert!(materialize_module_configs(&root).unwrap().is_empty()); } diff --git a/crates/coa-core/src/friends.rs b/crates/coa-core/src/friends.rs index 14d785f..10b77fa 100644 --- a/crates/coa-core/src/friends.rs +++ b/crates/coa-core/src/friends.rs @@ -89,6 +89,18 @@ pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result { originals.push((p.clone(), bytes)); edits.push((p, conf.to_text())); } + // Friends connect from other addresses, and the server only applies the CoA client protocol (extension packets, + // header mode) to loopback unless remote clients are allowed - so sharing must switch that on as well. + let coa_conf = root.join("Core/configs/modules/coa.conf"); + if let Ok(bytes) = fs::read(&coa_conf) { + let mut conf = ConfFile::parse_bytes(&bytes)?; + let want_remote = if open { "1" } else { "0" }; + if conf.get("CoA.AllowRemoteClients").map(str::trim) != Some(want_remote) { + conf.set("CoA.AllowRemoteClients", want_remote, &["Set by CoA Server Manager together with the friends mode"]); + originals.push((coa_conf.clone(), bytes)); + edits.push((coa_conf, conf.to_text())); + } + } if edits.is_empty() { return Ok(false); } @@ -200,6 +212,21 @@ mod tests { assert!(fs::read_to_string(root.join("Core/configs/worldserver.conf")).unwrap().contains("BindIP = \"127.0.0.1\"")); } + #[test] + fn sharing_also_allows_remote_coa_clients_and_closing_takes_it_back() { + let (_d, root, meta) = setup(); + let coa = root.join("Core/configs/modules/coa.conf"); + fs::create_dir_all(coa.parent().unwrap()).unwrap(); + fs::write(&coa, "CoA.Enable = 1 +CoA.AllowRemoteClients = 0 +").unwrap(); + assert!(set_open(&root, &meta, true).unwrap()); + assert!(fs::read_to_string(&coa).unwrap().contains("CoA.AllowRemoteClients = 1")); + assert!(fs::read_to_string(&coa).unwrap().contains("CoA.Enable = 1")); + assert!(set_open(&root, &meta, false).unwrap()); + assert!(fs::read_to_string(&coa).unwrap().contains("CoA.AllowRemoteClients = 0")); + } + #[test] fn a_shared_server_gets_its_bind_address_back_before_start_and_a_local_one_is_left_alone() { let (_d, root, meta) = setup();