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")}
    );