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).
This commit is contained in:
Fabien F. committed 2026-10-11 08:57:38 +02:00
1 parent 90c308615b
commit dfd8a50975
15 files changed
+360 -14

No files matched your search

+1 -1
View File
@@ -212,7 +212,7 @@ pub fn config_files(root: &Path) -> Vec<String> {
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}"));
}
}
+8
View File
@@ -206,6 +206,10 @@ pub fn drift(root: &Path) -> Result<Vec<String>> {
}
pub fn load(root: &Path, scope: Scope) -> Result<SettingsView> {
// 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<String, Value>) ->
}
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 }),
+13
View File
@@ -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<Line> {
let needle = filter.map(str::to_lowercase).filter(|n| !n.is_empty());
let out: Vec<Line> = 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<Vec<Line>> {
const WINDOW: u64 = 512 * 1024;
+39 -1
View File
@@ -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
+72
View File
@@ -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<Vec<Line>> {
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<Vec<Line>> {
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<Vec<Vec<String>>>,
}
impl Docker for Fake {
fn run(&self, call: &Call) -> Result<Output> {
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"));
}
}
+23
View File
@@ -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 [
+139
View File
@@ -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::<IpAddr>().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<bool> {
let mut cfg = Config::load(root)?;
let mut originals: Vec<(PathBuf, Vec<u8>)> = 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<Exposure> {
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));
}
}
+6
View File
@@ -80,6 +80,9 @@ fn bind_targets(root: &Path) -> Vec<PathBuf> {
/// 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<bool> {
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();
+19 -1
View File
@@ -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<f64> { text.lines().find_map(|l| l.strip_prefix(key)?.trim().strip_suffix("kB")?.trim().parse::<f64>().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<SizeOption> {
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();
+8
View File
@@ -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.
+24 -6
View File
@@ -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<coa_core::net::Exposure>,
/// The configuration lets other computers reach the login and world servers.
servers_open: bool,
firewall: coa_core::firewall::Status,
firewall: Option<coa_core::firewall::Status>,
tailscale: coa_core::net::Tailscale,
server_running: bool,
auth_port: u16,
@@ -1419,7 +1421,18 @@ fn report_targets() -> Vec<coa_core::report::Target> {
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<Vec<coa_core::console::Line>, 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))
})
+1
View File
@@ -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",
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -170,7 +170,7 @@ export function FriendsPage({ serverId }: { serverId: string }) {
<ul className="mt-2 divide-y divide-line">
<Line ok={st.server_running}>{t("fr.st.running")}</Line>
<Line ok={st.servers_open}>{t("fr.st.open")}</Line>
<Line ok={st.firewall.auth && st.firewall.world}>{t("fr.st.firewall")}</Line>
{st.firewall && <Line ok={st.firewall.auth && st.firewall.world}>{t("fr.st.firewall")}</Line>}
<Line ok={st.exposure.filter((e) => e.what === "database" || e.what === "server console").every((e) => !e.reachable_from_network)}>
{t("fr.st.private")}
</Line>
+4 -3
View File
@@ -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 (
<div className="flex items-center justify-between py-2.5">
<span>{label}</span>
<span
className={cn("flex items-center gap-2 text-sm", running ? "text-ok" : starting ? "text-warn" : "text-muted")}
className={cn("flex items-center gap-2 text-sm", running ? "text-ok" : starting || crashed ? "text-warn" : "text-muted")}
role="status"
>
<span
aria-hidden
className={cn(
"h-2 w-2 rounded-full",
running ? "bg-ok" : starting ? "animate-[pulse-dot_1.2s_ease-in-out_infinite] bg-warn" : "bg-muted/50",
running ? "bg-ok" : starting ? "animate-[pulse-dot_1.2s_ease-in-out_infinite] bg-warn" : crashed ? "bg-warn" : "bg-muted/50",
)}
/>
{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")}
</span>
</div>
);