diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cc23192 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.bat text eol=crlf diff --git a/Cargo.lock b/Cargo.lock index ff00b80..5a4a439 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "windows-sys 0.60.2", ] [[package]] @@ -664,7 +665,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -682,14 +692,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -698,48 +725,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/coa-core/Cargo.toml b/crates/coa-core/Cargo.toml index 54d3132..4310104 100644 --- a/crates/coa-core/Cargo.toml +++ b/crates/coa-core/Cargo.toml @@ -18,3 +18,9 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["f [dev-dependencies] tempfile = "3" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock"] } + +[features] +testkit = [] diff --git a/crates/coa-core/examples/scan.rs b/crates/coa-core/examples/scan.rs new file mode 100644 index 0000000..7ccbed2 --- /dev/null +++ b/crates/coa-core/examples/scan.rs @@ -0,0 +1,10 @@ +use std::path::Path; + +fn main() { + let path = std::env::args().nth(1).expect("usage: scan "); + let root = Path::new(&path); + let report = coa_core::layout::scan(root).expect("scan failed"); + println!("{}", serde_json::to_string_pretty(&report).unwrap()); + let observed = coa_core::process::observe(root, &report.ports); + println!("{}", serde_json::to_string_pretty(&observed).unwrap()); +} diff --git a/crates/coa-core/src/layout.rs b/crates/coa-core/src/layout.rs new file mode 100644 index 0000000..0104752 --- /dev/null +++ b/crates/coa-core/src/layout.rs @@ -0,0 +1,443 @@ +//! Read-only detection and classification of a CoA Repack-shaped server folder. +//! +//! Nothing here writes to the scanned folder. Files are opened for reading only, and only the few files whose +//! content matters (server executables) are hashed; everything else is looked at by name and size. + +use std::fs::{self, File}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::Result; +use crate::fsx; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Classification { + Healthy, + Partial, + UnknownCustom, + Incompatible, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Status { + Found, + Missing, + Attention, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Item { + pub key: &'static str, + pub label: &'static str, + pub status: Status, + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ExeInfo { + pub size: u64, + pub sha256: String, + /// Some(true/false) when RELEASE.json lists this binary, None when it does not. + pub matches_release: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReleaseJson { + pub name: Option, + pub release_date: Option, + pub main_revision: Option, + pub source_revision: Option, + #[serde(default)] + pub binaries: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ReleaseBinary { + pub name: String, + #[serde(rename = "SHA256")] + pub sha256: String, + pub bytes: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Ports { + pub mysql: u16, + pub auth: u16, + pub world: u16, + pub ra: u16, +} + +impl Default for Ports { + fn default() -> Self { + Ports { mysql: 3307, auth: 3724, world: 8085, ra: 3443 } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ClientInfo { + pub path: String, + pub executable: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ScanReport { + pub path: String, + pub classification: Classification, + pub items: Vec, + pub worldserver: Option, + pub authserver: Option, + pub release: Option, + /// Core commit as printed by the worldserver banner (12 hex) when found in its log. + pub banner_revision: Option, + pub module_configs: Vec, + pub bot_config_keys: usize, + pub ports: Ports, + pub database_schemas: Vec, + pub client: Option, + pub notes: Vec, + /// Always false: a scan never modifies anything. + pub modifies_files: bool, +} + +fn exists(root: &Path, rel: &str) -> bool { + root.join(rel).exists() +} + +fn item(key: &'static str, label: &'static str, present: bool, detail: Option) -> Item { + Item { key, label, status: if present { Status::Found } else { Status::Missing }, detail } +} + +fn hash_exe(path: &Path, release: Option<&ReleaseJson>, name: &str) -> Option { + let meta = fs::metadata(path).ok()?; + if !meta.is_file() { + return None; + } + let sha256 = fsx::sha256_file(path).ok()?; + let matches_release = release.and_then(|r| { + r.binaries.iter().find(|b| b.name.eq_ignore_ascii_case(name)).map(|b| b.sha256.eq_ignore_ascii_case(&sha256)) + }); + Some(ExeInfo { size: meta.len(), sha256, matches_release }) +} + +fn read_ports(root: &Path) -> Ports { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct P { + mysql_port: Option, + auth_port: Option, + world_port: Option, + ra_port: Option, + } + let d = Ports::default(); + match fsx::read_json::

