From 90c308615bdc0ccdf04f9cd8a1becd958917cb53 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:02:22 +0200 Subject: [PATCH 1/7] fix: settings the Docker backend fixes are not offered, and the world restarts itself Found by auditing every screen for a Docker server. - Server settings: Ra.Enable, Ra.IP and Updates.EnableDatabases were on the page and written to worldserver.conf, but the containers get AC_RA_ENABLE, AC_RA_IP and AC_UPDATES_ENABLE_DATABASES, and the core reads those before the file: the change was accepted and had no effect, and the page showed the file's value while the container ran another. A Docker server no longer offers them (nor "restore recommended defaults" for them), and a change forced through is refused with the reason. The list is docker::FORCED_KEYS, and a test compares it to the variables the containers really get, so adding one without the other fails. - `server restart` (and idlerestart) make the core exit with code 2 and expect a supervisor to start it again; the Docker world had none and showed "crashed". It now gets `--restart on-failure:3`, applied once the server is up (so a start that fails stays failed instead of looping); a stop or `server shutdown` (code 0) is never restarted. --- crates/coa-core/src/config/mod.rs | 27 +++++++++ crates/coa-core/src/docker/lifecycle.rs | 35 ++++++++++++ crates/coa-core/src/docker/mod.rs | 73 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/crates/coa-core/src/config/mod.rs b/crates/coa-core/src/config/mod.rs index 7070eb6..f5c82a8 100644 --- a/crates/coa-core/src/config/mod.rs +++ b/crates/coa-core/src/config/mod.rs @@ -213,9 +213,12 @@ pub fn load(root: &Path, scope: Scope) -> Result { let known: BTreeSet<&str> = schema.settings.iter().map(|s| s.key.as_str()).collect(); let unknown_keys = conf.entries().filter(|(k, _)| !known.contains(k)).count(); + // On a Docker server some settings are fixed by the backend (environment beats the file): not offered. + let docker = scope == Scope::Server && crate::docker::is_docker(root); let settings = schema .settings .iter() + .filter(|s| !(docker && crate::docker::managed_setting(&s.key))) .map(|s| { let raw = conf.get(&s.key); let (value, problem) = match raw { @@ -286,9 +289,13 @@ pub fn validate(root: &Path, scope: Scope, changes: &BTreeMap) -> let schema = scope.schema(); let mut errors = Vec::new(); let mut raws = BTreeMap::new(); + let docker = scope == Scope::Server && crate::docker::is_docker(root); for (key, value) in changes { match schema.get(key) { None => errors.push(FieldError { key: key.clone(), message: "is not a setting the Manager can change".into() }), + Some(_) if docker && crate::docker::managed_setting(key) => { + errors.push(FieldError { key: key.clone(), message: "is set by the Docker backend; changing it in the file would have no effect".into() }) + } Some(s) => match s.to_raw(value) { Ok(raw) => { raws.insert(key.clone(), raw); @@ -736,6 +743,26 @@ Spellbook.New = 5 assert!(list_snapshots(&meta).is_empty(), "no snapshot for a rejected save"); } + #[test] + fn a_docker_server_is_not_offered_the_settings_its_backend_fixes() { + let (_d, root, meta) = fixture(); + let keys = |root: &Path| load(root, Scope::Server).unwrap().settings.into_iter().map(|s| s.meta.key).collect::>(); + for k in ["Ra.Enable", "Ra.IP", "Updates.EnableDatabases", "PlayerLimit"] { + assert!(keys(&root).iter().any(|x| x == k), "a repack offers {k}"); + } + fs::write(root.join("Settings/docker.json"), br#"{"project":"t1"}"#).unwrap(); + let docker = keys(&root); + for k in ["Ra.Enable", "Ra.IP", "Updates.EnableDatabases"] { + assert!(!docker.iter().any(|x| x == k), "a Docker server does not offer {k}: the containers' environment beats the file"); + } + assert!(docker.iter().any(|x| x == "PlayerLimit"), "everything else is still offered"); + // Forcing a change through is refused and says why, instead of being saved and ignored. + let err = save(&root, &meta, Scope::Server, &set(&[("Ra.Enable", json!(0))])).unwrap_err().to_string(); + assert!(err.contains("Docker backend"), "{err}"); + assert!(!fs::read_to_string(root.join("Core/configs/worldserver.conf")).unwrap().contains("Ra.Enable = 0")); + save(&root, &meta, Scope::Server, &set(&[("PlayerLimit", json!(77))])).unwrap(); + } + #[test] fn missing_key_is_appended_and_noop_saves_do_nothing() { let (_d, root, meta) = fixture(); diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index 642ed36..6620c21 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -242,6 +242,10 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L run_container(d, &call, &n.world, log)?; } wait_listening(d, cfg, &n.world, ports.world, WORLD_WAIT, log)?; + // The core leaves with code 2 for `server restart` and expects a supervisor to start it again (the repack has one). Docker + // does that for any non-zero exit, but only once the server has come up: a start that fails must stay failed, not loop. + // A stop by `docker stop` or `server shutdown` (code 0) is never restarted. + let _ = docker(d, &["update", "--restart", "on-failure:3", &n.world], QUICK); if !state.get(&n.auth).is_some_and(Container::running) { remove(d, &n.auth); @@ -388,6 +392,21 @@ fn database_env(s: &Secrets, world: bool, realm: crate::realms::Mode) -> Vec<(St env } +/// Every setting variable (`AC_...`) the containers get, for the check that the screens know which settings are fixed. +#[cfg(test)] +pub(crate) fn forced_variables() -> Vec { + let cfg = Config { project: "t".into(), bind_address: "127.0.0.1".into(), mysql_image: "mysql:8.4".into(), data_dir: None }; + let n = cfg.names(); + let mut found = Vec::new(); + for kind in [GameKind::World, GameKind::Auth] { + let args = game_args(&cfg, &n, kind, Path::new("/s"), Path::new("/d"), 1, 2, None); + found.extend(args.iter().filter_map(|a| a.strip_prefix("AC_").map(|rest| format!("AC_{}", rest.split('=').next().unwrap())))); + } + found.sort(); + found.dedup(); + found +} + /// Realm profiles are only in play once a profile has been chosen on this server (`Settings/realm-profile.json`). fn prepare_realm(root: &Path, log: &mut Log) -> Step { let fail_with = |e: crate::error::Error| Failure { code: ErrorCode::StartupFailed, output: e.to_string() }; @@ -670,6 +689,7 @@ mod tests { } out(0, "containerid\n", "") } + ["update", "--restart", _, _] => out(0, "", ""), ["stop", "--time", _, name] => { self.running.borrow_mut().remove(*name); self.exited.borrow_mut().insert(name.to_string(), (0, false)); @@ -757,6 +777,21 @@ mod tests { assert!(!plain.iter().any(|a| a == "--lower-case-table-names=1" || a == "--user")); } + #[test] + fn the_world_server_is_started_again_when_it_restarts_itself_but_only_once_it_is_up() { + let (_d, root) = server("t1"); + let sim = Sim::new(); + assert!(start_all(&sim, &root).ok); + let world_run = sim.calls_of("run").into_iter().find(|c| c.args.iter().any(|a| a == "coa-t1-world")).unwrap(); + assert!(!world_run.args.iter().any(|a| a == "--restart"), "no restart while it is still starting"); + let updates = sim.calls_of("update"); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].args, ["update", "--restart", "on-failure:3", "coa-t1-world"]); + let verbs = sim.verbs(); + let pos = |p: &str| verbs.iter().position(|v| v.starts_with(p)).unwrap(); + assert!(pos("run --detach") < pos("update"), "after the container exists and listens"); + } + #[test] fn the_containers_use_the_databases_of_the_selected_realm() { let s = Secrets { root: "r".into(), app: "apppw".into() }; diff --git a/crates/coa-core/src/docker/mod.rs b/crates/coa-core/src/docker/mod.rs index fae61d9..d668bf1 100644 --- a/crates/coa-core/src/docker/mod.rs +++ b/crates/coa-core/src/docker/mod.rs @@ -140,6 +140,52 @@ pub fn ensure_main_configs(root: &Path) -> Result> { Ok(created) } +/// Settings the Docker backend gives the containers as environment variables. The core reads `AC_` before the +/// configuration file, so a value written to the file for one of these keys is silently ignored. They are what makes the +/// containers work (where things are inside them, listening on every interface, the console, no built-in database +/// updater, the databases); the owner has no use for them and the screens do not offer them. +pub(crate) const FORCED_KEYS: [&str; 12] = [ + "BindIP", + "WorldServerPort", + "RealmServerPort", + "LogsDir", + "DataDir", + "Updates.EnableDatabases", + "Ra.Enable", + "Ra.IP", + "Ra.Port", + "LoginDatabaseInfo", + "WorldDatabaseInfo", + "CharacterDatabaseInfo", +]; + +/// Is this setting fixed by the Docker backend (see `FORCED_KEYS`)? +pub fn managed_setting(key: &str) -> bool { + FORCED_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) +} + +/// The environment variable the core reads for a setting: `AC_` and the key in upper snake case, a `_` between a lower-case +/// letter and a capital, and at letter / digit boundaries (the core's `IniKeyToEnvVarKey`). +#[cfg(test)] +pub(crate) fn env_name(key: &str) -> String { + let chars: Vec = key.chars().collect(); + let mut out = String::from("AC_"); + for (i, &c) in chars.iter().enumerate() { + if matches!(c, ' ' | '.' | '-') { + out.push('_'); + continue; + } + out.push(c.to_ascii_uppercase()); + if let Some(&next) = chars.get(i + 1) { + let split = (!c.is_ascii_uppercase() && next.is_ascii_uppercase()) || (!c.is_ascii_digit() && next.is_ascii_digit()) || (c.is_ascii_digit() && !next.is_ascii_digit() && !matches!(next, ' ' | '.' | '-')); + if split { + out.push('_'); + } + } + } + out +} + /// Name of the runtime image: it follows the content of its Dockerfile, so changing the libraries builds a new image. pub(crate) fn runtime_image() -> String { format!("coa-runtime:{}", &fsx::sha256_bytes(RUNTIME_DOCKERFILE.as_bytes())[..12]) @@ -206,6 +252,33 @@ mod tests { use super::*; use std::fs; + #[test] + fn setting_names_become_the_variables_the_core_reads() { + for (key, var) in [ + ("BindIP", "AC_BIND_IP"), + ("Ra.Enable", "AC_RA_ENABLE"), + ("Ra.IP", "AC_RA_IP"), + ("Updates.EnableDatabases", "AC_UPDATES_ENABLE_DATABASES"), + ("WorldServerPort", "AC_WORLD_SERVER_PORT"), + ("LoginDatabaseInfo", "AC_LOGIN_DATABASE_INFO"), + ("DataDir", "AC_DATA_DIR"), + ("Dynamic.XP.Reminder.Interval", "AC_DYNAMIC_XP_REMINDER_INTERVAL"), + ("EtherealBazaar.Enable", "AC_ETHEREAL_BAZAAR_ENABLE"), + ] { + assert_eq!(env_name(key), var, "{key}"); + } + } + + #[test] + fn the_settings_called_managed_are_exactly_the_variables_the_containers_get() { + // If a variable is added to the containers without being listed here (or the other way round), a screen would + // offer a setting that does nothing, or hide one that works. + let given: std::collections::BTreeSet = lifecycle::forced_variables().into_iter().collect(); + let listed: std::collections::BTreeSet = FORCED_KEYS.iter().map(|k| env_name(k)).collect(); + assert_eq!(given, listed); + assert!(managed_setting("ra.enable") && managed_setting("Updates.EnableDatabases") && !managed_setting("PlayerLimit")); + } + #[test] fn the_main_configs_are_created_from_their_templates_and_never_overwritten() { let d = tempfile::tempdir().unwrap(); From dfd8a50975d94f477779357c2d095321bb093c02 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:08:54 +0200 Subject: [PATCH 2/7] fix: sharing with friends and the other screens work on a Docker server Second part of the audit of every screen for a Docker server. What it found, and what now happens: - Play with friends. "Servers open" read the BindIP of the configuration files, which the containers force to 0.0.0.0, so it was green on a fresh server while who can connect is decided by the address the ports are published on (bindAddress in docker.json). Opening and closing now edit bindAddress (and set CoA.AllowRemoteClients), the open state is read from it, and enabling a mode no longer fails on netsh / PowerShell after it already rewrote the files. There is no firewall row on Linux (Docker publishes the ports itself); the Windows reply is unchanged. - The "private services" check (Friends and Diagnostics) could never fail on Linux: it reads the Windows listener table. For a Docker server it is computed from how the ports are published: the database never, the console only on 127.0.0.1, the game servers on bindAddress. - Console, Database tab: read a mysql log file that does not exist; it shows `docker logs` of the database container. - The Overview showed a service that had stopped by itself as "Stopped"; it now says "Stopped unexpectedly". - Settings: a Docker server that was added but never started had no worldserver.conf and the page failed; it is created from its template. A talent file path outside the Core folder is refused (the container cannot see it). - Backups of configuration now include docker.json; verifying the files of an imported Docker server looked for worldserver.exe and reported it missing. - Linux locations: the friend package and the diagnostics go to the desktop folder (from the user directories file) instead of /tmp, the problem report names the distribution, and the size advice for companions reads /proc/meminfo instead of assuming 8 GB. Every Windows branch is the code that was there; the dispatch is the early return on docker::is_docker (or the host flavor for the firewall). --- crates/coa-core/src/backup.rs | 2 +- crates/coa-core/src/config/mod.rs | 8 ++ crates/coa-core/src/console.rs | 13 +++ crates/coa-core/src/diag.rs | 40 +++++++- crates/coa-core/src/docker/logs.rs | 72 +++++++++++++ crates/coa-core/src/docker/mod.rs | 23 +++++ crates/coa-core/src/docker/sharing.rs | 139 ++++++++++++++++++++++++++ crates/coa-core/src/friends.rs | 6 ++ crates/coa-core/src/population.rs | 20 +++- docs/ARCHITECTURE.md | 8 ++ src-tauri/src/lib.rs | 30 ++++-- src/i18n/locales/en.ts | 1 + src/lib/api.ts | 3 +- src/screens/FriendsPage.tsx | 2 +- src/screens/Overview.tsx | 7 +- 15 files changed, 360 insertions(+), 14 deletions(-) create mode 100644 crates/coa-core/src/docker/logs.rs create mode 100644 crates/coa-core/src/docker/sharing.rs diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 51e68e4..0f59c8a 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -212,7 +212,7 @@ pub fn config_files(root: &Path) -> Vec { for e in rd.flatten() { let n = e.file_name().to_string_lossy().to_string(); // database.json holds generated database passwords; it is launcher state, not configuration. - if e.path().is_file() && (n.contains(".template") || n == "repack.json") { + if e.path().is_file() && (n.contains(".template") || n == "repack.json" || n == "docker.json") { out.push(format!("Settings/{n}")); } } diff --git a/crates/coa-core/src/config/mod.rs b/crates/coa-core/src/config/mod.rs index f5c82a8..3cee550 100644 --- a/crates/coa-core/src/config/mod.rs +++ b/crates/coa-core/src/config/mod.rs @@ -206,6 +206,10 @@ pub fn drift(root: &Path) -> Result> { } pub fn load(root: &Path, scope: Scope) -> Result { + // A Docker server that was added but never started has no active world configuration yet; create it from its template. + if scope == Scope::Server && crate::docker::is_docker(root) { + let _ = crate::docker::ensure_main_configs(root); + } let schema = scope.schema(); let t = targets(root, scope)?; let conf = load_conf(&t.read)?; @@ -298,6 +302,10 @@ pub fn validate(root: &Path, scope: Scope, changes: &BTreeMap) -> } Some(s) => match s.to_raw(value) { Ok(raw) => { + if let Some(problem) = crate::docker::setting_problem(key, &raw).filter(|_| crate::docker::is_docker(root)) { + errors.push(FieldError { key: key.clone(), message: problem.into() }); + continue; + } raws.insert(key.clone(), raw); } Err(m) => errors.push(FieldError { key: key.clone(), message: m }), diff --git a/crates/coa-core/src/console.rs b/crates/coa-core/src/console.rs index 776244e..1cb356a 100644 --- a/crates/coa-core/src/console.rs +++ b/crates/coa-core/src/console.rs @@ -51,6 +51,19 @@ fn classify(l: &str) -> Level { } } +/// The last `max_lines` lines of some text (after filtering), classified and redacted like the lines of a log file. +pub fn lines_from_text(text: &str, filter: Option<&str>, max_lines: usize) -> Vec { + let needle = filter.map(str::to_lowercase).filter(|n| !n.is_empty()); + let out: Vec = text + .lines() + .filter(|l| !l.trim().is_empty()) + .filter(|l| needle.as_ref().map(|n| l.to_lowercase().contains(n)).unwrap_or(true)) + .map(|l| Line { level: classify(l), text: crate::diag::redact(l) }) + .collect(); + let skip = out.len().saturating_sub(max_lines); + out.into_iter().skip(skip).collect() +} + /// The last `max_lines` lines (after filtering) from the last ~512 KB of a log. Works on multi-gigabyte files. pub fn tail(path: &Path, filter: Option<&str>, max_lines: usize) -> Result> { const WINDOW: u64 = 512 * 1024; diff --git a/crates/coa-core/src/diag.rs b/crates/coa-core/src/diag.rs index a5ad4e8..73d9c4c 100644 --- a/crates/coa-core/src/diag.rs +++ b/crates/coa-core/src/diag.rs @@ -109,7 +109,8 @@ pub fn run(root: &Path, meta: &InstallMeta) -> Report { c.push(if crate::client::detect(Path::new(path), None).is_some() { check("client", "Game client", Level::Ok, path.clone()) } else { check("client", "Game client", Level::Warn, "The saved game folder was not found; choose it again in Settings.") }); } - let exposed: Vec<&str> = crate::net::exposure(&ports).iter().filter(|e| (e.what == "database" || e.what == "server console") && e.reachable_from_network).map(|e| e.what).collect(); + let exposure = if crate::docker::is_docker(root) { crate::docker::exposure(root, &ports) } else { crate::net::exposure(&ports) }; + let exposed: Vec<&str> = exposure.iter().filter(|e| (e.what == "database" || e.what == "server console") && e.reachable_from_network).map(|e| e.what).collect(); c.push(if exposed.is_empty() { check("exposure", "Private services", Level::Ok, "Database and server console are not reachable from the network.") } else { check("exposure", "Private services", Level::Fail, format!("Reachable from the network: {}.", exposed.join(", "))) }); let problems = c.iter().filter(|x| x.level != Level::Ok).count(); @@ -464,16 +465,53 @@ pub fn stamp() -> String { chrono::Local::now().format("%Y%m%d-%H%M%S").to_string() } +#[cfg(windows)] pub fn desktop_or_temp() -> PathBuf { let d = std::env::var_os("USERPROFILE").map(PathBuf::from).unwrap_or_else(std::env::temp_dir).join("Desktop"); if d.is_dir() { d } else { std::env::temp_dir() } } +/// Where a file meant for the person goes on Linux: the desktop folder they have (the XDG user directories file names it), else +/// `~/Desktop`, else `~/Downloads`, else the temporary folder. +#[cfg(not(windows))] +pub fn desktop_or_temp() -> PathBuf { + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { return std::env::temp_dir() }; + let dirs = std::fs::read_to_string(home.join(".config/user-dirs.dirs")).ok(); + pick_desktop(&home, dirs.as_deref(), &|p| p.is_dir()) +} + +#[cfg(not(windows))] +fn pick_desktop(home: &Path, user_dirs: Option<&str>, exists: &dyn Fn(&Path) -> bool) -> PathBuf { + let named = user_dirs.and_then(|t| { + t.lines().find_map(|l| { + let v = l.trim().strip_prefix("XDG_DESKTOP_DIR=")?.trim_matches('"'); + Some(PathBuf::from(v.replace("$HOME", &home.to_string_lossy()))) + }) + }); + // A user without a desktop sets XDG_DESKTOP_DIR to the home folder itself: that is not a place to drop files into. + [named, Some(home.join("Desktop")), Some(home.join("Downloads"))].into_iter().flatten().filter(|p| p != home).find(|p| exists(p)).unwrap_or_else(std::env::temp_dir) +} + #[cfg(test)] mod tests { use super::*; use crate::registry::{InstallKind, InstallMeta}; + #[cfg(not(windows))] + #[test] + fn the_desktop_folder_on_linux_follows_the_users_own_directories() { + let home = Path::new("/home/ana"); + let only = |wanted: &'static [&'static str]| move |p: &Path| wanted.contains(&p.to_str().unwrap()); + let dirs = "# comment\nXDG_DOWNLOAD_DIR=\"$HOME/Downloads\"\nXDG_DESKTOP_DIR=\"$HOME/Bureau\"\n"; + assert_eq!(pick_desktop(home, Some(dirs), &only(&["/home/ana/Bureau", "/home/ana/Desktop"])), PathBuf::from("/home/ana/Bureau")); + assert_eq!(pick_desktop(home, None, &only(&["/home/ana/Desktop"])), PathBuf::from("/home/ana/Desktop")); + assert_eq!(pick_desktop(home, Some(dirs), &only(&["/home/ana/Downloads"])), PathBuf::from("/home/ana/Downloads")); + assert_eq!(pick_desktop(home, None, &only(&[])), std::env::temp_dir()); + // No desktop: the user directories file names the home folder itself. + let none = "XDG_DESKTOP_DIR=\"$HOME/\"\n"; + assert_eq!(pick_desktop(home, Some(none), &only(&["/home/ana", "/home/ana/Downloads"])), PathBuf::from("/home/ana/Downloads")); + } + #[test] fn repeated_config_warnings_are_folded() { let t = "start diff --git a/crates/coa-core/src/docker/logs.rs b/crates/coa-core/src/docker/logs.rs new file mode 100644 index 0000000..19ca170 --- /dev/null +++ b/crates/coa-core/src/docker/logs.rs @@ -0,0 +1,72 @@ +//! The database of a Docker server has no log file in the server folder: MySQL writes to its container's output. + +use super::cli::{Call, Docker, SystemDocker}; +use super::Config; +use crate::console::Line; +use crate::error::{Error, Result}; +use std::path::Path; +use std::time::Duration; + +/// The last lines MySQL wrote (what `docker logs` returns), filtered and classified like the lines of a log file. +pub fn database_log(root: &Path, filter: Option<&str>, max_lines: usize) -> Result> { + database_log_with(&SystemDocker, root, filter, max_lines) +} + +pub fn database_log_with(d: &dyn Docker, root: &Path, filter: Option<&str>, max_lines: usize) -> Result> { + let name = Config::load(root)?.names().db; + // Ask for more than wanted: the filter may drop most of them. + let tail = (max_lines * 4).clamp(200, 8000).to_string(); + let out = d.run(&Call::new(&["logs", "--tail", &tail, &name], Duration::from_secs(20)))?; + if !out.ok() { + return Err(Error::Invalid("This log does not exist yet.".into())); + } + // MySQL writes to the error stream. + Ok(crate::console::lines_from_text(&format!("{}\n{}", out.stderr, out.stdout), filter, max_lines)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::docker::cli::Output; + use std::cell::RefCell; + + struct Fake { + out: Output, + calls: RefCell>>, + } + + impl Docker for Fake { + fn run(&self, call: &Call) -> Result { + self.calls.borrow_mut().push(call.args.clone()); + Ok(self.out.clone()) + } + } + + fn root() -> (tempfile::TempDir, std::path::PathBuf) { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + std::fs::create_dir_all(root.join("Settings")).unwrap(); + std::fs::write(root.join("Settings/docker.json"), r#"{"project":"t1"}"#).unwrap(); + (d, root) + } + + #[test] + fn the_database_log_is_what_the_container_printed() { + let (_d, root) = root(); + let fake = Fake { out: Output { code: Some(0), stdout: "2026-10-04T10:00:00Z [System] ready\n".into(), stderr: "2026-10-04T10:00:01Z [ERROR] [MY-010] boom\n2026-10-04T10:00:02Z [Warning] slow\n".into() }, calls: Default::default() }; + let lines = database_log_with(&fake, &root, None, 100).unwrap(); + assert_eq!(fake.calls.borrow()[0][..2], ["logs", "--tail"]); + assert_eq!(fake.calls.borrow()[0].last().unwrap(), "coa-t1-db"); + assert_eq!(lines.len(), 3); + assert!(lines.iter().any(|l| l.text.contains("boom") && l.level == crate::console::Level::Error)); + let only = database_log_with(&fake, &root, Some("slow"), 100).unwrap(); + assert_eq!(only.len(), 1); + } + + #[test] + fn a_database_that_never_ran_has_no_log_yet() { + let (_d, root) = root(); + let fake = Fake { out: Output { code: Some(1), stdout: String::new(), stderr: "Error: No such container: coa-t1-db".into() }, calls: Default::default() }; + assert!(database_log_with(&fake, &root, None, 10).unwrap_err().to_string().contains("does not exist yet")); + } +} diff --git a/crates/coa-core/src/docker/mod.rs b/crates/coa-core/src/docker/mod.rs index d668bf1..4edf3d4 100644 --- a/crates/coa-core/src/docker/mod.rs +++ b/crates/coa-core/src/docker/mod.rs @@ -18,9 +18,12 @@ mod cli; pub mod fixture; pub mod install; mod lifecycle; +pub mod logs; +pub mod sharing; pub use cli::{Call, Docker, Output, SystemDocker}; pub use lifecycle::{check_docker, observe, observe_with, run, run_with}; +pub use sharing::{bind_is_open, exposure, set_open}; pub(crate) use lifecycle::destroy; use std::net::IpAddr; @@ -186,6 +189,15 @@ pub(crate) fn env_name(key: &str) -> String { out } +/// A reason a value cannot work on a Docker server, for settings that name a path: the server runs in a container that sees only +/// its own `Core` folder (as `/srv/core`, its working directory), so a path elsewhere on this computer does not exist for it. +pub fn setting_problem(key: &str, raw: &str) -> Option<&'static str> { + let raw = raw.trim().trim_matches('"'); + let outside = Path::new(raw).is_absolute() && !raw.starts_with("/srv/core/"); + (key.eq_ignore_ascii_case("CoaBots.TalentBuildsPath") && !raw.is_empty() && outside) + .then_some("must be a path inside the server's Core folder (for example reference/ascensionsidekick-level-builds.json), because the server runs in a container that cannot see other folders") +} + /// Name of the runtime image: it follows the content of its Dockerfile, so changing the libraries builds a new image. pub(crate) fn runtime_image() -> String { format!("coa-runtime:{}", &fsx::sha256_bytes(RUNTIME_DOCKERFILE.as_bytes())[..12]) @@ -252,6 +264,17 @@ mod tests { use super::*; use std::fs; + #[cfg(unix)] + #[test] + fn a_path_outside_the_core_folder_is_refused_for_a_docker_server() { + let key = "CoaBots.TalentBuildsPath"; + assert!(setting_problem(key, "reference/builds.json").is_none()); + assert!(setting_problem(key, "").is_none()); + assert!(setting_problem(key, "\"/srv/core/reference/builds.json\"").is_none()); + assert!(setting_problem(key, "/home/ana/builds.json").is_some()); + assert!(setting_problem("Rate.XP.Kill", "/home/ana/x").is_none(), "only settings that name a path"); + } + #[test] fn setting_names_become_the_variables_the_core_reads() { for (key, var) in [ diff --git a/crates/coa-core/src/docker/sharing.rs b/crates/coa-core/src/docker/sharing.rs new file mode 100644 index 0000000..b233d4d --- /dev/null +++ b/crates/coa-core/src/docker/sharing.rs @@ -0,0 +1,139 @@ +//! Sharing a Docker server with friends. Who can reach the game is decided by the address the container ports are +//! published on (`bindAddress` in `Settings/docker.json`): `127.0.0.1` is this computer only, anything else is the network. +//! The `BindIP` of the configuration files is forced to `0.0.0.0` inside the containers and changes nothing, so the +//! repack's way of opening the servers (editing `BindIP`) is replaced here. The database is never published and the server +//! console is only ever published on the loopback address. + +use std::fs; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +use super::{Config, MARKER}; +use crate::config::parser::ConfFile; +use crate::config::{take_snapshot, Scope}; +use crate::error::Result; +use crate::fsx; +use crate::layout::Ports; +use crate::net::Exposure; +use crate::process::{ServiceState, ServiceStatus}; + +fn is_open(cfg: &Config) -> bool { + cfg.bind_address.parse::().map(|ip| !ip.is_loopback()).unwrap_or(false) +} + +/// Whether the login and world servers are published beyond this computer. +pub fn bind_is_open(root: &Path) -> bool { + Config::load(root).map(|c| is_open(&c)).unwrap_or(false) +} + +/// Publish the login and world servers beyond this computer (`0.0.0.0`) or only on it (`127.0.0.1`). An address the owner chose +/// by hand (for example one network card) is kept when opening. Returns true if anything changed, which needs a restart of +/// the containers. The old files are snapshotted. +pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result { + let mut cfg = Config::load(root)?; + let mut originals: Vec<(PathBuf, Vec)> = Vec::new(); + let mut changed = false; + if is_open(&cfg) != open { + originals.push((root.join(MARKER), fs::read(root.join(MARKER))?)); + cfg.bind_address = if open { "0.0.0.0" } else { "127.0.0.1" }.into(); + changed = true; + } + // Friends connect from other addresses, and the server only applies the CoA client protocol to loopback unless remote clients + // are allowed; opening switches that on (closing leaves it, it only matters while the servers listen beyond this computer). + let coa_conf = root.join("Core/configs/modules/coa.conf"); + let mut coa_edit = None; + if open { + if let Ok(bytes) = fs::read(&coa_conf) { + let mut conf = ConfFile::parse_bytes(&bytes)?; + if conf.get("CoA.AllowRemoteClients").map(str::trim) != Some("1") { + conf.set("CoA.AllowRemoteClients", "1", &["Set by CoA Server Manager together with the friends mode"]); + originals.push((coa_conf.clone(), bytes)); + coa_edit = Some(conf.to_text()); + } + } + } + if !changed && coa_edit.is_none() { + return Ok(false); + } + take_snapshot(meta, Scope::Server, if open { "before opening the servers to friends" } else { "before closing the servers to friends" }, &originals)?; + if changed { + fsx::atomic_write_json(&root.join(MARKER), &cfg)?; + } + if let Some(text) = coa_edit { + fsx::atomic_write(&coa_conf, text.as_bytes())?; + } + Ok(changed) +} + +/// What each service can be reached from, computed from how the containers publish their ports: the database is never published, +/// the console only on the loopback address, the game servers on `bindAddress`. +pub fn exposure(root: &Path, ports: &Ports) -> Vec { + let open = bind_is_open(root); + let observed = crate::process::observe(root, ports); + let up = |s: &ServiceStatus| matches!(s.state, ServiceState::Running | ServiceState::Starting); + vec![ + Exposure { port: ports.mysql, what: "database", listening: up(&observed.mysql), reachable_from_network: false }, + Exposure { port: ports.ra, what: "server console", listening: up(&observed.world), reachable_from_network: false }, + Exposure { port: ports.auth, what: "login server", listening: up(&observed.auth), reachable_from_network: open && up(&observed.auth) }, + Exposure { port: ports.world, what: "game world", listening: up(&observed.world), reachable_from_network: open && up(&observed.world) }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server(bind: Option<&str>) -> (tempfile::TempDir, PathBuf, PathBuf) { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + let meta = d.path().join("srv.manager"); + fs::create_dir_all(root.join("Settings")).unwrap(); + fs::create_dir_all(root.join("Core/configs/modules")).unwrap(); + fs::create_dir_all(&meta).unwrap(); + let extra = bind.map(|b| format!(r#","bindAddress":"{b}""#)).unwrap_or_default(); + fs::write(root.join(MARKER), format!(r#"{{"project":"t1"{extra}}}"#)).unwrap(); + fs::write(root.join("Core/configs/modules/coa.conf"), "CoA.Enable = 1\n").unwrap(); + (d, root, meta) + } + + #[test] + fn opening_and_closing_changes_the_published_address_and_allows_remote_clients() { + let (_d, root, meta) = server(None); + assert!(!bind_is_open(&root)); + assert!(set_open(&root, &meta, true).unwrap(), "a restart is needed"); + assert!(bind_is_open(&root)); + assert_eq!(Config::load(&root).unwrap().bind_address, "0.0.0.0"); + assert!(fs::read_to_string(root.join("Core/configs/modules/coa.conf")).unwrap().contains("CoA.AllowRemoteClients = 1")); + assert!(!set_open(&root, &meta, true).unwrap(), "nothing left to change"); + assert!(set_open(&root, &meta, false).unwrap()); + assert!(!bind_is_open(&root)); + assert_eq!(Config::load(&root).unwrap().bind_address, "127.0.0.1"); + } + + #[test] + fn an_address_chosen_by_hand_is_kept_when_opening_and_other_settings_survive() { + let (_d, root, meta) = server(Some("192.168.1.20")); + assert!(bind_is_open(&root), "any address but the loopback is the network"); + set_open(&root, &meta, true).unwrap(); + let cfg = Config::load(&root).unwrap(); + assert_eq!(cfg.bind_address, "192.168.1.20"); + assert_eq!(cfg.project, "t1"); + } + + #[test] + fn the_old_settings_are_snapshotted_before_a_change() { + let (_d, root, meta) = server(None); + set_open(&root, &meta, true).unwrap(); + assert_eq!(crate::config::list_snapshots(&meta).len(), 1); + } + + #[test] + fn the_database_and_the_console_are_never_reported_reachable() { + let (_d, root, _meta) = server(Some("0.0.0.0")); + let rows = exposure(&root, &Ports::default()); + let by = |what: &str| rows.iter().find(|e| e.what == what).unwrap(); + assert!(!by("database").reachable_from_network && !by("server console").reachable_from_network); + // Nothing runs in this test (docker is not asked to start anything), so nothing is reachable yet. + assert!(rows.iter().all(|e| !e.reachable_from_network)); + } +} diff --git a/crates/coa-core/src/friends.rs b/crates/coa-core/src/friends.rs index 9b8a71a..6b502e7 100644 --- a/crates/coa-core/src/friends.rs +++ b/crates/coa-core/src/friends.rs @@ -80,6 +80,9 @@ fn bind_targets(root: &Path) -> Vec { /// Whether the configuration currently lets other computers reach the login and world servers. pub fn bind_is_open(root: &Path) -> bool { + if crate::docker::is_docker(root) { + return crate::docker::bind_is_open(root); + } let files = bind_targets(root); !files.is_empty() && files @@ -90,6 +93,9 @@ pub fn bind_is_open(root: &Path) -> bool { /// Open (`0.0.0.0`) or close (`127.0.0.1`) the login and world servers to other computers. The database and the server /// console are never touched. Returns true if anything changed (a restart is then needed). The old files are snapshotted. pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result { + if crate::docker::is_docker(root) { + return crate::docker::set_open(root, meta, open); + } let want = if open { "\"0.0.0.0\"" } else { "\"127.0.0.1\"" }; let mut originals = Vec::new(); let mut edits = Vec::new(); diff --git a/crates/coa-core/src/population.rs b/crates/coa-core/src/population.rs index aca46da..38ba8b3 100644 --- a/crates/coa-core/src/population.rs +++ b/crates/coa-core/src/population.rs @@ -71,7 +71,15 @@ pub fn hardware() -> Hardware { #[cfg(not(windows))] pub fn hardware() -> Hardware { - Hardware { cores: std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(2), ram_gb: 8.0, free_ram_gb: 4.0 } + let (total, free) = std::fs::read_to_string("/proc/meminfo").ok().and_then(|t| parse_meminfo(&t)).unwrap_or((8.0, 4.0)); + Hardware { cores: std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(2), ram_gb: total, free_ram_gb: free } +} + +/// (total, available) memory in GB from the text of `/proc/meminfo`. +#[cfg(not(windows))] +fn parse_meminfo(text: &str) -> Option<(f64, f64)> { + let kb = |key: &str| -> Option { text.lines().find_map(|l| l.strip_prefix(key)?.trim().strip_suffix("kB")?.trim().parse::().ok()) }; + Some((kb("MemTotal:")? / (1u64 << 20) as f64, kb("MemAvailable:")? / (1u64 << 20) as f64)) } #[derive(Debug, Clone, Serialize)] @@ -105,6 +113,16 @@ pub fn sizes(h: &Hardware) -> Vec { mod tests { use super::*; + #[cfg(not(windows))] + #[test] + fn memory_is_read_from_proc_meminfo() { + let t = "MemTotal: 65536000 kB\nMemFree: 1000000 kB\nMemAvailable: 16777216 kB\nBuffers: 1 kB\n"; + let (total, free) = parse_meminfo(t).unwrap(); + assert!((total - 62.5).abs() < 0.01 && (free - 16.0).abs() < 0.01, "{total} {free}"); + assert!(parse_meminfo("nothing useful").is_none()); + assert!(hardware().ram_gb > 0.0); + } + #[test] fn parses_counts_and_never_goes_negative() { let p = parse_counts("12\t9\t400").unwrap(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1de9358..a056b2d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -422,6 +422,14 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop, DivxTac.dll deadlocks the world loading screen under wine-mono. `COA_PROTONPATH`, `COA_WINEPREFIX` or `client-launch.json` in the Manager's folder (`proton_path`, `prefix`, `runner`) override the detection. Whether the client runs is read from `/proc` (under Wine the command line holds the Windows form of the path). Output goes to `client.log`. +* **Sharing with friends** (`docker/sharing.rs`): who can reach the game is decided by the address the container ports are + published on, `bindAddress` in `docker.json`; the `BindIP` of the configuration files is forced to `0.0.0.0` inside the + containers and changes nothing. Opening and closing therefore edit `bindAddress` (and set `CoA.AllowRemoteClients`), "servers + open" and the exposure check are computed from it (the database is never published, the console only on `127.0.0.1`), and + there is no firewall row on Linux. Settings the backend fixes (`docker::FORCED_KEYS`) are not offered; a test keeps that list + equal to the variables the containers get. A world that restarts itself (exit code 2) is restarted by Docker once it has come + up. The database console shows `docker logs`, and the file-based screens read Linux locations (the desktop folder from the + user directories file, memory from `/proc/meminfo`, the distribution name for the problem report). * **Not done yet**: downloading the game data, updates, port conflict detection, the firewall and exposure checks, the Wildcard realm profiles, and the client under Wine/Proton. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7dc9d80..8138327 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -156,7 +156,9 @@ async fn add_server(state: State<'_, AppState>, path: String) -> std::result::Re meta.core.version = report.banner_revision.clone(); meta.database.port = Some(report.ports.mysql); meta.database.schemas = report.database_schemas.clone(); - for (rel, exe) in [("Core/worldserver.exe", &report.worldserver), ("Core/authserver.exe", &report.authserver)] { + // The executables are `worldserver.exe` in a repack and `worldserver` in a Docker server; the file check reads these names. + let exe_names = if coa_core::docker::is_docker(std::path::Path::new(&report.path)) { ("Core/worldserver", "Core/authserver") } else { ("Core/worldserver.exe", "Core/authserver.exe") }; + for (rel, exe) in [(exe_names.0, &report.worldserver), (exe_names.1, &report.authserver)] { if let Some(e) = exe { meta.original_hashes.insert(rel.into(), e.sha256.clone()); } @@ -1374,7 +1376,7 @@ struct FriendsStatus { exposure: Vec, /// The configuration lets other computers reach the login and world servers. servers_open: bool, - firewall: coa_core::firewall::Status, + firewall: Option, tailscale: coa_core::net::Tailscale, server_running: bool, auth_port: u16, @@ -1419,7 +1421,18 @@ fn report_targets() -> Vec { coa_core::report::targets() } +/// The distribution on Linux ("Linux (Arch Linux)"), from `/etc/os-release`; empty if it cannot be read. +#[cfg(not(windows))] +fn windows_version() -> String { + std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|t| t.lines().find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))) + .map(|name| format!("Linux ({name})")) + .unwrap_or_default() +} + /// "Windows 11 (build 26200)" from `ver`; empty if it cannot be read. +#[cfg(windows)] fn windows_version() -> String { let mut cmd = std::process::Command::new("cmd.exe"); cmd.args(["/C", "ver"]); @@ -1468,9 +1481,10 @@ async fn friends_status(state: State<'_, AppState>, id: String) -> std::result:: settings: coa_core::friends::load(&meta), lan_ip: automatic.map(|a| a.to_string()), lan_addresses: coa_core::net::lan_addresses(automatic).unwrap_or_default(), - exposure: coa_core::net::exposure(&ports), + exposure: if coa_core::docker::is_docker(&root) { coa_core::docker::exposure(&root, &ports) } else { coa_core::net::exposure(&ports) }, servers_open: coa_core::friends::bind_is_open(&root), - firewall: coa_core::firewall::status(), + // The Windows firewall exists only on Windows; on Linux Docker publishes the ports itself. + firewall: (platform::flavor() == Flavor::Repack).then(coa_core::firewall::status), tailscale: coa_core::net::tailscale(), server_running: coa_core::process::observe(&root, &ports).world.state == coa_core::process::ServiceState::Running, auth_port: ports.auth, @@ -1540,7 +1554,7 @@ async fn friends_enable( }; let open = mode != Mode::Local; let changed = friends::set_open(&root, &meta, open)?; - if open { + if open && platform::flavor() == Flavor::Repack { coa_core::firewall::ensure_rules_with_secondary(&ports, secondary)?; } if mode == Mode::Direct && use_upnp { @@ -1573,7 +1587,7 @@ async fn friends_package(state: State<'_, AppState>, id: String) -> std::result: blocking(move || { let meta = meta_dir(&root)?; let host = coa_core::friends::load(&meta).host.ok_or_else(|| Error::Invalid("Choose how friends connect first.".into()))?; - let desktop = std::env::var_os("USERPROFILE").map(PathBuf::from).unwrap_or_else(std::env::temp_dir).join("Desktop"); + let desktop = if platform::flavor() == Flavor::Docker { coa_core::diag::desktop_or_temp() } else { std::env::var_os("USERPROFILE").map(PathBuf::from).unwrap_or_else(std::env::temp_dir).join("Desktop") }; let out = if desktop.is_dir() { desktop } else { std::env::temp_dir() }.join("CoA-Friend-Setup.zip"); coa_core::friends::make_friend_package(&root, &host, true, &out)?; Ok(out.to_string_lossy().into_owned()) @@ -1634,6 +1648,10 @@ async fn console_tail( ) -> std::result::Result, UiError> { let root = path_of(&state, &id)?; blocking(move || { + // A Docker server's database writes to its container's output, not to a file. + if coa_core::docker::is_docker(&root) && matches!(source, coa_core::console::Source::Database) { + return coa_core::docker::logs::database_log(&root, filter.as_deref(), lines.unwrap_or(300).min(2000)); + } let path = coa_core::console::log_path(&root, &data_dir().join("logs").join("manager.log"), source); coa_core::console::tail(&path, filter.as_deref(), lines.unwrap_or(300).min(2000)) }) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6d42718..21910e5 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -101,6 +101,7 @@ const en = { "status.attention": "Needs attention", "status.running": "Running", "status.stopped": "Stopped", + "status.crashed": "Stopped unexpectedly", "btn.start": "START SERVER", "btn.stop": "STOP SERVER", diff --git a/src/lib/api.ts b/src/lib/api.ts index e3dcb13..dc0c173 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -420,7 +420,8 @@ export interface FriendsStatus { lan_addresses: LanAddress[]; exposure: { port: number; what: string; reachable_from_network: boolean; listening: boolean }[]; servers_open: boolean; - firewall: { auth: boolean; world: boolean }; + /** null where there is no Windows firewall to ask about (Linux). */ + firewall: { auth: boolean; world: boolean } | null; tailscale: { installed: boolean; ip: string | null; connected: boolean }; server_running: boolean; auth_port: number; diff --git a/src/screens/FriendsPage.tsx b/src/screens/FriendsPage.tsx index a7d3059..6371e76 100644 --- a/src/screens/FriendsPage.tsx +++ b/src/screens/FriendsPage.tsx @@ -170,7 +170,7 @@ export function FriendsPage({ serverId }: { serverId: string }) {
    {t("fr.st.running")} {t("fr.st.open")} - {t("fr.st.firewall")} + {st.firewall && {t("fr.st.firewall")}} e.what === "database" || e.what === "server console").every((e) => !e.reachable_from_network)}> {t("fr.st.private")} diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index 58175c7..b0f05e1 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -46,21 +46,22 @@ function Row({ label, s }: { label: string; s: ServiceStatus }) { const t = useT(); const running = s.state === "running"; const starting = s.state === "starting"; + const crashed = s.state === "crashed"; return (
    {label} - {running ? t("status.running") : starting ? t("status.starting") : t("status.stopped")} + {running ? t("status.running") : starting ? t("status.starting") : crashed ? t("status.crashed") : t("status.stopped")}
    ); From 0250b61c621d0fd13f969ef0e71a6e404c39019b Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:40:03 +0200 Subject: [PATCH 3/7] feat: a wrong game data folder is diagnosed, and it can be changed from Settings The world refusing to start with the standard Data set (no CoA DBC) is now reported as GameDataMismatch. A Settings card shows the folder in use and changes it while the server is stopped, using the install checks. --- crates/coa-core/src/docker/install.rs | 2 +- crates/coa-core/src/docker/mod.rs | 45 +++++++++++++++++++++++++++ crates/coa-core/src/error.rs | 6 ++++ crates/coa-core/src/health.rs | 9 ++++++ docs/ARCHITECTURE.md | 4 +++ src-tauri/src/lib.rs | 27 ++++++++++++++++ src/dev/mock.ts | 2 ++ src/i18n/locales/en.ts | 7 +++++ src/lib/api.ts | 3 ++ src/screens/GameDataCard.tsx | 44 ++++++++++++++++++++++++++ src/screens/SettingsHome.tsx | 3 ++ 11 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 src/screens/GameDataCard.tsx diff --git a/crates/coa-core/src/docker/install.rs b/crates/coa-core/src/docker/install.rs index e427c18..48ef998 100644 --- a/crates/coa-core/src/docker/install.rs +++ b/crates/coa-core/src/docker/install.rs @@ -112,7 +112,7 @@ pub fn preflight(dest: &Path, data_dir: &Path, needed_bytes: u64, registry: &Reg Preflight { ok: problems.is_empty(), problems, free_bytes } } -fn data_problems(data_dir: &Path) -> Vec { +pub(crate) fn data_problems(data_dir: &Path) -> Vec { let s = data_dir.to_string_lossy(); if !data_dir.is_absolute() || s.contains(':') { return vec![problem("data_path", "Choose the game data folder with its full path, without a colon in it.")]; diff --git a/crates/coa-core/src/docker/mod.rs b/crates/coa-core/src/docker/mod.rs index 4edf3d4..0ad30e2 100644 --- a/crates/coa-core/src/docker/mod.rs +++ b/crates/coa-core/src/docker/mod.rs @@ -189,6 +189,29 @@ pub(crate) fn env_name(key: &str) -> String { out } +/// The folder mounted as the game data of this Docker server. +pub fn game_data_dir(root: &Path) -> Result { + Ok(Config::load(root)?.data_path(root)) +} + +/// Point a Docker server at another game data folder (used in place, read-only). The containers are recreated at the next +/// start, so the servers must be stopped: a running one keeps the folder it has. +pub fn set_game_data(root: &Path, path: &Path) -> Result<()> { + let problems = install::data_problems(path); + if !problems.is_empty() { + return Err(Error::Invalid(problems.iter().map(|p| p.message.clone()).collect::>().join(" "))); + } + let observed = crate::process::observe(root, &crate::layout::read_ports(root)); + let busy = |s: &crate::process::ServiceStatus| matches!(s.state, crate::process::ServiceState::Running | crate::process::ServiceState::Starting | crate::process::ServiceState::Stopping); + if busy(&observed.world) || busy(&observed.auth) { + return Err(Error::Invalid("Stop the server before changing the game data folder.".into())); + } + let mut cfg = Config::load(root)?; + cfg.data_dir = Some(path.to_string_lossy().into_owned()); + cfg.validate()?; + fsx::atomic_write_json(&root.join(MARKER), &cfg) +} + /// A reason a value cannot work on a Docker server, for settings that name a path: the server runs in a container that sees only /// its own `Core` folder (as `/srv/core`, its working directory), so a path elsewhere on this computer does not exist for it. pub fn setting_problem(key: &str, raw: &str) -> Option<&'static str> { @@ -264,6 +287,28 @@ mod tests { use super::*; use std::fs; + #[cfg(unix)] + #[test] + fn the_game_data_folder_can_be_changed_when_the_servers_are_stopped() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + std::fs::create_dir_all(root.join("Settings")).unwrap(); + std::fs::write(root.join(MARKER), r#"{"project":"t1","dataDir":"/old/data"}"#).unwrap(); + let good = d.path().join("data"); + std::fs::create_dir_all(good.join("dbc")).unwrap(); + std::fs::create_dir_all(good.join("maps")).unwrap(); + assert_eq!(game_data_dir(&root).unwrap(), std::path::PathBuf::from("/old/data")); + set_game_data(&root, &good).unwrap(); + assert_eq!(game_data_dir(&root).unwrap(), good); + assert_eq!(Config::load(&root).unwrap().project, "t1", "the other settings are kept"); + // A folder that does not look like game data is refused and nothing changes. + let empty = d.path().join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + assert!(set_game_data(&root, &empty).is_err()); + assert_eq!(game_data_dir(&root).unwrap(), good); + assert!(set_game_data(&root, std::path::Path::new("relative/dir")).is_err()); + } + #[cfg(unix)] #[test] fn a_path_outside_the_core_folder_is_refused_for_a_docker_server() { diff --git a/crates/coa-core/src/error.rs b/crates/coa-core/src/error.rs index f2b236c..9ccabff 100644 --- a/crates/coa-core/src/error.rs +++ b/crates/coa-core/src/error.rs @@ -55,6 +55,7 @@ pub enum ErrorCode { OperationInProgress, StartupFailed, DockerUnavailable, + GameDataMismatch, HashMismatch, PathRejected, PackageNotPublished, @@ -138,6 +139,11 @@ impl ErrorCode { "This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.", &[Retry, ShowDetails], ), + ErrorCode::GameDataMismatch => ( + "The game data folder is not the CoA set", + "The server stopped because the DBC files in the game data folder are not the ones of the Conquest of Azeroth client. Choose the folder that holds the CoA data (Settings > Game data folder), then start again.", + &[ShowDetails], + ), ErrorCode::HashMismatch => ( "A downloaded file is damaged", "The file did not match its checksum and was not used.", diff --git a/crates/coa-core/src/health.rs b/crates/coa-core/src/health.rs index 65f608c..312e666 100644 --- a/crates/coa-core/src/health.rs +++ b/crates/coa-core/src/health.rs @@ -25,6 +25,8 @@ pub fn diagnose(text: &str) -> Option { Some(ErrorCode::DatabaseNotRunning) } else if l.contains("address already in use") || l.contains("could not bind") || l.contains("only one usage of each socket") { Some(ErrorCode::PortInUse) + } else if l.contains("does not hold the coa client dbc set") || l.contains("_outdated_ dbc data") { + Some(ErrorCode::GameDataMismatch) } else if l.contains("config::loadfile") && l.contains("failed open file") && l.contains("worldserver.conf") { Some(ErrorCode::ServerFilesIncomplete) } else { @@ -57,6 +59,13 @@ pub fn diagnose_installation(root: &Path) -> Option { mod tests { use super::*; + #[test] + fn a_game_data_folder_without_the_coa_dbc_set_is_named_as_such() { + let log = "Using DataDir /srv/data/\nSpellAffect.dbc not found in /srv/data/dbc/\nDataDir does not hold the CoA client DBC set. Install it with apps/coa-dbc/client_dbc.py."; + assert_eq!(diagnose(log), Some(ErrorCode::GameDataMismatch)); + assert_eq!(diagnose("You have _outdated_ DBC data. Please extract correct versions from current using client."), Some(ErrorCode::GameDataMismatch)); + } + #[test] fn recognises_missing_migrations() { let log = "ERROR [sql.sql] [1146] Table 'acore_world.coa_boss' doesn't exist\nFATAL [sql.sql] Your database structure is not up to date."; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a056b2d..476fd30 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -430,6 +430,10 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop, equal to the variables the containers get. A world that restarts itself (exit code 2) is restarted by Docker once it has come up. The database console shows `docker logs`, and the file-based screens read Linux locations (the desktop folder from the user directories file, memory from `/proc/meminfo`, the distribution name for the problem report). +* **Game data folder** (`docker::game_data_dir` / `set_game_data`, Settings card): `dataDir` in `docker.json` can be changed + after the install, only while the world and auth are stopped, and only to a folder that passes the install checks. A world + that stops with "does not hold the CoA client dbc set" is reported as `GameDataMismatch` (wrong data set: the standard one, + not Conquest of Azeroth's) instead of a bare failure. * **Not done yet**: downloading the game data, updates, port conflict detection, the firewall and exposure checks, the Wildcard realm profiles, and the client under Wine/Proton. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8138327..134d6e6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -110,6 +110,31 @@ fn summary(id: String, path: PathBuf) -> ServerSummary { ServerSummary { id, name, path: path.to_string_lossy().into_owned() } } +/// The game data folder of a Docker server; none for a repack, whose data is inside its own folder. +#[tauri::command] +fn game_data_folder(state: State<'_, AppState>, id: String) -> std::result::Result, UiError> { + let root = path_of(&state, &id)?; + if !coa_core::docker::is_docker(&root) { + return Ok(None); + } + Ok(Some(coa_core::docker::game_data_dir(&root)?.to_string_lossy().into_owned())) +} + +/// Point a Docker server at another game data folder. The servers must be stopped. +#[tauri::command] +async fn set_game_data_folder(state: State<'_, AppState>, id: String, path: String) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || { + if !coa_core::docker::is_docker(&root) { + return Err(Error::Invalid("The game data of this server is inside its folder.".into())); + } + coa_core::docker::set_game_data(&root, std::path::Path::new(&path))?; + Ok(path) + }) + .await +} + #[tauri::command] fn default_install_dir() -> String { if platform::flavor() == Flavor::Docker { @@ -1688,6 +1713,8 @@ pub fn run() { .manage(AppState { registry: Registry::at(dir.join("installs.json")), busy: Mutex::new(HashSet::new()), install_cancel: Mutex::new(None), client_cancel: Mutex::new(None) }) .invoke_handler(tauri::generate_handler![ default_install_dir, + game_data_folder, + set_game_data_folder, install_environment, scan_server, add_server, diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 122ad26..89b6b31 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -92,6 +92,8 @@ export {}; if(cmd==="open_link"){(window as any).__lastLink=args.url;return null;} if(cmd==="export_diagnostics") return "C:\\Users\\you\\Desktop\\CoA-Diagnostics-20261001-140000.zip"; if(cmd==="list_crashes") return []; + if(cmd==="game_data_folder") return new URLSearchParams(location.search).get("flavor")==="docker"?"/srv/coa/server-data":null; + if(cmd==="set_game_data_folder") return args.path; if(cmd==="install_environment"){const docker=new URLSearchParams(location.search).get("flavor")==="docker";return docker?{flavor:"docker",default_dir:"/home/you/CoaServer",docker_problem:new URLSearchParams(location.search).has("nodocker")?"permission denied while trying to connect to the Docker daemon socket":null}:{flavor:"repack",default_dir:"C:\\Games\\CoA Server",docker_problem:null};} if(cmd==="install_preflight") return {ok:true,problems:[],free_bytes:210*2**30}; if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.2.0"}; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 21910e5..90c80fa 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -144,6 +144,8 @@ const en = { "err.operation_in_progress.message": "The server is busy starting or stopping. Wait a moment and try again.", "err.startup_failed.title": "Server could not start", "err.startup_failed.message": "A service stopped while starting. Check the details for the cause.", + "err.game_data_mismatch.title": "The game data folder is not the CoA set", + "err.game_data_mismatch.message": "The server stopped because the DBC files in the game data folder are not the ones of the Conquest of Azeroth client. Choose the folder that holds the CoA data (Settings > Game data folder), then start again.", "err.docker_unavailable.title": "Docker is not available", "err.docker_unavailable.message": "This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.", "err.hash_mismatch.title": "A downloaded file is damaged", @@ -191,6 +193,11 @@ const en = { "install.localPkg": "Install from a local package folder instead of downloading", "install.localPkgHint": "(leave empty to download)", "install.button": "Install", + "gamedata.title": "Game data folder", + "gamedata.text": "The folder with the game's dbc, maps, vmaps and mmaps that this server reads. It is used where it is and never changed. It must be the Conquest of Azeroth set, not the standard one. Stop the server before changing it.", + "gamedata.change": "Change…", + "gamedata.dialog": "Choose the game data folder", + "gamedata.saved": "Saved. It is used the next time the server starts.", "install.docker.title": "Docker is needed", "install.docker.text": "On Linux the server runs in Docker. Install Docker and start it, or allow your user to use it, then check again.", "install.docker.retry": "Check again", diff --git a/src/lib/api.ts b/src/lib/api.ts index dc0c173..3c3c153 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -498,6 +498,9 @@ export const api = { restoreConfigs: (id: string, backupId: string) => invoke("restore_backup_configs", { id, backupId }), restoreDatabase: (id: string, backupId: string, database: string) => invoke("restore_backup_database", { id, backupId, database }), installEnvironment: () => invoke("install_environment"), + /** The game data folder of a Docker server; null for a repack. */ + gameDataFolder: (id: string) => invoke("game_data_folder", { id }), + setGameDataFolder: (id: string, path: string) => invoke("set_game_data_folder", { id, path }), installPreflight: (dest: string, needed?: number, gameData?: string) => invoke("install_preflight", { dest, needed: needed ?? null, gameData: gameData ?? null }), installRequirements: (pkg?: string) => invoke("install_requirements", { package: pkg ?? null }), realmlistProfiles: (id: string) => invoke<{ profiles: RealmProfile[]; active: string | null }>("realmlist_profiles", { id }), diff --git a/src/screens/GameDataCard.tsx b/src/screens/GameDataCard.tsx new file mode 100644 index 0000000..1f3d22d --- /dev/null +++ b/src/screens/GameDataCard.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { api, asUiError } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { useT } from "@/i18n"; + +/** Where a Docker server reads the game data from. Not shown for a repack, which keeps its data in its own folder. */ +export function GameDataCard({ serverId }: { serverId: string }) { + const t = useT(); + const [folder, setFolder] = useState(null); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + + useEffect(() => { + let alive = true; + void api.gameDataFolder(serverId).then((f) => alive && setFolder(f)).catch(() => alive && setFolder(null)); + return () => { alive = false; }; + }, [serverId]); + + if (folder === null) return null; + + async function change() { + const picked = await open({ directory: true, multiple: false, title: t("gamedata.dialog"), defaultPath: folder ?? undefined }); + if (typeof picked !== "string") return; + try { + setFolder(await api.setGameDataFolder(serverId, picked)); + setMessage({ ok: true, text: t("gamedata.saved") }); + } catch (e) { + setMessage({ ok: false, text: asUiError(e).technical }); + } + } + + return ( + +

    {t("gamedata.title")}

    +

    {t("gamedata.text")}

    +
    + {folder} + +
    + {message &&

    {message.text}

    } +
    + ); +} diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx index e3e999e..525cdd9 100644 --- a/src/screens/SettingsHome.tsx +++ b/src/screens/SettingsHome.tsx @@ -7,6 +7,7 @@ import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { ClientCard } from "@/screens/ClientCard"; +import { GameDataCard } from "@/screens/GameDataCard"; import { DiagnosticsCard } from "@/screens/DiagnosticsCard"; import { AboutCard } from "@/screens/AboutCard"; import { RemoveServerCard } from "@/screens/RemoveServerCard"; @@ -174,6 +175,8 @@ export function SettingsHome({ serverId, path, onForget }: { serverId: string; p + + From 3fb4ee39c681d24454edbf7c7cca31d53ac7e050 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:02:59 +0200 Subject: [PATCH 4/7] fix: the realm is not shown offline after a start on Docker The auth server marks every realm offline when it starts, after the world server cleared its own flag. Mark the active realm online once the auth server listens. --- crates/coa-core/src/docker/lifecycle.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index 6620c21..6fff3c7 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -255,9 +255,26 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L run_container(d, &call, &n.auth, log)?; } wait_listening(d, cfg, &n.auth, ports.auth, AUTH_WAIT, log)?; + mark_realm_online(d, &n, &secrets, realm, log); Ok(()) } +/// The auth server marks every realm offline when it starts, and the world server (already up, see the order above) only +/// puts itself back after its next uptime update, ten minutes later by default: until then the client shows "Realm is +/// Offline". The world is known to be listening here, so say so for the realm it serves. The other realm of a Wildcard +/// install keeps the offline flag it is given on purpose (`realms::setup_realmlist`). +fn mark_realm_online(d: &dyn Docker, n: &Names, secrets: &Secrets, realm: crate::realms::Mode, log: &mut Log) { + let sql = format!("UPDATE acore_auth.realmlist SET flag = flag & ~2 WHERE id = {};", realm.realm_id()); + let mut call = Call::new(&["exec", "-i", "-e", "MYSQL_PWD", &n.db, "mysql", "--user=root", "--protocol=tcp", "--host=127.0.0.1"], QUICK); + call.env = vec![("MYSQL_PWD".into(), secrets.root.clone())]; + call.stdin = Some(sql.as_bytes()); + match d.run(&call) { + Ok(o) if o.ok() => {} + Ok(o) => log.say(format!("the realm could not be marked online: {}", o.text().trim())), + Err(e) => log.say(format!("the realm could not be marked online: {e}")), + } +} + fn remove(d: &dyn Docker, name: &str) { // A stopped container of the same name is replaced, so its settings always match the files on disk. let _ = d.run(&Call::new(&["rm", "-f", name], QUICK)); @@ -690,6 +707,7 @@ mod tests { out(0, "containerid\n", "") } ["update", "--restart", _, _] => out(0, "", ""), + ["exec", ..] => out(0, "", ""), ["stop", "--time", _, name] => { self.running.borrow_mut().remove(*name); self.exited.borrow_mut().insert(name.to_string(), (0, false)); @@ -732,6 +750,10 @@ mod tests { assert!(at("network create") < at("run --detach"), "database comes first"); let runs: Vec = sim.calls_of("run").iter().map(|c| c.args[c.args.iter().position(|x| x == "--name").unwrap() + 1].clone()).collect(); assert_eq!(runs, ["coa-t1-db", "coa-t1-world", "coa-t1-auth"], "database, then world, then auth"); + let exec = sim.calls_of("exec"); + assert_eq!(exec.len(), 1, "the realm is marked online once the auth server is up"); + assert!(exec[0].stdin && exec[0].env == [("MYSQL_PWD".to_string(), ROOT_PW.to_string())]); + assert!(!exec[0].args.iter().any(|a| a.contains(ROOT_PW)), "the password is not on the command line"); assert!(at("build") < verbs.iter().rposition(|v| v.starts_with("run")).unwrap(), "the runtime image exists before the game servers"); assert!(sim.calls_of("build")[0].stdin, "the Dockerfile travels on stdin"); } From 36bfc6d31e2b7e165e076e88f251a463318a6485 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:10:53 +0200 Subject: [PATCH 5/7] docs: changelog entry for Docker server parity --- changelog.d/20261005-linux-docker-server-settings.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/20261005-linux-docker-server-settings.md diff --git a/changelog.d/20261005-linux-docker-server-settings.md b/changelog.d/20261005-linux-docker-server-settings.md new file mode 100644 index 0000000..fa6ba2b --- /dev/null +++ b/changelog.d/20261005-linux-docker-server-settings.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Linux Docker servers follow Settings, sharing and backups (experimental) +--- +Experimental. Settings the Docker backend fixes are hidden, friends sharing and logs work, the game data folder can be changed, and the realm no longer shows offline after a start. From 4d8ca00e46ca05c444dc73a914dfe439ea6a7f1e Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:31:30 +0200 Subject: [PATCH 6/7] test: a Docker config built by a test has no database data directory --- crates/coa-core/src/docker/lifecycle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index 6fff3c7..1bd135c 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -412,7 +412,7 @@ fn database_env(s: &Secrets, world: bool, realm: crate::realms::Mode) -> Vec<(St /// Every setting variable (`AC_...`) the containers get, for the check that the screens know which settings are fixed. #[cfg(test)] pub(crate) fn forced_variables() -> Vec { - let cfg = Config { project: "t".into(), bind_address: "127.0.0.1".into(), mysql_image: "mysql:8.4".into(), data_dir: None }; + let cfg = Config { project: "t".into(), bind_address: "127.0.0.1".into(), mysql_image: "mysql:8.4".into(), data_dir: None, mysql_data: None }; let n = cfg.names(); let mut found = Vec::new(); for kind in [GameKind::World, GameKind::Auth] { From ca2a555d7cdeb3f77386c3c62c9aab3b2d7df303 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: Tue, 13 Oct 2026 06:41:16 +0500 Subject: [PATCH 7/7] fix(docker): request restart when remote client protocol changes --- changelog.d/20261013-docker-remote-client-restart.md | 7 +++++++ crates/coa-core/src/docker/sharing.rs | 12 +++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20261013-docker-remote-client-restart.md diff --git a/changelog.d/20261013-docker-remote-client-restart.md b/changelog.d/20261013-docker-remote-client-restart.md new file mode 100644 index 0000000..01bafc6 --- /dev/null +++ b/changelog.d/20261013-docker-remote-client-restart.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Docker friend mode requests a restart when remote client support changes +--- +Enabling remote CoA clients now requests a world restart even when the game ports were already open. Repeating the same unchanged setting does not request another restart. diff --git a/crates/coa-core/src/docker/sharing.rs b/crates/coa-core/src/docker/sharing.rs index b233d4d..1c0d120 100644 --- a/crates/coa-core/src/docker/sharing.rs +++ b/crates/coa-core/src/docker/sharing.rs @@ -59,10 +59,11 @@ pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result { if changed { fsx::atomic_write_json(&root.join(MARKER), &cfg)?; } + let remote_changed = coa_edit.is_some(); if let Some(text) = coa_edit { fsx::atomic_write(&coa_conf, text.as_bytes())?; } - Ok(changed) + Ok(changed || remote_changed) } /// What each service can be reached from, computed from how the containers publish their ports: the database is never published, @@ -110,6 +111,15 @@ mod tests { assert_eq!(Config::load(&root).unwrap().bind_address, "127.0.0.1"); } + #[test] + fn enabling_remote_client_protocol_requires_restart_even_when_ports_are_open() { + let (_d, root, meta) = server(Some("0.0.0.0")); + fs::write(root.join("Core/configs/modules/coa.conf"), "CoA.Enable = 1\nCoA.AllowRemoteClients = 0\n").unwrap(); + assert!(set_open(&root, &meta, true).unwrap()); + assert_eq!(Config::load(&root).unwrap().bind_address, "0.0.0.0"); + assert!(!set_open(&root, &meta, true).unwrap()); + } + #[test] fn an_address_chosen_by_hand_is_kept_when_opening_and_other_settings_survive() { let (_d, root, meta) = server(Some("192.168.1.20"));