mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Phase 2a: read-only scan/classification and process observation (verified against live repack)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c39ff556a3
commit
e1b8267022
7 files changed
+865
-9
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
Generated
+84
-9
@@ -82,6 +82,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -664,7 +665,16 @@ version = "0.59.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -682,14 +692,31 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu",
|
"windows_i686_gnu 0.52.6",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm 0.52.6",
|
||||||
"windows_i686_msvc",
|
"windows_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc",
|
"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]]
|
[[package]]
|
||||||
@@ -698,48 +725,96 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
@@ -18,3 +18,9 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["f
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
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 = []
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let path = std::env::args().nth(1).expect("usage: scan <server folder>");
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReleaseJson {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub release_date: Option<String>,
|
||||||
|
pub main_revision: Option<String>,
|
||||||
|
pub source_revision: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub binaries: Vec<ReleaseBinary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Item>,
|
||||||
|
pub worldserver: Option<ExeInfo>,
|
||||||
|
pub authserver: Option<ExeInfo>,
|
||||||
|
pub release: Option<ReleaseJson>,
|
||||||
|
/// Core commit as printed by the worldserver banner (12 hex) when found in its log.
|
||||||
|
pub banner_revision: Option<String>,
|
||||||
|
pub module_configs: Vec<String>,
|
||||||
|
pub bot_config_keys: usize,
|
||||||
|
pub ports: Ports,
|
||||||
|
pub database_schemas: Vec<String>,
|
||||||
|
pub client: Option<ClientInfo>,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
/// 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<String>) -> Item {
|
||||||
|
Item { key, label, status: if present { Status::Found } else { Status::Missing }, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_exe(path: &Path, release: Option<&ReleaseJson>, name: &str) -> Option<ExeInfo> {
|
||||||
|
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<u16>,
|
||||||
|
auth_port: Option<u16>,
|
||||||
|
world_port: Option<u16>,
|
||||||
|
ra_port: Option<u16>,
|
||||||
|
}
|
||||||
|
let d = Ports::default();
|
||||||
|
match fsx::read_json::<P>(&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. <hex>` 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<String> {
|
||||||
|
const WINDOW: u64 = 4 * 1024 * 1024;
|
||||||
|
let len = File::open(log).ok()?.metadata().ok()?.len();
|
||||||
|
let read = |start: u64| -> Option<String> {
|
||||||
|
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<String> {
|
||||||
|
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<ClientInfo> {
|
||||||
|
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<ScanReport> {
|
||||||
|
let root = fsx::canonicalize_lenient(root)?;
|
||||||
|
let mut notes = Vec::new();
|
||||||
|
|
||||||
|
let release: Option<ReleaseJson> = 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<String> = 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<String> = ["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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod fsx;
|
pub mod fsx;
|
||||||
|
pub mod layout;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
pub mod process;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
|
||||||
|
|||||||
@@ -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/<name>.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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ServiceStatus {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub state: ServiceState,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
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<PortConflict>,
|
||||||
|
/// Seconds since the process started.
|
||||||
|
pub uptime_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<ProcessIdentity> {
|
||||||
|
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<ProcessIdentity> {
|
||||||
|
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<u32> {
|
||||||
|
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::<PROCESSENTRY32W>() 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<u8> {
|
||||||
|
const TCP_TABLE_OWNER_PID_LISTENER: i32 = 3;
|
||||||
|
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_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<ProcessIdentity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
pub fn all_pids() -> Vec<u32> {
|
||||||
|
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<ProcessIdentity> {
|
||||||
|
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<u64> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user