From ee36692db73ec2a693648256d6fff7486abc2f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 10 Nov 2026 07:29:55 +0500 Subject: [PATCH] launcher (Rust): services, world supervisor, SQUID provisioning, coa-launcher.exe - services: exclusive-bind port check, readiness by listener row or the server side of a loopback connection, MySQL client, start MySQL/auth, stop-all (world through its console, database last), status. - world: start-world through a supervisor process (coa-launcher watch-world) that keeps the bug-report relay (still BugReport/relay.py via the repack's Python) alive; detailed logging. - squid: squid_playerbots.py in Rust with the same journals (coa_squid_migrations, coa_squid_pending, coa_bots_installed). - coa-launcher.exe: the manage.py commands and error texts (the Manager reads them). - Services no longer inherit the launcher's stdin/stdout/stderr: a server holding the output pipe kept whoever read the launcher's output waiting. E2E (examples/launcher_e2e, disposable stable repack on free ports): coa-launcher start-all -> manage.py sees and stops it; manage.py start-all -> coa-launcher sees and stops it; a second Rust-only cycle leaves nothing running. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 10 + Cargo.toml | 2 +- crates/coa-core/Cargo.toml | 3 +- crates/coa-core/examples/launcher_e2e.rs | 114 +++++ crates/coa-core/src/launcher/mod.rs | 3 + crates/coa-core/src/launcher/services.rs | 523 +++++++++++++++++++++++ crates/coa-core/src/launcher/squid.rs | 398 +++++++++++++++++ crates/coa-core/src/launcher/world.rs | 243 +++++++++++ crates/coa-core/src/ra.rs | 9 + crates/coa-launcher/Cargo.toml | 15 + crates/coa-launcher/src/main.rs | 238 +++++++++++ 11 files changed, 1556 insertions(+), 2 deletions(-) create mode 100644 crates/coa-core/examples/launcher_e2e.rs create mode 100644 crates/coa-core/src/launcher/services.rs create mode 100644 crates/coa-core/src/launcher/squid.rs create mode 100644 crates/coa-core/src/launcher/world.rs create mode 100644 crates/coa-launcher/Cargo.toml create mode 100644 crates/coa-launcher/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 9d82548..42742ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -606,6 +606,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "socket2", "tar", "tempfile", "thiserror 2.0.21", @@ -618,6 +619,15 @@ dependencies = [ "zstd", ] +[[package]] +name = "coa-launcher" +version = "0.9.10" +dependencies = [ + "coa-core", + "serde_json", + "windows-sys 0.60.2", +] + [[package]] name = "coa-registry-proto" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index be96eef..d7446ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/coa-core", "crates/coa-release", "crates/coa-registry-proto", "crates/coa-control-proto", "src-tauri"] +members = ["crates/coa-core", "crates/coa-launcher", "crates/coa-release", "crates/coa-registry-proto", "crates/coa-control-proto", "src-tauri"] exclude = ["services/coa-registry", "services/coa-registry-e2e", "services/coa-coordinator", "services/coa-relay"] [workspace.package] diff --git a/crates/coa-core/Cargo.toml b/crates/coa-core/Cargo.toml index 6f12f60..efae212 100644 --- a/crates/coa-core/Cargo.toml +++ b/crates/coa-core/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +socket2 = "0.6" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" @@ -37,7 +38,7 @@ rand_core = { version = "0.6", features = ["getrandom"] } ed25519-dalek = { version = "2", features = ["rand_core"] } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_System_Registry", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_System_Console", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_System_Registry", "Win32_UI_WindowsAndMessaging"] } [features] testkit = [] diff --git a/crates/coa-core/examples/launcher_e2e.rs b/crates/coa-core/examples/launcher_e2e.rs new file mode 100644 index 0000000..81692f9 --- /dev/null +++ b/crates/coa-core/examples/launcher_e2e.rs @@ -0,0 +1,114 @@ +//! The Rust launcher on a DISPOSABLE repack, side by side with Scripts/manage.py: +//! +//! 1. install the stable server into a new folder on unused ports; +//! 2. coa-launcher start-all, then manage.py status-json must see every service; +//! 3. manage.py stop-all stops what coa-launcher started; +//! 4. manage.py start-all, coa-launcher status-json sees it, coa-launcher stop-all stops it; +//! 5. both stop-alls leave nothing of the repack running. +//! +//! usage: launcher_e2e +use std::path::Path; +use std::process::Command; + +use coa_core::install::{install_base, Params}; +use coa_core::registry::Registry; + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port() +} + +fn run(exe: &Path, args: &[&str]) -> (bool, String) { + let out = Command::new(exe).args(args).output().unwrap(); + let text = String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr); + (out.status.success(), text) +} + +fn launcher(root: &Path, exe: &Path, action: &str) -> (bool, String) { + let r = root.to_string_lossy().into_owned(); + let t = std::time::Instant::now(); + let out = run(exe, &["--root", &r, action]); + println!("[coa-launcher {action}] ok={} in {:?}\n{}", out.0, t.elapsed(), indent(&out.1)); + out +} + +fn manage(root: &Path, action: &str) -> (bool, String) { + let python = root.join("Runtime/python/python.exe"); + let script = root.join("Scripts/manage.py").to_string_lossy().into_owned(); + let t = std::time::Instant::now(); + let out = run(&python, &["-B", &script, action]); + println!("[manage.py {action}] ok={} in {:?}\n{}", out.0, t.elapsed(), indent(&out.1)); + out +} + +fn indent(s: &str) -> String { + s.lines().map(|l| format!(" {l}")).collect::>().join("\n") +} + +fn status(out: &str) -> serde_json::Value { + let line = out.lines().rev().find(|l| l.trim_start().starts_with('{')).expect("a JSON status line"); + serde_json::from_str(line).unwrap() +} + +fn all(v: &serde_json::Value, running: bool) -> bool { + ["mysql", "auth", "world", "relay"].iter().all(|k| v[*k] == running) +} + +fn main() { + let a: Vec = std::env::args().skip(1).collect(); + let root = Path::new(&a[0]); + let exe = Path::new(&a[1]); + assert!(a[0].ends_with("-fixture"), "a folder ending in -fixture is required"); + if root.join("Core/worldserver.exe").is_file() { + println!("reusing the installed fixture"); + } else { + install(root); + } + test(root, exe); +} + +fn install(root: &Path) { + assert!(!root.exists(), "a new folder is required"); + let reg_dir = std::env::temp_dir().join("coa-launcher-e2e-registry"); + let _ = std::fs::remove_dir_all(®_dir); + let reg = Registry::at(reg_dir.join("installs.json")); + // what the Manager installs: the stable release's installation image + let image = coa_core::install::Source::Installation(Box::new(coa_core::channels::resolve("stable").expect("stable channel"))); + let installed = install_base( + &Params { source: image, dest: root.into(), trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, registry: ®, cancel: Default::default() }, + &|s| eprintln!(" {:>3}% {}", s.percent, s.step), + ) + .expect("install"); + println!("installed {}", installed.version); + let repack = root.join("Settings/repack.json"); + let mut cfg: serde_json::Value = serde_json::from_slice(&std::fs::read(&repack).unwrap()).unwrap(); + for k in ["mysqlPort", "authPort", "worldPort", "raPort"] { + cfg[k] = free_port().into(); + } + std::fs::write(&repack, serde_json::to_vec_pretty(&cfg).unwrap()).unwrap(); +} + +fn test(root: &Path, exe: &Path) { + // 2. Rust starts, Python sees it + assert!(launcher(root, exe, "start-all").0, "coa-launcher start-all"); + let seen = status(&manage(root, "status-json").1); + assert!(all(&seen, true), "manage.py must see the services coa-launcher started: {seen}"); + // 3. Python stops what Rust started + assert!(manage(root, "stop-all").0, "manage.py stop-all"); + assert!(all(&status(&launcher(root, exe, "status-json").1), false)); + + // 4. Python starts, Rust sees and stops it + assert!(manage(root, "start-all").0, "manage.py start-all"); + let seen = status(&launcher(root, exe, "status-json").1); + assert!(all(&seen, true), "coa-launcher must see the services manage.py started: {seen}"); + assert!(launcher(root, exe, "stop-all").0, "coa-launcher stop-all"); + assert!(all(&status(&manage(root, "status-json").1), false)); + + // 5. Rust alone, a second time (start on a server it stopped) + assert!(launcher(root, exe, "start-all").0); + assert!(launcher(root, exe, "stop-all").0); + let left = coa_core::process::find_by_exe(&root.join("mysql/bin/mysqld.exe")).len() + + coa_core::process::find_by_exe(&root.join("Core/worldserver.exe")).len() + + coa_core::process::find_by_exe(&root.join("Core/authserver.exe")).len(); + assert_eq!(left, 0, "nothing of the repack may be left running"); + println!("LAUNCHER E2E PASS"); +} diff --git a/crates/coa-core/src/launcher/mod.rs b/crates/coa-core/src/launcher/mod.rs index c4bc0d4..b1d79d1 100644 --- a/crates/coa-core/src/launcher/mod.rs +++ b/crates/coa-core/src/launcher/mod.rs @@ -12,7 +12,10 @@ //! power cut could leave a zero-filled record that stopped every later start). pub mod render; +pub mod services; pub mod settings; +pub mod squid; pub mod state; +pub mod world; pub use settings::Settings; diff --git a/crates/coa-core/src/launcher/services.rs b/crates/coa-core/src/launcher/services.rs new file mode 100644 index 0000000..82160a1 --- /dev/null +++ b/crates/coa-core/src/launcher/services.rs @@ -0,0 +1,523 @@ +//! Starting, checking and stopping the services of one repack: MySQL, authserver, worldserver +//! (`manage.py start_mysql`, `start_auth`, `stop_all` and their helpers), with the same rules. + +use std::fs::OpenOptions; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use crate::error::{Error, Result}; +use crate::launcher::{settings::Settings, state}; +use crate::process::ProcessIdentity; + +pub type Progress<'a> = &'a dyn Fn(&str); + +const CREATE_NO_WINDOW: u32 = 0x0800_0000; +const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + +fn hidden(cmd: &mut Command) -> &mut Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP); + } + cmd +} + +/// Keep this process's own stdin/stdout/stderr out of the services it starts. Windows children inherit every +/// inheritable handle, and a server holding the launcher's output pipe keeps whoever reads that output (the +/// Manager, a test) waiting until the server exits. Python's subprocess passes only the handles it is given; this +/// gives the same result. +pub(crate) fn keep_std_handles_private() { + #[cfg(windows)] + unsafe { + use windows_sys::Win32::Foundation::{SetHandleInformation, HANDLE_FLAG_INHERIT}; + use windows_sys::Win32::System::Console::{GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE}; + for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] { + let h = GetStdHandle(which); + if !h.is_null() && h as isize != -1 { + SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0); + } + } + } +} + +/// Start `exe` with its output appended to `log`, and record it as `name`. +pub fn spawn(root: &Path, name: &str, exe: &Path, args: &[&str], cwd: &Path, log: &Path) -> Result { + keep_std_handles_private(); + if let Some(dir) = log.parent() { + std::fs::create_dir_all(dir)?; + } + let out = OpenOptions::new().create(true).append(true).open(log)?; + let err = out.try_clone()?; + let child = hidden(Command::new(exe).args(args).current_dir(cwd)) + .stdin(Stdio::null()) + .stdout(out) + .stderr(err) + .spawn()?; + state::record(root, name, child.id()) +} + +// --------------------------------------------------------------------------------------------------------------- +// Ports +// --------------------------------------------------------------------------------------------------------------- + +/// Wait (up to 30 s) until `port` can be bound by a new server. A port with a listening owner is refused at +/// once; a port Windows still holds for a stopped process (a PID-zero row) is waited for. The test bind is +/// exclusive (`SO_EXCLUSIVEADDRUSE`), never a shared one. +pub fn ensure_free(port: u16, progress: Progress) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(30); + let mut announced = false; + loop { + let mut owners: Vec = sys::tcp_rows_v4() + .into_iter() + .filter(|r| r.state == sys::LISTEN && r.local_port == port) + .map(|r| r.pid) + .collect(); + owners.sort_unstable(); + owners.dedup(); + if owners.iter().any(|p| *p != 0) { + let list: Vec = owners.iter().map(u32::to_string).collect(); + return Err(Error::Invalid(format!( + "Port {port} is already used (PID {}). Stop the other server or change Settings/repack.json.", + list.join(", ") + ))); + } + match sys::exclusive_bind(port) { + Ok(()) => return Ok(()), + Err(code) if code == 10013 || code == 10048 => { + if Instant::now() >= deadline { + return Err(Error::Invalid(format!( + "Port {port} remains unavailable after 30 seconds (Windows bind error {code}). No process was stopped. Retry after the previous database shutdown has finished, or check the Windows port reservation." + ))); + } + if !announced { + progress(&format!("Waiting for Windows to release port {port}...")); + announced = true; + } + std::thread::sleep(Duration::from_millis(250)); + } + Err(code) => return Err(Error::Invalid(format!("Port {port} cannot be checked (Windows error {code})."))), + } + } +} + +/// Is the recorded process of `name` serving `port`? Its listener row is the evidence; when a system does not +/// show the owner of a listener, the server side of a fresh loopback connection is (never for MySQL, whose +/// listener has always been visible and which should not get stray connections). +pub fn ready(root: &Path, name: &str, port: u16) -> bool { + let Some(record) = state::running(root, name) else { return false }; + let listening = sys::tcp_rows_v4().iter().any(|r| r.state == sys::LISTEN && r.local_port == port && r.pid == record.pid); + if listening { + return crate::process::is_alive(&record); + } + if name == "mysql" { + return false; + } + accepted_by(port, record.pid) && crate::process::is_alive(&record) +} + +/// Connect to 127.0.0.1:`port` and check that exactly one established server-side row, owned by `pid`, answers +/// that connection. +fn accepted_by(port: u16, pid: u32) -> bool { + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + let Ok(client) = TcpStream::connect_timeout(&addr, Duration::from_millis(500)) else { return false }; + let Ok(mine) = client.local_addr().map(|a| a.port()) else { return false }; + let me = std::process::id(); + let rows: Vec = sys::tcp_rows_v4() + .into_iter() + .chain(sys::tcp_rows_v6()) + .filter(|r| r.remote_port == mine && r.remote_loopback && !(r.local_port == mine && r.pid == me)) + .collect(); + rows.len() == 1 && rows[0].state == sys::ESTABLISHED && rows[0].pid == pid +} + +pub fn wait_ready(root: &Path, name: &str, port: u16, seconds: u64) -> Result<()> { + let until = Instant::now() + Duration::from_secs(seconds); + while Instant::now() < until { + if ready(root, name, port) { + return Ok(()); + } + if state::running(root, name).is_none() { + return Err(Error::Invalid(format!("{name} stopped during startup. Check its log."))); + } + std::thread::sleep(Duration::from_millis(500)); + } + Err(Error::Invalid(format!( + "{name} is still starting: its expected TCP listener on port {port} was not found; check its log." + ))) +} + +// --------------------------------------------------------------------------------------------------------------- +// MySQL client +// --------------------------------------------------------------------------------------------------------------- + +/// Run SQL (or a `mysqladmin` command) with the repack's own client and administrator login. +pub fn mysql(root: &Path, sql: Option<&str>, admin: Option<&str>) -> Result { + let exe = root.join(if admin.is_some() { "mysql/bin/mysqladmin.exe" } else { "mysql/bin/mysql.exe" }); + let defaults = format!("--defaults-file={}", root.join("mysql/admin-client.ini").display()); + let mut cmd = Command::new(exe); + hidden(&mut cmd).arg(defaults); + match admin { + Some(a) => cmd.arg(a), + None => cmd.args(["--batch", "--skip-column-names"]), + }; + let payload = sql.map(|s| s.as_bytes().to_vec()); + let timeout = if payload.as_ref().is_some_and(|p| p.len() > 1024 * 1024) { 900 } else { 90 }; + cmd.stdin(if payload.is_some() { Stdio::piped() } else { Stdio::null() }).stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = cmd.spawn()?; + if let (Some(data), Some(mut stdin)) = (payload, child.stdin.take()) { + std::thread::spawn(move || { + use std::io::Write; + let _ = stdin.write_all(&data); + }); + } + let started = Instant::now(); + let failed = || Error::Invalid("The repack database command failed; check mysql/logs/mysql-error.log.".into()); + loop { + if let Some(status) = child.try_wait()? { + let out = child.wait_with_output()?; + if !status.success() { + return Err(failed()); + } + return Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()); + } + if started.elapsed() > Duration::from_secs(timeout) { + let _ = child.kill(); + let _ = child.wait(); + return Err(failed()); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +// --------------------------------------------------------------------------------------------------------------- +// Start and stop +// --------------------------------------------------------------------------------------------------------------- + +pub fn start_mysql(root: &Path, cfg: &Settings, progress: Progress) -> Result<()> { + if state::running(root, "mysql").is_some() { + wait_ready(root, "mysql", cfg.mysql_port, 300)?; + mysql(root, None, Some("ping"))?; + progress("MySQL is already running."); + return Ok(()); + } + ensure_free(cfg.mysql_port, progress)?; + if !root.join("mysql/data/mysql").is_dir() { + return Err(Error::Invalid("The packaged database is missing. Extract the complete repack.".into())); + } + progress("Starting MySQL..."); + let ini = format!("--defaults-file={}", root.join("mysql/my.ini").display()); + spawn(root, "mysql", &root.join("mysql/bin/mysqld.exe"), &[&ini, "--no-monitor"], &root.join("mysql"), &root.join("mysql/logs/startup.log"))?; + wait_ready(root, "mysql", cfg.mysql_port, 300)?; + mysql(root, None, Some("ping"))?; + mysql( + root, + Some(&format!( + "UPDATE acore_auth.realmlist SET name='AzerothCore',address='127.0.0.1',localAddress='127.0.0.1',port={} WHERE id=1;", + cfg.world_port + )), + None, + )?; + Ok(()) +} + +pub fn start_auth(root: &Path, cfg: &Settings, progress: Progress) -> Result<()> { + start_mysql(root, cfg, progress)?; + if state::running(root, "auth").is_some() { + wait_ready(root, "auth", cfg.auth_port, 60)?; + progress("Authserver is already running."); + return Ok(()); + } + ensure_free(cfg.auth_port, progress)?; + // An interrupted world startup can leave flag 3, which authserver excludes. + mysql(root, Some("UPDATE acore_auth.realmlist SET flag=0 WHERE id=1;"), None)?; + progress("Starting authserver..."); + spawn(root, "auth", &root.join("Core/authserver.exe"), &["-c", "configs/authserver.conf"], &root.join("Core"), &root.join("Core/Logs/auth-console.log"))?; + wait_ready(root, "auth", cfg.auth_port, 60) +} + +/// Stop the recorded process, only if it is still that process. +pub fn terminate(record: &ProcessIdentity) -> Result<()> { + if !crate::process::is_alive(record) { + return Ok(()); + } + sys::terminate(record) +} + +pub fn wait_stopped(record: &ProcessIdentity, seconds: u64) -> bool { + let until = Instant::now() + Duration::from_secs(seconds); + while crate::process::is_alive(record) && Instant::now() < until { + std::thread::sleep(Duration::from_millis(250)); + } + !crate::process::is_alive(record) +} + +/// Stop everything of this repack, world first (gracefully through its console), its database last. +pub fn stop_all(root: &Path, cfg: &Settings, progress: Progress) -> Result<()> { + if let Some(world) = state::running(root, "world") { + let asked = ready(root, "world", cfg.ra_port) + .then(|| crate::ra::Ra::connect_to(cfg.ra_port, &cfg.ra_username, &cfg.ra_password)) + .and_then(|r| r.ok()) + .map(|ra| ra.send_without_answer("server shutdown 1s 0")); + match asked { + Some(Ok(())) => progress("Graceful world shutdown requested."), + _ => { + progress("World console unavailable; stopping this repack's recorded world process."); + terminate(&world)?; + } + } + if !wait_stopped(&world, 90) { + return Err(Error::Invalid("Worldserver is still stopping; its database has been left running.".into())); + } + } + if let Some(supervisor) = state::running(root, "supervisor") { + if !wait_stopped(&supervisor, 45) { + return Err(Error::Invalid("The report relay is still finishing a request. Try Stop All again shortly.".into())); + } + } + if let Some(relay) = state::running(root, "relay") { + std::fs::write(state::dir(root).join("stop-relay"), b"")?; + if !wait_stopped(&relay, 40) { + return Err(Error::Invalid("The report relay did not finish; it was left running for recovery.".into())); + } + } + if let Some(auth) = state::running(root, "auth") { + terminate(&auth)?; + if !wait_stopped(&auth, 10) { + return Err(Error::Invalid("Authserver did not stop.".into())); + } + } + if let Some(database) = state::running(root, "mysql") { + let shutdown = mysql(root, None, Some("shutdown")); + if !wait_stopped(&database, 60) { + shutdown?; + return Err(Error::Invalid("MySQL is still stopping; wait before copying the repack.".into())); + } + } + progress("All services from this repack are stopped. It is safe to move or copy this folder."); + Ok(()) +} + +/// Which services run (`manage.py status-json`). +pub fn status(root: &Path) -> serde_json::Value { + let mut out = serde_json::Map::new(); + for name in ["mysql", "auth", "world", "relay"] { + out.insert(name.into(), state::running(root, name).is_some().into()); + } + let debug: bool = crate::fsx::read_json::(&state::dir(root).join("debug-logs.json")) + .ok() + .and_then(|v| v.get("enabled").and_then(|e| e.as_bool())) + .unwrap_or(false); + out.insert("debugLogs".into(), debug.into()); + serde_json::Value::Object(out) +} + +pub fn log_path(root: &Path, name: &str) -> PathBuf { + match name { + "mysql" => root.join("mysql/logs/startup.log"), + "auth" => root.join("Core/Logs/auth-console.log"), + "world" => root.join("Core/Logs/world-console.log"), + "supervisor" => root.join("Core/Logs/supervisor.log"), + _ => root.join("BugReport/Logs/relay.log"), + } +} + +// --------------------------------------------------------------------------------------------------------------- +// Windows +// --------------------------------------------------------------------------------------------------------------- + +#[cfg(windows)] +mod sys { + use crate::error::{Error, Result}; + use crate::process::ProcessIdentity; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::NetworkManagement::IpHelper::GetExtendedTcpTable; + use windows_sys::Win32::Networking::WinSock::{setsockopt, SOL_SOCKET, SO_EXCLUSIVEADDRUSE}; + use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE}; + + pub const LISTEN: u32 = 2; + pub const ESTABLISHED: u32 = 5; + + pub struct TcpRow { + pub state: u32, + pub local_port: u16, + pub remote_port: u16, + pub remote_loopback: bool, + pub pid: u32, + } + + fn table(af: u32) -> Vec { + const TCP_TABLE_OWNER_PID_ALL: i32 = 5; + let mut size = 0u32; + let mut buf: Vec = Vec::new(); + for _ in 0..4 { + let code = unsafe { + GetExtendedTcpTable( + if buf.is_empty() { std::ptr::null_mut() } else { buf.as_mut_ptr().cast() }, + &mut size, + 0, + af, + TCP_TABLE_OWNER_PID_ALL, + 0, + ) + }; + match code { + 0 if !buf.is_empty() => return buf, + 0 | 122 => buf = vec![0u8; size as usize + 4096], + _ => return Vec::new(), + } + } + Vec::new() + } + + fn u32_at(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes(b[o..o + 4].try_into().unwrap()) + } + + pub fn tcp_rows_v4() -> Vec { + let b = table(2); + let mut out = Vec::new(); + if b.len() < 4 { + return out; + } + for i in 0..u32_at(&b, 0) as usize { + let o = 4 + 24 * i; + if o + 24 > b.len() { + break; + } + let remote = u32_at(&b, o + 12).to_le_bytes(); + out.push(TcpRow { + state: u32_at(&b, o), + local_port: u16::from_be((u32_at(&b, o + 8) & 0xFFFF) as u16), + remote_port: u16::from_be((u32_at(&b, o + 16) & 0xFFFF) as u16), + remote_loopback: remote == [127, 0, 0, 1], + pid: u32_at(&b, o + 20), + }); + } + out + } + + pub fn tcp_rows_v6() -> Vec { + let b = table(23); + let mut out = Vec::new(); + if b.len() < 4 { + return out; + } + // MIB_TCP6ROW_OWNER_PID: local addr 16, scope 4, local port 4, remote addr 16, scope 4, remote port 4, state 4, pid 4 + for i in 0..u32_at(&b, 0) as usize { + let o = 4 + 56 * i; + if o + 56 > b.len() { + break; + } + let remote = &b[o + 24..o + 40]; + let mapped_loopback = remote[..10].iter().all(|x| *x == 0) && remote[10] == 0xff && remote[11] == 0xff && remote[12..] == [127, 0, 0, 1]; + out.push(TcpRow { + state: u32_at(&b, o + 48), + local_port: u16::from_be((u32_at(&b, o + 20) & 0xFFFF) as u16), + remote_port: u16::from_be((u32_at(&b, o + 44) & 0xFFFF) as u16), + remote_loopback: mapped_loopback, + pid: u32_at(&b, o + 52), + }); + } + out + } + + /// Bind 0.0.0.0:port exclusively and release it. Err carries the Windows socket error. + pub fn exclusive_bind(port: u16) -> std::result::Result<(), i32> { + use socket2::{Domain, Socket, Type}; + use std::os::windows::io::AsRawSocket; + let socket = Socket::new(Domain::IPV4, Type::STREAM, None).map_err(|e| e.raw_os_error().unwrap_or(-1))?; + let one: i32 = 1; + let set = unsafe { + setsockopt(socket.as_raw_socket() as _, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, (&one as *const i32).cast(), 4) + }; + if set != 0 { + return Err(std::io::Error::last_os_error().raw_os_error().unwrap_or(-1)); + } + let addr: std::net::SocketAddr = ([0, 0, 0, 0], port).into(); + socket.bind(&addr.into()).map_err(|e| e.raw_os_error().unwrap_or(-1)) + } + + pub fn terminate(record: &ProcessIdentity) -> Result<()> { + unsafe { + let h = OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION, 0, record.pid); + if h.is_null() { + return Err(Error::Invalid("Unable to stop the repack process.".into())); + } + let same = crate::process::is_alive(record); + let ok = same && TerminateProcess(h, 0) != 0; + CloseHandle(h); + if !ok { + return Err(Error::Invalid("The process changed while stopping it.".into())); + } + } + Ok(()) + } +} + +#[cfg(not(windows))] +mod sys { + use crate::error::{Error, Result}; + use crate::process::ProcessIdentity; + pub const LISTEN: u32 = 2; + pub const ESTABLISHED: u32 = 5; + pub struct TcpRow { + pub state: u32, + pub local_port: u16, + pub remote_port: u16, + pub remote_loopback: bool, + pub pid: u32, + } + pub fn tcp_rows_v4() -> Vec { + Vec::new() + } + pub fn tcp_rows_v6() -> Vec { + Vec::new() + } + pub fn exclusive_bind(_: u16) -> std::result::Result<(), i32> { + Ok(()) + } + pub fn terminate(_: &ProcessIdentity) -> Result<()> { + Err(Error::Invalid("not supported".into())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_port_with_a_listener_is_refused_and_a_free_one_passes() { + let held = std::net::TcpListener::bind("0.0.0.0:0").unwrap(); + let port = held.local_addr().unwrap().port(); + let err = ensure_free(port, &|_| {}).unwrap_err().to_string(); + assert!(err.contains(&format!("Port {port} is already used (PID {}", std::process::id())), "{err}"); + drop(held); + ensure_free(port, &|_| {}).unwrap(); + } + + #[test] + fn a_service_is_ready_only_on_its_own_port() { + let d = tempfile::tempdir().unwrap(); + let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = held.local_addr().unwrap().port(); + assert!(!ready(d.path(), "auth", port), "no record yet"); + state::record(d.path(), "auth", std::process::id()).unwrap(); + assert!(ready(d.path(), "auth", port)); + let other = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + drop(other); + assert!(!ready(d.path(), "auth", port + 1) || port + 1 == 0); + } + + #[test] + fn status_reads_the_records() { + let d = tempfile::tempdir().unwrap(); + state::record(d.path(), "mysql", std::process::id()).unwrap(); + let s = status(d.path()); + assert_eq!(s["mysql"], true); + assert_eq!(s["world"], false); + assert_eq!(s["debugLogs"], false); + } +} diff --git a/crates/coa-core/src/launcher/squid.rs b/crates/coa-core/src/launcher/squid.rs new file mode 100644 index 0000000..1ac0409 --- /dev/null +++ b/crates/coa-core/src/launcher/squid.rs @@ -0,0 +1,398 @@ +//! SQUID Playerbots on a repack (`Scripts/squid_playerbots.py`): the bot modules must not both run, how long a +//! SQUID world may take to start, and its database, provisioned before the world starts. +//! +//! The SQL journals are the ones the Python script keeps, unchanged: `coa_squid_migrations(path, sha256)` and +//! `coa_squid_pending(path, sha256)` in every target schema, and the SQUID installer's `coa_bots_installed`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use sha1::Digest as _; + +use crate::error::{Error, Result}; + +/// `key = value` lines of a module config (comments and quotes dropped), as `options()` reads them. +pub fn options(path: &Path) -> BTreeMap { + let mut out = BTreeMap::new(); + let Ok(bytes) = std::fs::read(path) else { return out }; + let text = String::from_utf8_lossy(&bytes); + let text = text.strip_prefix('\u{feff}').unwrap_or(&text); + for line in text.lines() { + let line = line.trim_start(); + let Some((key, rest)) = line.split_once('=') else { continue }; + let key = key.trim_end(); + if key.is_empty() || key.starts_with('#') || key.contains(char::is_whitespace) { + continue; + } + let value = rest.split('#').next().unwrap_or("").trim(); + out.insert(key.to_string(), value.trim_matches('"').to_string()); + } + out +} + +/// A secondary world shares the primary's package files. +pub fn package_root(root: &Path) -> PathBuf { + let secondary = root.file_name().is_some_and(|n| n == "secondary") + && root.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".realms"); + if secondary { + root.parent().and_then(Path::parent).map(Path::to_path_buf).unwrap_or_else(|| root.to_path_buf()) + } else { + root.to_path_buf() + } +} + +pub fn module_options(root: &Path, name: &str) -> (PathBuf, BTreeMap) { + let folder = root.join("Core/configs/modules"); + let active = folder.join(name); + let mut path = if active.exists() { active } else { folder.join(format!("{name}.dist")) }; + if !path.exists() && name == "playerbots.conf" { + path = package_root(root).join("Core/configs/modules/playerbots.conf.dist"); + } + let values = options(&path); + (path, values) +} + +/// Invalid booleans count as the modules' compiled default: enabled. +pub fn enabled(values: &BTreeMap, key: &str) -> bool { + !matches!(values.get(key).map(|v| v.to_ascii_lowercase()).as_deref(), Some("0" | "false" | "no" | "off")) +} + +pub fn validate_bots(root: &Path) -> Result<()> { + let (a, av) = module_options(root, "mod_coa_playerbots.conf"); + let (b, bv) = module_options(root, "playerbots.conf"); + if a.exists() && b.exists() && enabled(&av, "CoaBots.Enable") && enabled(&bv, "AiPlayerbot.Enabled") { + return Err(Error::Invalid( + "CoA Companions and SQUID Playerbots cannot both be enabled. Disable one bot module in Modules or its config.".into(), + )); + } + Ok(()) +} + +/// Seconds a world may take to open its port: SQUID loads its bots first. +pub fn startup_timeout(root: &Path) -> u64 { + let (path, values) = module_options(root, "playerbots.conf"); + if path.exists() && enabled(&values, "AiPlayerbot.Enabled") { + 1500 + } else { + 180 + } +} + +/// Replace (or append) `key = value` lines; the file is written with "\n" line ends. +pub fn set_options(path: &Path, values: &[(&str, String)]) -> Result<()> { + let bytes = std::fs::read(path)?; + let text = String::from_utf8_lossy(&bytes); + let mut text = text.strip_prefix('\u{feff}').unwrap_or(&text).replace("\r\n", "\n"); + for (key, value) in values { + let line = format!("{key} = {value}"); + let mut found = false; + let lines: Vec = text + .split('\n') + .map(|l| { + let t = l.trim_start(); + let matches = t.strip_prefix(key).is_some_and(|rest| rest.trim_start().starts_with('=')); + if matches { + found = true; + line.clone() + } else { + l.to_string() + } + }) + .collect(); + text = lines.join("\n"); + if !found { + text.push('\n'); + text.push_str(&line); + text.push('\n'); + } + } + crate::fsx::atomic_write(path, text.as_bytes()) +} + +fn valid_relative(rel: &str) -> bool { + !rel.is_empty() && rel.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'/' | b'-')) +} + +/// Table names a base file creates (`CREATE TABLE [IF NOT EXISTS] name`). +fn created_tables(sql: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + let upper = sql.to_ascii_uppercase(); + let mut from = 0; + while let Some(i) = upper[from..].find("CREATE") { + let start = from + i + "CREATE".len(); + from = start; + let rest = &sql[start..]; + let mut words = rest.split_whitespace(); + if !words.next().is_some_and(|w| w.eq_ignore_ascii_case("TABLE")) { + continue; + } + let mut name = words.next().unwrap_or(""); + if name.eq_ignore_ascii_case("IF") { + let _ = words.next(); // NOT + let _ = words.next(); // EXISTS + name = words.next().unwrap_or(""); + } + let name: String = name.trim_start_matches('`').chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + if !name.is_empty() { + out.insert(name); + } + } + out +} + +fn sha256_hex(bytes: &[u8]) -> String { + crate::fsx::sha256_bytes(bytes) +} + +fn sha1_hex(bytes: &[u8]) -> String { + let mut h = sha1::Sha1::new(); + h.update(bytes); + h.finalize().iter().map(|b| format!("{b:02x}")).collect() +} + +fn rows(text: &str) -> Vec<(String, String)> { + text.lines().filter_map(|r| r.split_once('\t')).map(|(a, b)| (a.to_string(), b.to_string())).collect() +} + +/// Provision an enabled SQUID without replaying imported base tables (`prepare_playerbots`). `mysql(sql)` runs +/// SQL with the repack's administrator login and returns its output. +pub fn prepare_playerbots(root: &Path, mysql_port: u16, mysql: &dyn Fn(&str) -> Result, progress: &dyn Fn(&str)) -> Result<()> { + let (mut path, values) = module_options(root, "playerbots.conf"); + if !path.exists() || !enabled(&values, "AiPlayerbot.Enabled") { + return Ok(()); + } + validate_bots(root)?; + let sql_root = package_root(root).join("Extras/SquidPlayerbots/sql"); + if !sql_root.join("playerbots/base").is_dir() { + return Err(Error::Invalid("SQUID Playerbots database files are missing. Repair the server installation.".into())); + } + let world = options(&root.join("Core/configs/worldserver.conf")); + let mut schemas: Vec<(&str, String)> = Vec::new(); + for (kind, key) in [("characters", "CharacterDatabaseInfo"), ("world", "WorldDatabaseInfo")] { + let schema = world.get(key).and_then(|v| v.rsplit(';').next()).unwrap_or("").to_string(); + let ok = matches!(schema.as_str(), "acore_world" | "acore_characters" | "acore_world_wildcard" | "acore_characters_wildcard"); + if !ok { + return Err(Error::Invalid("Unsupported world/characters database for SQUID Playerbots.".into())); + } + schemas.push((kind, schema)); + } + schemas.push(("playerbots", "acore_playerbots".into())); + + let list = |schema: &str| -> Result> { + Ok(mysql(&format!("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='{schema}';"))? + .lines() + .map(str::to_string) + .collect()) + }; + let existing_playerbots = list("acore_playerbots")?; + let bookkeeping = ["updates", "updates_include", "version_db_playerbots", "coa_squid_migrations", "coa_bots_installed", "coa_squid_pending"]; + let data_tables: Vec<&String> = existing_playerbots.iter().filter(|t| !bookkeeping.contains(&t.as_str())).collect(); + let mut installer_history = BTreeSet::new(); + if existing_playerbots.contains("coa_bots_installed") { + for name in mysql("SELECT file FROM `acore_playerbots`.coa_bots_installed;")?.lines() { + let mut n = name.replace('\\', "/"); + while n.contains("//") { + n = n.replace("//", "/"); + } + let n = n.trim_matches('/').to_string(); + if n.to_ascii_lowercase().ends_with(".sql") { + installer_history.insert(n); + } + } + } + let mut tables: BTreeMap<&str, BTreeSet> = BTreeMap::new(); + let mut histories: BTreeMap<&str, BTreeMap> = BTreeMap::new(); + let mut ledgers: BTreeMap<&str, BTreeMap> = BTreeMap::new(); + for (kind, schema) in &schemas { + let t = if *kind == "playerbots" { existing_playerbots.clone() } else { list(schema)? }; + if t.contains("coa_squid_pending") { + let pending = mysql(&format!("SELECT path FROM `{schema}`.coa_squid_pending;"))?; + let pending = pending.trim(); + if !pending.is_empty() { + return Err(Error::Invalid(format!( + "A SQUID base import was interrupted. Restore the database backup or repair the incomplete import before continuing; no SQL was applied: {pending}" + ))); + } + } + let mut history = BTreeMap::new(); + if t.contains("updates") { + for (name, digest) in rows(&mysql(&format!("SELECT name,hash FROM `{schema}`.updates;"))?) { + history.insert(name, digest.to_ascii_lowercase()); + } + } + let mut ledger = BTreeMap::new(); + if t.contains("coa_squid_migrations") { + for (name, digest) in rows(&mysql(&format!("SELECT path,sha256 FROM `{schema}`.coa_squid_migrations;"))?) { + ledger.insert(name, digest); + } + } + tables.insert(kind, t); + histories.insert(kind, history); + ledgers.insert(kind, ledger); + } + if !data_tables.is_empty() && installer_history.is_empty() && histories["playerbots"].is_empty() && ledgers["playerbots"].is_empty() { + for table in data_tables { + let quoted = table.replace('`', "``"); + if mysql(&format!("SELECT EXISTS(SELECT 1 FROM `acore_playerbots`.`{quoted}` LIMIT 1);"))?.trim() == "1" { + return Err(Error::Invalid( + "Existing SQUID data has no recorded installation history. Repair the imported database history before continuing; no SQL was applied.".into(), + )); + } + } + } + mysql("CREATE DATABASE IF NOT EXISTS acore_playerbots CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;")?; + for host in ["localhost", "127.0.0.1"] { + mysql(&format!("GRANT ALL PRIVILEGES ON acore_playerbots.* TO 'acore'@'{host}';"))?; + } + let (_, password) = crate::db::credentials(root)?; + if password.len() != 48 || !password.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { + return Err(Error::Invalid("Invalid packaged database configuration.".into())); + } + if path.file_name().is_some_and(|n| n.to_string_lossy().ends_with(".dist")) { + let active = root.join("Core/configs/modules/playerbots.conf"); + std::fs::create_dir_all(active.parent().unwrap())?; + std::fs::write(&active, std::fs::read(&path)?)?; + path = active; + } + set_options( + &path, + &[ + ("PlayerbotsDatabaseInfo", format!("\"127.0.0.1;{mysql_port};acore;{password};acore_playerbots\"")), + ("Playerbots.Updates.EnableDatabases", "0".into()), + ], + )?; + for (kind, schema) in &schemas { + mysql(&format!("CREATE TABLE IF NOT EXISTS `{schema}`.coa_squid_migrations (path VARCHAR(240) PRIMARY KEY, sha256 CHAR(64) NOT NULL);"))?; + mysql(&format!("CREATE TABLE IF NOT EXISTS `{schema}`.coa_squid_pending (path VARCHAR(240) PRIMARY KEY, sha256 CHAR(64) NOT NULL);"))?; + let history = &histories[kind]; + let recorded = ledgers[kind].clone(); + for stage in ["base", "updates", "custom"] { + let mut sources: Vec = std::fs::read_dir(sql_root.join(kind).join(stage)) + .map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "sql")).collect()) + .unwrap_or_default(); + sources.sort(); + for source in sources { + let relative = source.strip_prefix(&sql_root).unwrap_or(&source).to_string_lossy().replace('\\', "/"); + if !valid_relative(&relative) { + return Err(Error::Invalid("Invalid SQUID SQL filename.".into())); + } + let bytes = std::fs::read(&source)?; + let digest = sha256_hex(&bytes); + if let Some(previous) = recorded.get(&relative) { + if *previous != digest { + return Err(Error::Invalid(format!("A previously applied SQUID database migration changed: {relative}"))); + } + if stage != "base" { + continue; + } + } + let ledger_sql = if recorded.contains_key(&relative) { + String::new() + } else { + format!("INSERT INTO `{schema}`.coa_squid_migrations VALUES ('{relative}','{digest}');") + }; + let text = String::from_utf8_lossy(&bytes); + let text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_string(); + if stage == "base" { + let definitions = created_tables(&text); + let present: BTreeSet = definitions.intersection(&tables[kind]).cloned().collect(); + if !present.is_empty() && present != definitions { + return Err(Error::Invalid(format!("SQUID base file has only some of its tables. Repair the incomplete database before continuing: {relative}"))); + } + if definitions.is_empty() { + return Err(Error::Invalid(format!("SQUID base file has no recognizable table definitions; repair is required: {relative}"))); + } + if present == definitions { + if !ledger_sql.is_empty() { + mysql(&ledger_sql)?; + } + continue; + } + mysql(&format!("INSERT INTO `{schema}`.coa_squid_pending VALUES ('{relative}','{digest}'); COMMIT;"))?; + let complete = format!("DELETE FROM `{schema}`.coa_squid_pending WHERE path='{relative}' AND sha256='{digest}'; COMMIT;"); + progress(&format!("Importing SQUID {relative} into {schema} ({} bytes); large imports may take several minutes...", bytes.len())); + mysql(&format!("USE `{schema}`;\n{text}\n{ledger_sql}\n{complete}")).map_err(|_| { + Error::Invalid(format!( + "SQUID import failed: {relative} in {schema}. The world server was not started. The incomplete import is recorded; restore a database backup or repair that import before retrying." + )) + })?; + progress(&format!("Imported SQUID {relative}.")); + if let Some(t) = tables.get_mut(kind) { + t.extend(definitions); + } + continue; + } + if installer_history.iter().any(|n| *n == relative || n.ends_with(&format!("/{relative}"))) { + mysql(&ledger_sql)?; + continue; + } + let file_name = source.file_name().unwrap_or_default().to_string_lossy().into_owned(); + if let Some(upstream) = history.get(&file_name) { + let lf = String::from_utf8_lossy(&bytes).replace("\r\n", "\n"); + let accepted = [sha1_hex(&bytes), sha1_hex(lf.as_bytes())]; + if !upstream.is_empty() && !accepted.contains(upstream) { + return Err(Error::Invalid(format!("SQUID update history does not match the shipped SQL: {relative}"))); + } + mysql(&ledger_sql)?; + continue; + } + mysql(&format!("USE `{schema}`;\n{text}\n{ledger_sql}"))?; + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_lines_are_read_like_the_python_script() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("playerbots.conf"); + std::fs::write(&p, "\u{feff}# comment = 1\nAiPlayerbot.Enabled = 0 # off\n Key.Two=\"quoted\"\nnot a line\nEmpty =\n").unwrap(); + let o = options(&p); + assert_eq!(o.get("AiPlayerbot.Enabled").map(String::as_str), Some("0")); + assert_eq!(o.get("Key.Two").map(String::as_str), Some("quoted")); + assert_eq!(o.get("Empty").map(String::as_str), Some("")); + assert!(!o.contains_key("#")); + assert!(!enabled(&o, "AiPlayerbot.Enabled")); + assert!(enabled(&o, "Missing")); + } + + #[test] + fn both_bot_modules_on_is_refused_and_squid_gets_a_long_startup() { + let d = tempfile::tempdir().unwrap(); + let m = d.path().join("Core/configs/modules"); + std::fs::create_dir_all(&m).unwrap(); + std::fs::write(m.join("mod_coa_playerbots.conf"), "CoaBots.Enable = 1\n").unwrap(); + std::fs::write(m.join("playerbots.conf.dist"), "AiPlayerbot.Enabled = 1\n").unwrap(); + assert!(validate_bots(d.path()).is_err()); + assert_eq!(startup_timeout(d.path()), 1500); + std::fs::write(m.join("playerbots.conf"), "AiPlayerbot.Enabled = 0\n").unwrap(); + validate_bots(d.path()).unwrap(); + assert_eq!(startup_timeout(d.path()), 180); + } + + #[test] + fn options_are_replaced_or_appended() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("playerbots.conf"); + std::fs::write(&p, "A = 1\r\n Playerbots.Updates.EnableDatabases = 1\r\n").unwrap(); + set_options(&p, &[("Playerbots.Updates.EnableDatabases", "0".into()), ("PlayerbotsDatabaseInfo", "\"x\"".into())]).unwrap(); + assert_eq!(std::fs::read_to_string(&p).unwrap(), "A = 1\nPlayerbots.Updates.EnableDatabases = 0\n\nPlayerbotsDatabaseInfo = \"x\"\n"); + } + + #[test] + fn base_tables_are_found() { + let t = created_tables("CREATE TABLE IF NOT EXISTS `ai_playerbot_names` (\n id int);\ncreate table playerbots_random_bots(x int);\n-- CREATE INDEX i ON t(x);"); + assert_eq!(t, ["ai_playerbot_names".to_string(), "playerbots_random_bots".to_string()].into_iter().collect()); + } + + #[test] + fn a_secondary_world_uses_the_primary_package() { + assert_eq!(package_root(Path::new("C:/S/.realms/secondary")), Path::new("C:/S")); + assert_eq!(package_root(Path::new("C:/S")), Path::new("C:/S")); + } +} diff --git a/crates/coa-core/src/launcher/world.rs b/crates/coa-core/src/launcher/world.rs new file mode 100644 index 0000000..52667db --- /dev/null +++ b/crates/coa-core/src/launcher/world.rs @@ -0,0 +1,243 @@ +//! The world server and the bug-report relay beside it (`manage.py start_world`, `watch_world`, `wait_relay`), +//! and the detailed-logging switch (`debug_logs`). +//! +//! A world is started through a supervisor process that outlives the caller: it starts `worldserver.exe`, keeps +//! the relay running while the world runs, and stops the relay when the world ends. The relay itself is still +//! the Python `BugReport/relay.py`, run through the repack's Python, until it is ported too. + +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use crate::error::{Error, Result}; +use crate::launcher::services::{self, ensure_free, mysql, spawn, start_mysql, wait_ready, Progress}; +use crate::launcher::{settings::Settings, squid, state}; + +fn stop_relay_flag(root: &Path) -> std::path::PathBuf { + state::dir(root).join("stop-relay") +} + +/// The relay is up and has finished starting (it records itself as `relay-ready` once it serves). +pub fn wait_relay(root: &Path) -> Result<()> { + let until = Instant::now() + Duration::from_secs(20); + while Instant::now() < until { + if let (Some(relay), Some(ready)) = (state::running(root, "relay"), state::running(root, "relay-ready")) { + if relay == ready { + return Ok(()); + } + } + std::thread::sleep(Duration::from_millis(250)); + } + Err(Error::Invalid("Bug-report relay is not ready; check BugReport/Logs/relay.log.".into())) +} + +/// Start the world (and its database). `supervisor` is the launcher executable that runs `watch-world`. +pub fn start_world(root: &Path, cfg: &Settings, offset: i32, supervisor: &Path, progress: Progress) -> Result<()> { + start_mysql(root, cfg, progress)?; + squid::validate_bots(root)?; + let seconds = squid::startup_timeout(root); + if state::running(root, "world").is_some() { + if state::running(root, "supervisor").is_none() { + return Err(Error::Invalid("Worldserver has no repack supervisor. Use Stop_All_Server.bat before restarting it.".into())); + } + wait_ready(root, "world", cfg.world_port, seconds)?; + wait_relay(root)?; + progress("Worldserver and bug-report relay are already running."); + return Ok(()); + } + ensure_free(cfg.world_port, progress)?; + ensure_free(cfg.ra_port, progress)?; + if state::running(root, "supervisor").is_some() || state::running(root, "relay").is_some() { + return Err(Error::Invalid("The previous world/relay shutdown is still finishing. Try again shortly.".into())); + } + squid::prepare_playerbots(root, cfg.mysql_port, &|sql| mysql(root, Some(sql), None), progress)?; + progress("Starting worldserver and its bug-report relay..."); + let _ = std::fs::remove_file(stop_relay_flag(root)); + let offset_text = offset.to_string(); + let root_text = root.to_string_lossy().into_owned(); + spawn( + root, + "supervisor", + supervisor, + &["--root", &root_text, "--port-offset", &offset_text, "watch-world"], + root, + &root.join("Core/Logs/supervisor.log"), + )?; + let until = Instant::now() + Duration::from_secs(20); + while state::running(root, "world").is_none() && Instant::now() < until { + if state::running(root, "supervisor").is_none() { + return Err(Error::Invalid("World startup failed; check Core/Logs/supervisor.log.".into())); + } + std::thread::sleep(Duration::from_millis(250)); + } + wait_ready(root, "world", cfg.world_port, seconds)?; + wait_ready(root, "world", cfg.ra_port, seconds)?; + wait_relay(root)?; + progress("Worldserver and automatic bug reporting are ready."); + Ok(()) +} + +fn hidden(cmd: &mut Command) -> &mut Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000 | 0x0000_0200); + } + cmd +} + +fn spawn_child(root: &Path, name: &str, exe: &Path, args: &[&str], cwd: &Path, log: &Path) -> Result { + services::keep_std_handles_private(); + if let Some(dir) = log.parent() { + std::fs::create_dir_all(dir)?; + } + let out = std::fs::OpenOptions::new().create(true).append(true).open(log)?; + let err = out.try_clone()?; + let child = hidden(Command::new(exe).args(args).current_dir(cwd)).stdin(Stdio::null()).stdout(out).stderr(err).spawn()?; + state::record(root, name, child.id())?; + Ok(child) +} + +/// The relay worker: the repack's Python running `Scripts/manage.py relay-worker` (BugReport/relay.py). +fn spawn_relay(root: &Path) -> Result { + let python = squid::package_root(root).join("Runtime/python/python.exe"); + let manage = root.join("Scripts/manage.py"); + if !python.is_file() || !manage.is_file() { + return Err(Error::Invalid("The bug-report relay needs the repack's Python and Scripts/manage.py.".into())); + } + let manage = manage.to_string_lossy().into_owned(); + spawn_child(root, "relay", &python, &["-B", &manage, "relay-worker"], root, &root.join("BugReport/Logs/relay.log")) +} + +/// The supervisor (`watch-world`): run the world, keep the relay alive while it runs, stop the relay after it. +pub fn watch_world(root: &Path, log: Progress) -> Result<()> { + squid::validate_bots(root)?; + let mut world = spawn_child(root, "world", &root.join("Core/worldserver.exe"), &["-c", "configs/worldserver.conf"], &root.join("Core"), &root.join("Core/Logs/world-console.log"))?; + let mut relay: Option = None; + while world.try_wait()?.is_none() { + let dead = match relay.as_mut() { + None => true, + Some(r) => r.try_wait()?.is_some(), + }; + if dead { + match spawn_relay(root) { + Ok(r) => relay = Some(r), + Err(e) => { + log(&format!("Report relay could not start ({e}); retrying.")); + std::thread::sleep(Duration::from_secs(5)); + continue; + } + } + } + std::thread::sleep(Duration::from_secs(2)); + } + std::fs::write(stop_relay_flag(root), b"")?; + if let Some(mut r) = relay { + let until = Instant::now() + Duration::from_secs(40); + while r.try_wait()?.is_none() && Instant::now() < until { + std::thread::sleep(Duration::from_millis(250)); + } + if r.try_wait()?.is_none() { + // An interrupted POST stays uncertain in the relay's journal; it is not resent. + if let Some(record) = state::running(root, "relay") { + services::terminate(&record)?; + } + } + } + Ok(()) +} + +// --------------------------------------------------------------------------------------------------------------- +// Detailed logging (Debug_Logs_On.bat / Debug_Logs_Off.bat) +// --------------------------------------------------------------------------------------------------------------- + +const DEBUG_TEMPLATES: [&str; 2] = ["worldserver.conf.template", "authserver.conf.template"]; + +/// One template line with every logger forced to Trace and the console/server appenders to the most detail. +fn debug_line(line: &str) -> String { + let body = line.strip_prefix('#').unwrap_or(line).trim_start(); + for appender in ["Appender.Console", "Appender.Server"] { + if let Some(rest) = body.strip_prefix(appender).and_then(|r| r.strip_prefix('=')) { + let parts: Vec<&str> = rest.splitn(4, ',').collect(); + if parts.len() == 4 && parts[..3].iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) { + return format!("{appender}={},6,7,{}", parts[0], parts[3]); + } + } + } + if let Some(rest) = body.strip_prefix("Logger.") { + if let Some((name, value)) = rest.split_once('=') { + if !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.') { + if let Some((level, tail)) = value.split_once(',') { + if !level.is_empty() && level.bytes().all(|b| b.is_ascii_digit()) { + return format!("Logger.{name}=6,{tail}"); + } + } + } + } + } + let t = line.trim_end(); + if let Some(v) = t.strip_prefix("Log.Async.Enable") { + let v = v.trim_start(); + if let Some(n) = v.strip_prefix('=') { + let n = n.trim(); + if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) { + return "Log.Async.Enable = 1".into(); + } + } + } + line.to_string() +} + +/// Turn detailed logging on or off (the templates; services apply it at their next start). Returns the changed +/// templates. +pub fn debug_logs(root: &Path, enable: bool) -> Result> { + let mut changed = Vec::new(); + for name in DEBUG_TEMPLATES { + let path = root.join("Settings").join(name); + let original = root.join("Settings").join(format!("{name}.original")); + if !original.exists() { + std::fs::write(&original, std::fs::read(&path)?)?; + } + let new_bytes = if enable { + let text = String::from_utf8(std::fs::read(&original)?).map_err(|_| Error::Invalid(format!("{name} is not UTF-8.")))?; + let newline = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let mut out = text.lines().map(debug_line).collect::>().join(newline); + if text.ends_with('\n') { + out.push_str(newline); + } + out.into_bytes() + } else { + std::fs::read(&original)? + }; + if std::fs::read(&path)? != new_bytes { + std::fs::write(&path, &new_bytes)?; + changed.push(name.to_string()); + } + } + state::write_json(&state::dir(root).join("debug-logs.json"), &serde_json::json!({ "enabled": enable }))?; + Ok(changed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detailed_logging_rewrites_loggers_and_restores_the_original() { + let d = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(d.path().join("Settings")).unwrap(); + let world = "Appender.Console=1,3,0\r\nAppender.Server=2,2,0,Server.log,w\r\n#Logger.module=4,Console Server\r\nLogger.root=5,Console Server\r\nLog.Async.Enable = 0\r\nOther = 1\r\n"; + std::fs::write(d.path().join("Settings/worldserver.conf.template"), world).unwrap(); + std::fs::write(d.path().join("Settings/authserver.conf.template"), "Logger.root=2,Console\n").unwrap(); + let changed = debug_logs(d.path(), true).unwrap(); + assert_eq!(changed.len(), 2); + assert_eq!( + std::fs::read_to_string(d.path().join("Settings/worldserver.conf.template")).unwrap(), + "Appender.Console=1,3,0\r\nAppender.Server=2,6,7,Server.log,w\r\nLogger.module=6,Console Server\r\nLogger.root=6,Console Server\r\nLog.Async.Enable = 1\r\nOther = 1\r\n" + ); + assert_eq!(debug_logs(d.path(), true).unwrap().len(), 0, "already on"); + debug_logs(d.path(), false).unwrap(); + assert_eq!(std::fs::read_to_string(d.path().join("Settings/worldserver.conf.template")).unwrap(), world); + assert_eq!(services::status(d.path())["debugLogs"], false); + } +} diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index 2523c04..5764488 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -88,6 +88,15 @@ impl Ra { self.command(c) } + /// Send a command the server does not answer before it closes the console (`server shutdown`), and return at + /// once. + pub fn send_without_answer(mut self, cmd: &str) -> Result<()> { + let c = crate::console::check_command(cmd)?; + self.stream.write_all(format!("{c}\r\n").as_bytes())?; + self.stream.flush()?; + Ok(()) + } + fn command(&mut self, cmd: &str) -> Result { debug_assert!(!cmd.contains(['\r', '\n'])); self.stream.write_all(format!("{cmd}\r\n").as_bytes())?; diff --git a/crates/coa-launcher/Cargo.toml b/crates/coa-launcher/Cargo.toml new file mode 100644 index 0000000..b4263cf --- /dev/null +++ b/crates/coa-launcher/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "coa-launcher" +description = "The CoA repack launcher (Start_All_Server.bat and friends) without Python" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "coa-launcher" +path = "src/main.rs" + +[dependencies] +coa-core = { path = "../coa-core" } +serde_json = "1" +windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_System_Console"] } diff --git a/crates/coa-launcher/src/main.rs b/crates/coa-launcher/src/main.rs new file mode 100644 index 0000000..39d802d --- /dev/null +++ b/crates/coa-launcher/src/main.rs @@ -0,0 +1,238 @@ +//! `coa-launcher.exe`: the repack launcher, the same commands as `Scripts/manage.py`. +//! +//! It lives in `/Scripts/` next to manage.py (the server folder is its parent), or takes `--root `. +//! Errors are printed as `ERROR: ` on stderr with exit code 1, exactly like manage.py, because the +//! Manager reads them. + +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use coa_core::launcher::{render, services, settings::Settings, state, world}; + +const ACTIONS: [&str; 17] = [ + "start-all", "start-mysql", "start-auth", "start-world", "stop-all", "status", "status-json", "create-account", + "create-account-once", "console", "ra-once", "prepare", "watch-world", "relay-worker", "debug-logs-on", + "debug-logs-off", "debug-logs-status", +]; + +fn say(text: &str) { + println!("{text}"); + let _ = std::io::stdout().flush(); +} + +fn usage() -> String { + format!("usage: coa-launcher [--root ] [--port-offset N] {{{}}}", ACTIONS.join(",")) +} + +struct Args { + root: PathBuf, + offset: i32, + action: String, +} + +fn parse() -> Result { + let mut root: Option = None; + let mut offset = 0; + let mut action = None; + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + match a.as_str() { + "--root" => root = Some(PathBuf::from(it.next().ok_or_else(usage)?)), + "--port-offset" => offset = it.next().and_then(|v| v.parse().ok()).ok_or_else(usage)?, + _ if ACTIONS.contains(&a.as_str()) && action.is_none() => action = Some(a), + _ => return Err(usage()), + } + } + let root = match root { + Some(r) => r, + None => { + // /Scripts/coa-launcher.exe -> + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + exe.parent().and_then(Path::parent).map(Path::to_path_buf).ok_or("cannot find the server folder")? + } + }; + Ok(Args { root, offset, action: action.ok_or_else(usage)? }) +} + +fn read_line(prompt: &str) -> std::io::Result { + print!("{prompt}"); + std::io::stdout().flush()?; + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line)?; + Ok(line.trim_end_matches(['\r', '\n']).to_string()) +} + +/// Read a line without showing it (passwords). +fn read_secret(prompt: &str) -> std::io::Result { + #[cfg(windows)] + unsafe { + use windows_sys::Win32::System::Console::{GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_ECHO_INPUT, STD_INPUT_HANDLE}; + let h = GetStdHandle(STD_INPUT_HANDLE); + let mut mode = 0; + if GetConsoleMode(h, &mut mode) != 0 { + SetConsoleMode(h, mode & !ENABLE_ECHO_INPUT); + let line = read_line(prompt); + SetConsoleMode(h, mode); + println!(); + return line; + } + } + read_line(prompt) +} + +fn valid_login(text: &str) -> bool { + (1..=16).contains(&text.len()) && text.bytes().all(|b| b.is_ascii_alphanumeric()) +} + +fn ra(cfg: &Settings, root: &Path, command: &str) -> Result { + if !services::ready(root, "world", cfg.ra_port) { + return Err("Start this repack's worldserver first.".into()); + } + let mut console = coa_core::ra::Ra::connect_to(cfg.ra_port, &cfg.ra_username, &cfg.ra_password).map_err(|e| e.to_string())?; + console.run(command).map_err(|e| e.to_string()) +} + +fn run(args: Args) -> Result<(), String> { + let root = args.root.as_path(); + let e = |x: coa_core::Error| x.to_string(); + match args.action.as_str() { + "watch-world" => return world::watch_world(root, &|m| say(m)).map_err(e), + "relay-worker" => { + return Err("The bug-report relay runs through Scripts/manage.py relay-worker until it is ported.".into()) + } + "debug-logs-status" => { + let on = services::status(root)["debugLogs"].as_bool().unwrap_or(false); + say(&format!("Detailed server logging is {}.", if on { "ON" } else { "off" })); + if on { + say("Every AzerothCore logger is forced to Trace level in Core/Logs (Server.log, Auth.log, world-console.log, auth-console.log). Expect much larger, faster-growing log files."); + } + say("Run Debug_Logs_On.bat / Debug_Logs_Off.bat, then restart the affected service(s) to apply."); + return Ok(()); + } + "debug-logs-on" | "debug-logs-off" => { + let on = args.action == "debug-logs-on"; + let changed = { + let _lock = state::ControlLock::acquire(root, &args.action, Duration::from_secs(30), &|m| say(m)).map_err(e)?; + world::debug_logs(root, on).map_err(e)? + }; + if changed.is_empty() { + say(&format!("Detailed logging was already {}.", if on { "on" } else { "off" })); + } else { + say(&format!("{} detailed logging in: {}", if on { "Enabled" } else { "Restored" }, changed.join(", "))); + } + say("Restart the affected service(s) (Stop_All_Server.bat then Start_All_Server.bat) to apply this."); + return Ok(()); + } + _ => {} + } + let cfg = Settings::load(root, args.offset).map_err(e)?; + match args.action.as_str() { + "console" => { + say("World console. Enter a server command, or exit to close this window."); + loop { + let command = read_line("AC> ").map_err(|x| x.to_string())?; + let command = command.trim(); + if command.eq_ignore_ascii_case("exit") || command.eq_ignore_ascii_case("quit") { + return Ok(()); + } + if !command.is_empty() { + say(&ra(&cfg, root, command)?); + } + } + } + "create-account" => { + let name = read_line("New account name (letters and digits, up to 16): ").map_err(|x| x.to_string())?; + let name = name.trim(); + let password = read_secret("New account password (letters and digits, up to 16): ").map_err(|x| x.to_string())?; + let again = read_secret("Repeat password: ").map_err(|x| x.to_string())?; + if !valid_login(name) || !valid_login(&password) { + return Err("Use 1-16 letters or digits for the account name and password.".into()); + } + if password != again { + return Err("Passwords do not match.".into()); + } + say(&ra(&cfg, root, &format!("account create {name} {password}"))?); + if read_line("Give this account GM permissions? [y/N]: ").map_err(|x| x.to_string())?.trim().eq_ignore_ascii_case("y") { + say(&ra(&cfg, root, &format!("account set gmlevel {name} 3 -1"))?); + } + return Ok(()); + } + "ra-once" => { + let command = read_line("").map_err(|x| x.to_string())?; + let command = command.trim(); + if command.is_empty() { + return Err("No command given.".into()); + } + say(&ra(&cfg, root, command)?); + return Ok(()); + } + "create-account-once" => { + let line = read_line("").map_err(|x| x.to_string())?; + let request: serde_json::Value = serde_json::from_str(&line).map_err(|x| x.to_string())?; + let name = request.get("username").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); + let password = request.get("password").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if !valid_login(&name) || !valid_login(&password) { + return Err("Use 1-16 letters or digits for the account name and password.".into()); + } + let mut messages = vec![ra(&cfg, root, &format!("account create {name} {password}"))?]; + if request.get("gm").and_then(|v| v.as_bool()).unwrap_or(false) { + messages.push(ra(&cfg, root, &format!("account set gmlevel {name} 3 -1"))?); + } + say(&serde_json::json!({ "messages": messages }).to_string()); + return Ok(()); + } + _ => {} + } + { + let _lock = state::ControlLock::acquire(root, &args.action, Duration::from_secs(30), &|m| say(m)).map_err(e)?; + match args.action.as_str() { + "status" => { + for name in ["mysql", "auth", "world", "relay"] { + let line = match state::running(root, name) { + Some(r) => format!("{name:12} running (PID {})", r.pid), + None => format!("{name:12} stopped"), + }; + say(&line); + } + return Ok(()); + } + "status-json" => { + say(&services::status(root).to_string()); + return Ok(()); + } + "stop-all" => return services::stop_all(root, &cfg, &|m| say(m)).map_err(e), + _ => {} + } + if matches!(args.action.as_str(), "start-all" | "start-world") { + coa_core::launcher::squid::validate_bots(root).map_err(e)?; + } + render::prepare(root, &cfg).map_err(e)?; + if args.action == "prepare" { + return Ok(()); + } + let progress = |m: &str| say(m); + if matches!(args.action.as_str(), "start-all" | "start-mysql") { + services::start_mysql(root, &cfg, &progress).map_err(e)?; + } + if matches!(args.action.as_str(), "start-all" | "start-auth") { + services::start_auth(root, &cfg, &progress).map_err(e)?; + } + if matches!(args.action.as_str(), "start-all" | "start-world") { + let me = std::env::current_exe().map_err(|x| x.to_string())?; + world::start_world(root, &cfg, args.offset, &me, &progress).map_err(e)?; + } + } + if args.action.starts_with("start-") { + say("Use Stop_All_Server.bat to shut down this repack safely."); + } + Ok(()) +} + +fn main() { + let result = parse().and_then(run); + if let Err(message) = result { + eprintln!("ERROR: {message}"); + std::process::exit(1); + } +}