mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-12 04:01:01 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
ee43522019
commit
ee36692db7
11 files changed
+1556
-2
No files matched your search
Generated
+10
@@ -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"
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
@@ -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 = []
|
||||
@@ -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 <new folder ending in -fixture> <coa-launcher.exe>
|
||||
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::<Vec<_>>().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<String> = 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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<ProcessIdentity> {
|
||||
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<u32> = 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<String> = 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::TcpRow> = 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<String> {
|
||||
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::<serde_json::Value>(&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<u8> {
|
||||
const TCP_TABLE_OWNER_PID_ALL: i32 = 5;
|
||||
let mut size = 0u32;
|
||||
let mut buf: Vec<u8> = 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<TcpRow> {
|
||||
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<TcpRow> {
|
||||
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<TcpRow> {
|
||||
Vec::new()
|
||||
}
|
||||
pub fn tcp_rows_v6() -> Vec<TcpRow> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
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<String, String>) {
|
||||
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<String, String>, 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<String> = 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<String> {
|
||||
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<String>, 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<BTreeSet<String>> {
|
||||
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<String>> = BTreeMap::new();
|
||||
let mut histories: BTreeMap<&str, BTreeMap<String, String>> = BTreeMap::new();
|
||||
let mut ledgers: BTreeMap<&str, BTreeMap<String, String>> = 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<PathBuf> = 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<String> = 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Child> {
|
||||
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<Child> {
|
||||
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<Child> = 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<Vec<String>> {
|
||||
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::<Vec<_>>().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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
debug_assert!(!cmd.contains(['\r', '\n']));
|
||||
self.stream.write_all(format!("{cmd}\r\n").as_bytes())?;
|
||||
|
||||
@@ -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"] }
|
||||
@@ -0,0 +1,238 @@
|
||||
//! `coa-launcher.exe`: the repack launcher, the same commands as `Scripts/manage.py`.
|
||||
//!
|
||||
//! It lives in `<server>/Scripts/` next to manage.py (the server folder is its parent), or takes `--root <folder>`.
|
||||
//! Errors are printed as `ERROR: <message>` 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 <server>] [--port-offset N] {{{}}}", ACTIONS.join(","))
|
||||
}
|
||||
|
||||
struct Args {
|
||||
root: PathBuf,
|
||||
offset: i32,
|
||||
action: String,
|
||||
}
|
||||
|
||||
fn parse() -> Result<Args, String> {
|
||||
let mut root: Option<PathBuf> = 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 => {
|
||||
// <server>/Scripts/coa-launcher.exe -> <server>
|
||||
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<String> {
|
||||
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<String> {
|
||||
#[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<String, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user