mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
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:
1 parent
90c308615b
commit
dfd8a50975
15 files changed
+360
-14
No files matched your search
@@ -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}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user