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. 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/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 7070eb6..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)?; @@ -213,9 +217,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,11 +293,19 @@ 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) => { + 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 }), @@ -736,6 +751,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/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 c53614d..3d10807 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(); @@ -473,16 +474,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/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/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index 642ed36..1bd135c 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); @@ -251,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)); @@ -388,6 +409,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, mysql_data: 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 +706,8 @@ 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)); @@ -712,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"); } @@ -757,6 +799,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/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 fae61d9..0ad30e2 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; @@ -140,6 +143,84 @@ 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 +} + +/// 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> { + 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]) @@ -206,6 +287,66 @@ 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() { + 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 [ + ("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(); diff --git a/crates/coa-core/src/docker/sharing.rs b/crates/coa-core/src/docker/sharing.rs new file mode 100644 index 0000000..1c0d120 --- /dev/null +++ b/crates/coa-core/src/docker/sharing.rs @@ -0,0 +1,149 @@ +//! 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)?; + } + let remote_changed = coa_edit.is_some(); + if let Some(text) = coa_edit { + fsx::atomic_write(&coa_conf, text.as_bytes())?; + } + Ok(changed || remote_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 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")); + 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/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/friends.rs b/crates/coa-core/src/friends.rs index afc8277..9e8b368 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/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/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..476fd30 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -422,6 +422,18 @@ 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). +* **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 c58aa68..aa9337a 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 { @@ -156,7 +181,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()); } @@ -1381,7 +1408,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, @@ -1426,7 +1453,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"]); @@ -1475,9 +1513,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, @@ -1547,7 +1586,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 { @@ -1580,7 +1619,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()) @@ -1641,6 +1680,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)) }) @@ -1677,6 +1720,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 96dae69..a7263f4 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", @@ -143,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", @@ -190,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 b0fb34a..2b34c11 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -421,7 +421,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; @@ -498,6 +499,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/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/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/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")}
    ); 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 + +