(&root.join("Settings/repack.json")) { + Ok(p) => Ports { + mysql: p.mysql_port.unwrap_or(d.mysql), + auth: p.auth_port.unwrap_or(d.auth), + world: p.world_port.unwrap_or(d.world), + ra: p.ra_port.unwrap_or(d.ra), + }, + Err(_) => d, + } +} + +/// Find the last worldserver banner `AzerothCore rev. ` in a (possibly huge) log: the tail window first, +/// then the head window, since a long-running server's banner can be far from the end. +pub fn banner_revision_in_log(log: &Path) -> Option { + const WINDOW: u64 = 4 * 1024 * 1024; + let len = File::open(log).ok()?.metadata().ok()?.len(); + let read = |start: u64| -> Option { + let mut f = File::open(log).ok()?; + f.seek(SeekFrom::Start(start)).ok()?; + let mut buf = Vec::new(); + f.take(WINDOW).read_to_end(&mut buf).ok()?; + last_banner(&String::from_utf8_lossy(&buf)) + }; + read(len.saturating_sub(WINDOW)).or_else(|| if len > WINDOW { read(0) } else { None }) +} + +fn last_banner(text: &str) -> Option { + let mut found = None; + for line in text.lines() { + if let Some(pos) = line.find("AzerothCore rev. ") { + let rest = &line[pos + "AzerothCore rev. ".len()..]; + let rev: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect(); + if rev.len() >= 7 { + found = Some(rev); + } + } + } + found +} + +pub fn detect_client(dir: &Path) -> Option { + if !dir.join("Data").is_dir() { + return None; + } + for exe in ["Ascension.exe", "Wow.exe", "WoW.exe"] { + if dir.join(exe).is_file() { + return Some(ClientInfo { path: dir.to_string_lossy().into_owned(), executable: exe.into() }); + } + } + None +} + +fn count_bot_keys(conf: &Path) -> usize { + fs::read_to_string(conf) + .map(|t| t.lines().filter(|l| l.trim_start().starts_with("CoaBots.")).count()) + .unwrap_or(0) +} + +/// Scan `root` without modifying it. +pub fn scan(root: &Path) -> Result { + let root = fsx::canonicalize_lenient(root)?; + let mut notes = Vec::new(); + + let release: Option = fsx::read_json(&root.join("RELEASE.json")).ok(); + let world = hash_exe(&root.join("Core/worldserver.exe"), release.as_ref(), "worldserver.exe"); + let auth = hash_exe(&root.join("Core/authserver.exe"), release.as_ref(), "authserver.exe"); + + let modules_dir = root.join("Core/configs/modules"); + let mut module_configs: Vec = fs::read_dir(&modules_dir) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| n.ends_with(".conf")) + .collect() + }) + .unwrap_or_default(); + module_configs.sort(); + + let mysql_data = root.join("mysql/data"); + let mut database_schemas: Vec = ["acore_auth", "acore_characters", "acore_world"] + .iter() + .filter(|s| mysql_data.join(s).is_dir()) + .map(|s| s.to_string()) + .collect(); + database_schemas.sort(); + + let bot_conf = modules_dir.join("mod_coa_playerbots.conf"); + let bot_config_keys = count_bot_keys(&bot_conf); + let banner_revision = banner_revision_in_log(&root.join("Core/Logs/Server.log")); + + let has_repack_scaffold = + exists(&root, "Scripts/manage.py") && exists(&root, "Settings/repack.json") && exists(&root, "Runtime/python/python.exe"); + let has_bundled_db = exists(&root, "mysql/bin/mysqld.exe") && database_schemas.len() == 3; + let has_data = exists(&root, "Data/dbc") && exists(&root, "Data/maps"); + + let items = vec![ + item("worldserver", "World server", world.is_some(), None), + item("authserver", "Auth server", auth.is_some(), None), + item("worldserver_conf", "World server configuration", exists(&root, "Core/configs/worldserver.conf"), None), + item("authserver_conf", "Auth server configuration", exists(&root, "Core/configs/authserver.conf"), None), + item("modules_conf", "Module configuration", modules_dir.is_dir(), Some(format!("{} files", module_configs.len()))), + item("game_data", "Game data (maps, DBC)", has_data, None), + item("database_runtime", "Database runtime", exists(&root, "mysql/bin/mysqld.exe"), None), + item( + "database_content", + "Auth / Characters / World databases", + has_bundled_db, + Some(database_schemas.join(", ")), + ), + item("launcher", "Repack launcher scripts", has_repack_scaffold, None), + item("release_info", "RELEASE.json", release.is_some(), release.as_ref().and_then(|r| r.main_revision.clone())), + item( + "companions", + "CoA Companions (bots)", + bot_conf.is_file(), + (bot_config_keys > 0).then(|| format!("{bot_config_keys} settings")), + ), + ]; + + let client = ["Client", "client"].iter().find_map(|d| detect_client(&root.join(d))); + + if let (Some(w), Some(r)) = (&world, &release) { + if w.matches_release == Some(false) { + notes.push(format!( + "worldserver.exe differs from the one listed in RELEASE.json (release {}); this looks like a customised build.", + r.release_date.clone().unwrap_or_default() + )); + } + } + if module_configs.iter().any(|c| c.ends_with(".bak")) { + notes.push("Backup copies of module configs were found.".into()); + } + + let has_exes = world.is_some() && auth.is_some(); + let looks_like_client = root.join("Wow.exe").is_file() || root.join("Ascension.exe").is_file(); + let nested_repack = exists(&root, "Core/Core"); + + let classification = if looks_like_client || nested_repack { + Classification::Incompatible + } else if has_exes && !has_repack_scaffold { + Classification::UnknownCustom + } else if has_exes && has_repack_scaffold && has_bundled_db && has_data + && exists(&root, "Core/configs/worldserver.conf") + && exists(&root, "Core/configs/authserver.conf") + { + Classification::Healthy + } else if has_repack_scaffold || exists(&root, "Core") { + Classification::Partial + } else { + Classification::Incompatible + }; + + if looks_like_client { + notes.push("This looks like a game client folder, not a server folder.".into()); + } + + Ok(ScanReport { + path: root.to_string_lossy().into_owned(), + classification, + items, + worldserver: world, + authserver: auth, + release, + banner_revision, + module_configs, + bot_config_keys, + ports: read_ports(&root), + database_schemas, + client, + notes, + modifies_files: false, + }) +} + +/// Paths of the executables used by an installation, if present. +pub fn executables(root: &Path) -> (PathBuf, PathBuf, PathBuf) { + (root.join("Core/worldserver.exe"), root.join("Core/authserver.exe"), root.join("mysql/bin/mysqld.exe")) +} + +#[cfg(any(test, feature = "testkit"))] +pub mod testkit { + use std::fs; + use std::path::Path; + + fn put(root: &Path, rel: &str, bytes: &[u8]) { + let p = root.join(rel); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(p, bytes).unwrap(); + } + + /// Build a minimal repack-shaped tree with fake binaries. + pub fn fake_repack(root: &Path) { + put(root, "Core/worldserver.exe", b"fake-world"); + put(root, "Core/authserver.exe", b"fake-auth"); + put(root, "Core/configs/worldserver.conf", b"[worldserver]\nRealmID = 1\n"); + put(root, "Core/configs/authserver.conf", b"[authserver]\n"); + put(root, "Core/configs/modules/mod_coa_playerbots.conf", b"CoaBots.AutoLoginOnStartup = 0\nCoaBots.X = 1\n"); + put(root, "Core/Logs/Server.log", b"x\nINFO AzerothCore rev. 3567e2f8e9d5 2026-09-18 ready...\n"); + put(root, "Data/dbc/Spell.dbc", b"d"); + put(root, "Data/maps/000.map", b"m"); + put(root, "mysql/bin/mysqld.exe", b"fake-mysqld"); + for db in ["acore_auth", "acore_characters", "acore_world"] { + fs::create_dir_all(root.join("mysql/data").join(db)).unwrap(); + } + put(root, "Scripts/manage.py", b"# stub"); + put(root, "Runtime/python/python.exe", b"stub"); + put(root, "Settings/repack.json", br#"{"mysqlPort":3307,"authPort":3724,"worldPort":8085,"raPort":3443}"#); + let world_hash = crate::fsx::sha256_bytes(b"fake-world"); + let release = format!( + r#"{{"name":"CoA Repack","releaseDate":"2026-09-11","mainRevision":"c3beca68","binaries":[{{"Name":"worldserver.exe","SHA256":"{world_hash}","Bytes":10}}]}}"# + ); + put(root, "RELEASE.json", release.as_bytes()); + put(root, "user-notes.txt", b"my own file"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn healthy_repack_is_classified_and_untouched() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("repack"); + testkit::fake_repack(&root); + let before = tree(&root); + let r = scan(&root).unwrap(); + assert_eq!(r.classification, Classification::Healthy); + assert_eq!(r.banner_revision.as_deref(), Some("3567e2f8e9d5")); + assert_eq!(r.bot_config_keys, 2); + assert_eq!(r.worldserver.as_ref().unwrap().matches_release, Some(true)); + assert_eq!(r.authserver.as_ref().unwrap().matches_release, None); + assert!(!r.modifies_files); + assert_eq!(before, tree(&root), "scan must not modify anything"); + } + + #[test] + fn customised_binary_is_flagged_but_still_healthy() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("repack"); + testkit::fake_repack(&root); + fs::write(root.join("Core/worldserver.exe"), b"my-custom-build").unwrap(); + let r = scan(&root).unwrap(); + assert_eq!(r.classification, Classification::Healthy); + assert_eq!(r.worldserver.unwrap().matches_release, Some(false)); + assert!(r.notes.iter().any(|n| n.contains("customised"))); + } + + #[test] + fn partial_when_database_content_missing() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("repack"); + testkit::fake_repack(&root); + fs::remove_dir_all(root.join("mysql/data/acore_world")).unwrap(); + assert_eq!(scan(&root).unwrap().classification, Classification::Partial); + } + + #[test] + fn unknown_custom_when_exes_without_repack_scaffold() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("custom"); + fs::create_dir_all(root.join("Core")).unwrap(); + fs::write(root.join("Core/worldserver.exe"), b"w").unwrap(); + fs::write(root.join("Core/authserver.exe"), b"a").unwrap(); + assert_eq!(scan(&root).unwrap().classification, Classification::UnknownCustom); + } + + #[test] + fn client_and_random_folders_are_incompatible() { + let dir = tempfile::tempdir().unwrap(); + let client = dir.path().join("wow"); + fs::create_dir_all(client.join("Data")).unwrap(); + fs::write(client.join("Wow.exe"), b"x").unwrap(); + assert_eq!(scan(&client).unwrap().classification, Classification::Incompatible); + let docs = dir.path().join("Documents"); + fs::create_dir_all(&docs).unwrap(); + fs::write(docs.join("cv.docx"), b"x").unwrap(); + assert_eq!(scan(&docs).unwrap().classification, Classification::Incompatible); + } + + #[test] + fn banner_takes_the_last_occurrence() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("Server.log"); + fs::write(&p, "AzerothCore rev. aaaaaaaaaaaa x\nnoise\nAzerothCore rev. bbbbbbbbbbbb y\n").unwrap(); + assert_eq!(banner_revision_in_log(&p).as_deref(), Some("bbbbbbbbbbbb")); + assert_eq!(banner_revision_in_log(&dir.path().join("none")), None); + } + + fn tree(root: &Path) -> Vec<(String, u64)> { + fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) { + for e in fs::read_dir(dir).unwrap() { + let e = e.unwrap(); + let md = e.metadata().unwrap(); + out.push((e.path().strip_prefix(root).unwrap().to_string_lossy().into_owned(), md.len())); + if md.is_dir() { + walk(&e.path(), root, out); + } + } + } + let mut out = Vec::new(); + walk(root, root, &mut out); + out.sort(); + out + } +} diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 65fd472..a834e1a 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -1,6 +1,8 @@ pub mod error; pub mod fsx; +pub mod layout; pub mod logging; +pub mod process; pub mod manifest; pub mod registry; diff --git a/crates/coa-core/src/process.rs b/crates/coa-core/src/process.rs new file mode 100644 index 0000000..6335400 --- /dev/null +++ b/crates/coa-core/src/process.rs @@ -0,0 +1,318 @@ +//! Process identity and observation. A process belongs to an installation only if its executable path and +//! creation time say so; a bare PID or an image name is never trusted. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::fsx; +use crate::layout::Ports; + +/// Same shape as the repack's own `.state/.json`, so records are interchangeable with its scripts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcessIdentity { + pub pid: u32, + pub exe: String, + /// Windows FILETIME of process creation (100 ns ticks since 1601). + pub created: u64, +} + +impl ProcessIdentity { + pub fn same_as(&self, other: &ProcessIdentity) -> bool { + self.pid == other.pid && self.created == other.created && self.exe.eq_ignore_ascii_case(&other.exe) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ServiceState { + Stopped, + Starting, + Running, + Stopping, + Crashed, + Updating, + Unknown, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PortConflict { + pub port: u16, + pub pid: u32, + pub exe: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ServiceStatus { + pub name: &'static str, + pub state: ServiceState, + pub pid: Option, + pub port: u16, + /// Our process is the one listening on the port. + pub port_ready: bool, + /// Something else is listening on our port. + pub conflict: Option, + /// Seconds since the process started. + pub uptime_secs: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Observed { + pub mysql: ServiceStatus, + pub auth: ServiceStatus, + pub world: ServiceStatus, +} + +pub fn read_state_record(root: &Path, name: &str) -> Option { + fsx::read_json(&root.join(".state").join(format!("{name}.json"))).ok() +} + +#[cfg(windows)] +mod sys { + use super::ProcessIdentity; + use windows_sys::Win32::Foundation::{CloseHandle, FILETIME, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::NetworkManagement::IpHelper::GetExtendedTcpTable; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, + }; + use windows_sys::Win32::System::SystemInformation::GetSystemTimeAsFileTime; + use windows_sys::Win32::System::Threading::{ + GetProcessTimes, OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + fn ft(f: FILETIME) -> u64 { + ((f.dwHighDateTime as u64) << 32) | f.dwLowDateTime as u64 + } + + pub fn identity(pid: u32) -> Option { + unsafe { + let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if h.is_null() { + return None; + } + let mut buf = vec![0u16; 32768]; + let mut size = buf.len() as u32; + let ok_name = QueryFullProcessImageNameW(h, 0, buf.as_mut_ptr(), &mut size); + let (mut c, mut e, mut k, mut u) = (std::mem::zeroed(), std::mem::zeroed(), std::mem::zeroed(), std::mem::zeroed()); + let ok_time = GetProcessTimes(h, &mut c, &mut e, &mut k, &mut u); + CloseHandle(h); + if ok_name == 0 || ok_time == 0 || ft(e) != 0 { + return None; + } + Some(ProcessIdentity { pid, exe: String::from_utf16_lossy(&buf[..size as usize]), created: ft(c) }) + } + } + + pub fn all_pids() -> Vec { + let mut out = Vec::new(); + unsafe { + let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snap == INVALID_HANDLE_VALUE { + return out; + } + let mut entry: PROCESSENTRY32W = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + let mut more = Process32FirstW(snap, &mut entry); + while more != 0 { + out.push(entry.th32ProcessID); + more = Process32NextW(snap, &mut entry); + } + CloseHandle(snap); + } + out + } + + pub fn now_filetime() -> u64 { + unsafe { + let mut f: FILETIME = std::mem::zeroed(); + GetSystemTimeAsFileTime(&mut f); + ft(f) + } + } + + fn table(af: u32) -> Vec { + const TCP_TABLE_OWNER_PID_LISTENER: i32 = 3; + 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_LISTENER, + 0, + ) + }; + match code { + 0 => return buf, + 122 => buf = vec![0u8; size as usize + 1024], + _ => return Vec::new(), + } + } + Vec::new() + } + + /// (port, owning pid) for every TCP listener, IPv4 and IPv6. + pub fn listeners() -> Vec<(u16, u32)> { + let mut out = Vec::new(); + let u32_at = |b: &[u8], o: usize| u32::from_le_bytes(b[o..o + 4].try_into().unwrap()); + let v4 = table(2); + if v4.len() >= 4 { + let n = u32_at(&v4, 0) as usize; + for i in 0..n { + let o = 4 + 24 * i; + if o + 24 > v4.len() { + break; + } + out.push((u16::from_be((u32_at(&v4, o + 8) & 0xFFFF) as u16), u32_at(&v4, o + 20))); + } + } + let v6 = table(23); + if v6.len() >= 4 { + let n = u32_at(&v6, 0) as usize; + for i in 0..n { + let o = 4 + 56 * i; + if o + 56 > v6.len() { + break; + } + out.push((u16::from_be((u32_at(&v6, o + 20) & 0xFFFF) as u16), u32_at(&v6, o + 52))); + } + } + out + } +} + +#[cfg(not(windows))] +mod sys { + use super::ProcessIdentity; + pub fn identity(_: u32) -> Option { + None + } + pub fn all_pids() -> Vec { + Vec::new() + } + pub fn now_filetime() -> u64 { + 0 + } + pub fn listeners() -> Vec<(u16, u32)> { + Vec::new() + } +} + +pub use sys::{identity, listeners}; + +/// True if the recorded identity still describes a live process (same exe and creation time). +pub fn is_alive(record: &ProcessIdentity) -> bool { + identity(record.pid).map(|cur| cur.same_as(record)).unwrap_or(false) +} + +/// Every running process whose image is exactly `exe`. +pub fn find_by_exe(exe: &Path) -> Vec { + let want = exe.to_string_lossy().to_lowercase().replace('/', "\\"); + sys::all_pids() + .into_iter() + .filter_map(identity) + .filter(|p| p.exe.to_lowercase() == want) + .collect() +} + +fn uptime_secs(p: &ProcessIdentity) -> Option { + let now = sys::now_filetime(); + (now >= p.created).then(|| (now - p.created) / 10_000_000) +} + +fn service(root: &Path, name: &'static str, exe: PathBuf, port: u16, listen: &[(u16, u32)]) -> ServiceStatus { + // Prefer the repack's own record when it still matches a live process; otherwise discover by exact exe path. + let proc = read_state_record(root, name) + .filter(is_alive) + .filter(|r| r.exe.eq_ignore_ascii_case(&exe.to_string_lossy().replace('/', "\\"))) + .or_else(|| find_by_exe(&exe).into_iter().next()); + + let owner = listen.iter().find(|(p, _)| *p == port).map(|(_, pid)| *pid); + let (state, port_ready, conflict) = match (&proc, owner) { + (Some(p), Some(o)) if p.pid == o => (ServiceState::Running, true, None), + (Some(_), _) => (ServiceState::Starting, false, None), + (None, Some(o)) => { + (ServiceState::Stopped, false, Some(PortConflict { port, pid: o, exe: identity(o).map(|i| i.exe) })) + } + (None, None) => (ServiceState::Stopped, false, None), + }; + ServiceStatus { + name, + state, + pid: proc.as_ref().map(|p| p.pid), + port, + port_ready, + conflict, + uptime_secs: proc.as_ref().and_then(uptime_secs), + } +} + +/// Snapshot the three services of an installation without touching anything. +pub fn observe(root: &Path, ports: &Ports) -> Observed { + let listen = listeners(); + let (world, auth, mysql) = crate::layout::executables(root); + Observed { + mysql: service(root, "mysql", mysql, ports.mysql, &listen), + auth: service(root, "auth", auth, ports.auth, &listen), + world: service(root, "world", world, ports.world, &listen), + } +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn identity_of_self_is_stable_and_alive() { + let me = identity(std::process::id()).unwrap(); + assert!(me.exe.to_lowercase().ends_with(".exe")); + assert!(is_alive(&me)); + let mut forged = me.clone(); + forged.created += 1; + assert!(!is_alive(&forged), "different creation time = different process (PID reuse)"); + let mut wrong_exe = me.clone(); + wrong_exe.exe = "C:\\other.exe".into(); + assert!(!is_alive(&wrong_exe)); + assert!(identity(0xFFFF_FF00).is_none()); + } + + #[test] + fn finds_own_listener_and_exe() { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let me = std::process::id(); + assert!(listeners().contains(&(port, me))); + let exe = identity(me).unwrap().exe; + assert!(find_by_exe(Path::new(&exe)).iter().any(|p| p.pid == me)); + } + + #[test] + fn observe_reports_foreign_listener_as_conflict_not_as_our_server() { + let dir = tempfile::tempdir().unwrap(); + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let ports = Ports { mysql: port, auth: 1, world: 2, ra: 3 }; + let o = observe(dir.path(), &ports); + assert_eq!(o.mysql.state, ServiceState::Stopped); + assert!(!o.mysql.port_ready); + assert_eq!(o.mysql.conflict.as_ref().unwrap().pid, std::process::id()); + } + + #[test] + fn stale_state_record_is_not_trusted() { + let dir = tempfile::tempdir().unwrap(); + let state = dir.path().join(".state"); + std::fs::create_dir_all(&state).unwrap(); + let me = identity(std::process::id()).unwrap(); + // Right PID, wrong creation time => stale record after PID reuse. + let stale = ProcessIdentity { created: me.created + 12345, ..me }; + std::fs::write(state.join("world.json"), serde_json::to_vec(&stale).unwrap()).unwrap(); + let o = observe(dir.path(), &Ports::default()); + assert_eq!(o.world.state, ServiceState::Stopped); + assert!(o.world.pid.is_none()); + } +}