diff --git a/Cargo.lock b/Cargo.lock index 809b6e0..6aca5b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.4" +version = "0.6.6" dependencies = [ "base64 0.22.1", "chrono", @@ -499,6 +499,7 @@ dependencies = [ "ed25519-dalek", "fs4", "hex", + "if-addrs", "num-bigint", "rand_core", "reqwest 0.12.28", @@ -520,7 +521,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.4" +version = "0.6.6" dependencies = [ "chrono", "coa-core", @@ -529,7 +530,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.4" +version = "0.6.6" dependencies = [ "coa-core", "serde", @@ -1947,6 +1948,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "indexmap" version = "1.9.3" diff --git a/Cargo.toml b/Cargo.toml index 2f57649..2c16456 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/coa-core", "crates/coa-release", "src-tauri"] [workspace.package] -version = "0.6.4" +version = "0.6.6" edition = "2021" license = "AGPL-3.0-only" diff --git a/HOTFIX_READINESS.md b/HOTFIX_READINESS.md new file mode 100644 index 0000000..ab03652 --- /dev/null +++ b/HOTFIX_READINESS.md @@ -0,0 +1,104 @@ +# Urgent update hotfix + +Deadline: 2026-10-06. This checkout starts at the exact v0.6.4 tag, commit +6c771fa, and excludes the ongoing isolated-trial/recovery redesign. + +The client accepts checksum compatibility only from a verified signed manifest and only +for an Applied migration. It never updates the historical checksum, reruns historical +SQL, or excuses a Running/Failed ledger row. A server package must explicitly list the +verified published variants and append corrections under new migration IDs. + +Current local evidence: the locked workspace tests passed, including 252 core tests +run serially. Six frontend logic tests and the frontend production build passed. +The hotfix runtime was tested against the exact signed candidate manifest on the marked +CoA/Wildcard fixture. Both published repair checksums were accepted independently in +both worlds, with zero migration executions, unchanged historical checksums, and unchanged +account, character and inventory counts. The original ledger rows were restored after +the test, and all fixture processes were stopped. A real Running or Failed repair row in +either world was rejected before staging or creating a transaction, with unchanged world +executable and installation metadata. Tests use independent hotfix metadata so experimental +transaction formats are not confused with the 0.6.4 upgrade path. Windows journal writes also use flushed +atomic replacement with long-path support. Changelog validation passed. + +Additional guards implemented: + +- Read migration history without creating its ledger before downloading or replacing files. +- Reject missing journals, invalid identities, paths, hashes and inconsistent operations. + Durable completed-cleanup markers distinguish interrupted pruning from a lost journal. +- Preserve all backups while an update is unfinished or its journal is damaged. +- Verify saved-file hashes and the complete database/configuration recovery point before + stopping services or changing files/databases. New snapshots include configuration hashes; + update journals bind their backup metadata to a hash. +- Surface state-reading errors in Settings and disable applying until the disk state is + successfully read. Checking again refreshes that state. +- Verify every extracted SQL artifact before taking the update snapshot or replacing + server files; missing files, invalid IDs, duplicate migrations and checksum disagreements + reject the update early. SQL application repeats the integrity check immediately before use. +- Count unapplied migrations in the active and secondary realms, counting shared auth once. + Matching versions and files do not hide remaining database work. Preview reads history + under the installation lock; a stopped database is temporarily started for inspection + and stopped again when no game services are running. +- Reread the disk state after every update/recovery attempt, including failed attempts and + background updates. Unfinished or unreadable state blocks another application in the UI. +- Acquire the installation lock before checking and deleting a backup. + +Latest real-runtime test used the independently copied, doubly marked fixture +`C:\games\coa-schema-fixture-chaos-20261005-v1` and signed candidate `0.261005.24`, manifest SHA-256 +`1284b1273815209c9621b6d173a2211bae316f33462ccbe8b578e53b8d26fb5b`. +All 1748 SQL artifacts passed verification after authenticated archive extraction. +Removing one repair record separately in CoA and Wildcard increased the pending count +by exactly one; restoring its original record restored the original count. No migrations +were executed, player counts were preserved and fixture services were stopped afterward. +The source checkout remains uncommitted; these results are not an installer acceptance report. + +Additional acceptance on 2026-10-06: a new independent installation/database copy at +`C:\games\coa-schema-fixture-chaos-064-transition-20261006` received the authenticated +published `0.261005.22` program files, original CRLF repair checksum, and a deliberately +missing Wildcard table in both realms. The production hotfix update pipeline committed +signed candidate `0.261005.24`, repaired both realms, passed schema checks and actual +bundled-Python server startup, and preserved the selected account, character, item and +inventory fields byte-for-byte. The fixture was stopped afterward. The harness explicitly +instruments the launcher to prevent relay submissions and bind to localhost; this is not +proof of packaged UI behavior or operating-system network containment. + +Local installer `C:\games\source\manager-065-local-installer-20261006\CoA Server Manager_0.6.5_x64-setup.exe` +has SHA-256 `520ee0391d923b92da0a20591e30006c048cdb60156275d7a829918bf1b77491`. +Its updater signature and the published 0.6.4 installer's signature passed independent +verification with the configured updater public key. Both executables extracted from +their exact NSIS packages started, created responsive windows, and were stopped using +an independent application/WebView profile. No installer was executed over a user's +installation, and no UI update operation was exercised. + +Legacy limitation: old 0.6.4 transaction copies and configuration snapshots without recorded +hashes can be checked for presence and declared database hashes, but their original file +contents cannot be authenticated retroactively. Missing copies block restoration. This +hotfix does not claim that old unchecksummed copies received full integrity verification. + +Remaining release requirements: reproduce the existing-user failures through the published +0.6.4 interface, verify legacy unfinished transaction handling on real-runtime fixtures, +and execute the exact installer over 0.6.4 in an isolated Windows environment with UI +state/settings/server-list verification. Windows native UI automation is unavailable in +this session. Packaged startup and signature checks do not satisfy that installer gate. +No public release has been made. + +The user subsequently exercised the packaged UI: candidate 0.261005.24 committed and +both CoA and Wildcard were shown running. Repeat-check/restart acceptance is still pending. +The screenshots exposed an older published package being offered after the local candidate +was installed. Follow-up changes reject older packages before preview/database inspection +and application and clear a cached offer after a failed recheck. All 253 core tests and +seven frontend tests passed. The earlier installer hash above does not include this follow-up; +a separately recorded revision is required. Native UI control was subsequently initialized +through the deferred node_repl tool, then the user stopped automation with Escape and +performed the UI check manually. + +Another user repeat-check exposed the already installed launcher being offered again. +The preview now verifies canonical launcher bytes against the current signed package, +caches those bytes by SHA-256, and compares only recognized Manager transformations. +The actual disposable fixture's repeat preview passed with all 104 files skipped and +zero pending SQL. User edits, recorded custom content, and a package with different +launcher bytes are not mistaken for that integration. All 254 core tests passed; +the strengthened regression test and seven frontend tests also passed. The first +preview of a managed launcher may download/extract its authenticated package to build +the optional canonical cache. Earlier installer revisions do not include this fix. + +Do not recommend deleting an installation or replacing its player databases. diff --git a/changelog.d/20261005-imported-squid-warning.md b/changelog.d/20261005-imported-squid-warning.md new file mode 100644 index 0000000..6e7d7b1 --- /dev/null +++ b/changelog.d/20261005-imported-squid-warning.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Imported SquidBots servers warn before replacing their original binary +--- +Imports explain that the original launcher and updater check the server binary hash. Updating that binary requires an explicit choice to keep or replace it. diff --git a/changelog.d/20261005-linux-docker-server-installer.md b/changelog.d/20261005-linux-docker-server-installer.md new file mode 100644 index 0000000..43457d3 --- /dev/null +++ b/changelog.d/20261005-linux-docker-server-installer.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Install a server from a package on Linux, running in Docker (experimental) +--- +Experimental. The Manager creates the database, imports the packaged baseline and starts the server in Docker containers. It needs a Linux server package and your own game data folder. diff --git a/changelog.d/20261005-squid-release-metadata.md b/changelog.d/20261005-squid-release-metadata.md new file mode 100644 index 0000000..4de32ab --- /dev/null +++ b/changelog.d/20261005-squid-release-metadata.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: SquidBots settings follow the options shipped by the module +--- +New settings use the module's descriptions, groups, defaults and value limits while retaining existing translations. Unknown formats or invalid fields use built-in controls so the settings page remains available. diff --git a/changelog.d/20261005-squid-version-reports.md b/changelog.d/20261005-squid-version-reports.md new file mode 100644 index 0000000..20b3cec --- /dev/null +++ b/changelog.d/20261005-squid-version-reports.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: SquidBots shows its bundled release and includes it in problem reports +--- +The Bots and Modules pages and problem reports include the release identifiers. Imported repacks use their recorded bot revision; empty tags are hidden. The settings file's source commit is not presented as the build revision. diff --git a/changelog.d/20261005-update-history-hotfix.md b/changelog.d/20261005-update-history-hotfix.md new file mode 100644 index 0000000..1edaa12 --- /dev/null +++ b/changelog.d/20261005-update-history-hotfix.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Server updates accept verified published migration variants without rerunning them +--- +Signed updates can recognise known published checksums without changing your migration history. Interrupted or failed SQL still requires recovery before retrying. diff --git a/changelog.d/20261005-update-legacy-realm-launcher.md b/changelog.d/20261005-update-legacy-realm-launcher.md new file mode 100644 index 0000000..7765599 --- /dev/null +++ b/changelog.d/20261005-update-legacy-realm-launcher.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Server updates replace older launchers adapted by the Manager for Wildcard +--- +Updates recognize the Manager’s own realm and import adjustments even when older metadata still records the original launcher. New server binaries receive their matching launcher; genuine custom edits still require your decision. diff --git a/changelog.d/20261006-ah-bot-needs-no-character-guid.md b/changelog.d/20261006-ah-bot-needs-no-character-guid.md new file mode 100644 index 0000000..c0034a8 --- /dev/null +++ b/changelog.d/20261006-ah-bot-needs-no-character-guid.md @@ -0,0 +1,8 @@ +--- +area: manager +type: changed +audience: admins +title: The Auction House Bot card no longer asks for a character GUID +--- +Turning the module and the seller on is enough: a server build with the automatic auction character creates its own account and character. The GUID field moved to the advanced settings. +The buyer option hint no longer says it spends the auction character's gold. diff --git a/changelog.d/20261006-lan-address-selector.md b/changelog.d/20261006-lan-address-selector.md new file mode 100644 index 0000000..118b21c --- /dev/null +++ b/changelog.d/20261006-lan-address-selector.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Choose and remember the local network address friends use to connect +--- +Select an adapter or custom IPv4 address when sharing on LAN. Your choice survives restarts and mode changes; unavailable addresses show a warning instead of silently switching adapters. diff --git a/changelog.d/20261006-npc-enchanter-placement-hint.md b/changelog.d/20261006-npc-enchanter-placement-hint.md new file mode 100644 index 0000000..8210ab8 --- /dev/null +++ b/changelog.d/20261006-npc-enchanter-placement-hint.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: The NPC Enchanter card explains how to place and remove the NPC +--- +The card now shows the two commands: `.npc add 601015` where you stand, and `.npc delete` on the targeted NPC. diff --git a/changelog.d/20261006-prevent-server-downgrades.md b/changelog.d/20261006-prevent-server-downgrades.md new file mode 100644 index 0000000..852ab7d --- /dev/null +++ b/changelog.d/20261006-prevent-server-downgrades.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Older server packages cannot replace a newer installation +--- +Update checks stop offering stale packages after an update. To undo an update, use its recovery point instead of applying an older server package. diff --git a/changelog.d/20261006-recognize-managed-launcher.md b/changelog.d/20261006-recognize-managed-launcher.md new file mode 100644 index 0000000..eb10e36 --- /dev/null +++ b/changelog.d/20261006-recognize-managed-launcher.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Checking again no longer offers an already installed launcher update +--- +The Manager recognizes its own launcher integration while still detecting user edits and changes in newer signed packages. diff --git a/changelog.d/20261006-squid-first-start-timeout.md b/changelog.d/20261006-squid-first-start-timeout.md new file mode 100644 index 0000000..8d8f7b3 --- /dev/null +++ b/changelog.d/20261006-squid-first-start-timeout.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Large SQUID database imports have more time to finish on first startup +--- +The Manager allows up to 45 minutes when SQUID Playerbots is enabled, covering its initial database import and bot creation. Ordinary server startup and shutdown keep their existing deadlines. diff --git a/changelog.d/20261006-update-preflight-and-recovery-guards.md b/changelog.d/20261006-update-preflight-and-recovery-guards.md new file mode 100644 index 0000000..8216096 --- /dev/null +++ b/changelog.d/20261006-update-preflight-and-recovery-guards.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Update history conflicts are detected before server files change +--- +Damaged update records block changes and remain visible. Recovery checks saved copies before restoring, and unfinished updates protect backups from deletion and cleanup. diff --git a/changelog.d/20261006-update-sql-and-status-guards.md b/changelog.d/20261006-update-sql-and-status-guards.md new file mode 100644 index 0000000..38e797b --- /dev/null +++ b/changelog.d/20261006-update-sql-and-status-guards.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Pending database updates remain visible when the server version matches +--- +SQL files are checked before replacing server files. Update and recovery attempts refresh their saved status, including errors. Backup deletion cannot race with an update. diff --git a/changelog.d/20261007-diagnostics-valid-json.md b/changelog.d/20261007-diagnostics-valid-json.md new file mode 100644 index 0000000..a321311 --- /dev/null +++ b/changelog.d/20261007-diagnostics-valid-json.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Diagnostic files with database checks stay readable after secrets are removed +--- +Removing a line that mentioned a secret could break the JSON files in a diagnostic package. Secret values are now replaced instead, so the files stay valid. diff --git a/changelog.d/20261007-one-click-diagnostics.md b/changelog.d/20261007-one-click-diagnostics.md new file mode 100644 index 0000000..9bf18f0 --- /dev/null +++ b/changelog.d/20261007-one-click-diagnostics.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: One button collects all diagnostics, including crashes and Windows crash records +--- +Report a problem collects logs, crash reports and small dumps, Windows crash records, update journals and the running server programs in one file, then shows its folder. Crashes without a server crash report are no longer missing. diff --git a/changelog.d/20261007-report-routing.md b/changelog.d/20261007-report-routing.md new file mode 100644 index 0000000..7820136 --- /dev/null +++ b/changelog.d/20261007-report-routing.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Problem reports go to the right project +--- +Choose what the problem is about: Manager and other modules, CoA Companions, or SQUID Playerbots. The report opens in that project's GitHub page with the versions filled in. The bot system that is switched on is preselected. diff --git a/crates/coa-core/Cargo.toml b/crates/coa-core/Cargo.toml index 990a4df..b707bcb 100644 --- a/crates/coa-core/Cargo.toml +++ b/crates/coa-core/Cargo.toml @@ -26,13 +26,14 @@ sha1 = "0.10" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] } tempfile = "3" rusqlite = { version = "0.40.2", features = ["bundled"] } +if-addrs = "0.15" [dev-dependencies] 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_System_Threading", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock"] } +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"] } [features] testkit = [] diff --git a/crates/coa-core/examples/coordinated_package.rs b/crates/coa-core/examples/coordinated_package.rs new file mode 100644 index 0000000..d7a3e6d --- /dev/null +++ b/crates/coa-core/examples/coordinated_package.rs @@ -0,0 +1,38 @@ +use std::{fs, path::PathBuf}; +use coa_core::{manifest::{Kind, Manifest}, package::{self, BuildOptions}, pkgsource::{self, Source}, release, signing, Result}; + +fn main() -> Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + let source = Source::Dir(args[1].clone().into()); + let (previous, _) = pkgsource::fetch_manifest(&source, signing::EMBEDDED_PUBLIC_KEY)?; + let tree = PathBuf::from(&args[2]); + if args[0] == "extract" { + assert!(!tree.exists(), "Use a new release tree"); + package::extract(&PathBuf::from(&args[1]), &previous, &tree, &|_, _| {})?; + println!("Verified and extracted {}: {} historical migrations", previous.version, previous.migrations.len()); + return Ok(()); + } + assert_eq!(args[0], "pack"); + let out = PathBuf::from(&args[3]); + assert!(!out.exists(), "Release addresses must be immutable"); + for migration in &previous.migrations { + let entry = previous.files.iter().find(|file| file.path.starts_with("_migrations/") && file.sha256 == migration.sha256).expect("Historical SQL entry"); + assert_eq!(coa_core::fsx::sha256_file(&tree.join(&entry.path))?, entry.sha256, "Historical SQL changed: {}", migration.id); + } + let mut manifest = package::build(&tree, &out, &BuildOptions { + kind: Kind::Update, version: args[4].clone(), core_commit: Some(args[5].clone()), + bots_commit: previous.bots.and_then(|revision| revision.commit), + built_at: chrono::Utc::now().to_rfc3339(), part_size: package::DEFAULT_PART_SIZE, + migrations: previous.migrations.clone(), + }, &|line| println!("{line}"))?; + manifest.min_manager_version = "0.6.5".into(); + manifest.validate()?; + coa_core::fsx::atomic_write(&out.join("manifest.json"), &serde_json::to_vec_pretty(&manifest)?)?; + let key_path = PathBuf::from(std::env::var("USERPROFILE").unwrap()).join(".coa-manager/signing/manifest-signing.key"); + release::sign_manifest(&out, &fs::read_to_string(key_path)?)?; + coa_core::release_schema::verify_package(&out, signing::EMBEDDED_PUBLIC_KEY)?; + let reread: Manifest = serde_json::from_slice(&fs::read(out.join("manifest.json"))?)?; + assert_eq!(serde_json::to_value(reread.migrations)?, serde_json::to_value(previous.migrations)?); + println!("PASS: signed {}; all historical migration bytes, checksums, aliases and order preserved", manifest.version); + Ok(()) +} diff --git a/crates/coa-core/examples/coordinated_squid_e2e.rs b/crates/coa-core/examples/coordinated_squid_e2e.rs new file mode 100644 index 0000000..814a752 --- /dev/null +++ b/crates/coa-core/examples/coordinated_squid_e2e.rs @@ -0,0 +1,70 @@ +use std::path::{Path, PathBuf}; +use coa_core::{backup, driver, fsx, Result}; + +fn set(root: &Path, filename: &str, values: &[(&str, &str)]) -> Result<()> { + let path = root.join("Core/configs/modules").join(filename); + let mut config = coa_core::config::parser::ConfFile::parse_bytes(&std::fs::read(&path)?)?; + for (key, value) in values { config.set(key, value, &[]); } + fsx::atomic_write(&path, config.to_text().as_bytes()) +} + +fn ledgers(root: &Path) -> Result> { + backup::with_database(root, |db| ["acore_playerbots", "acore_world", "acore_characters"].into_iter() + .map(|schema| db.query(&format!("SELECT path,sha256 FROM `{schema}`.coa_squid_migrations ORDER BY path;"))).collect()) +} + +fn exercise(root: &Path) -> Result<()> { + let meta = coa_core::registry::metadata_dir_for(root)?; + let point = backup::create(root, &meta, backup::Kind::Full, backup::Trigger::Manual, Some("Disposable SQUID acceptance".into()), &|stage| println!("{stage}"))?; + assert!(backup::verify(&meta, &point.id)?.ok); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> { + backup::with_database(root, |db| db.query("DROP DATABASE IF EXISTS acore_playerbots;").map(|_| ()))?; + assert!(driver::run(root, driver::Verb::StartAll)?.ok, "Disabled SQUID must start without its database"); + assert!(driver::run(root, driver::Verb::StopAll)?.ok); + coa_core::multiworld::set_enabled(root, false)?; + coa_core::realms::select(root, coa_core::realms::Mode::Coa)?; + set(root, "mod_coa_playerbots.conf", &[("CoaBots.Enable", "0")])?; + set(root, "playerbots.conf", &[("AiPlayerbot.Enabled", "1"), ("AiPlayerbot.RandomBotAutologin", "0"), ("AiPlayerbot.MinRandomBots", "0"), ("AiPlayerbot.MaxRandomBots", "0"), ("AiPlayerbot.RandomBotGuildCount", "0"), ("AiPlayerbot.RandomBotAccountCount", "0")])?; + let started = driver::run(root, driver::Verb::StartAll)?; + assert!(started.ok, "Fresh SQUID startup: {}", coa_core::diag::redact(&started.output)); + let before = ledgers(root)?; + assert!(!before[0].is_empty(), "Fresh SQUID history missing"); + assert!(driver::run(root, driver::Verb::StopAll)?.ok); + let started = driver::run(root, driver::Verb::StartAll)?; + assert!(started.ok, "Repeated SQUID startup: {}", coa_core::diag::redact(&started.output)); + assert_eq!(ledgers(root)?, before, "SQUID replay changed its migration history"); + println!("PASS: SQUID-disabled dual startup without its database; fresh enabled SQUID import and startup; repeated startup preserves all three histories"); + Ok(()) + })); + assert!(driver::run(root, driver::Verb::StopAll)?.ok); + restore(root, &meta, &point)?; + result.map_err(|_| coa_core::Error::Invalid("SQUID acceptance failed; disposable databases and configurations restored".into()))? +} + +fn restore(root: &Path, meta: &Path, point: &backup::RecoveryPoint) -> Result<()> { + assert!(backup::verify(meta, &point.id)?.ok); + assert!(driver::run(root, driver::Verb::StopAll)?.ok); + coa_core::realms::select(root, point.realm)?; + for component in point.components.iter().filter(|component| component.name != "configs") { + backup::restore_database(root, meta, &point.id, &component.name)?; + } + backup::restore_configs(root, meta, &point.id)?; + assert!(driver::run(root, driver::Verb::StopAll)?.ok); + println!("PASS: disposable SQUID acceptance databases and original realm configurations restored"); + Ok(()) +} + +fn main() -> Result<()> { + let root = PathBuf::from(std::env::args().nth(1).unwrap()); + assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-chaos-064-transition-")); + assert_eq!(std::fs::read(root.join(".transition-fixture"))?, b"disposable published-064 transition fixture"); + assert!(!root.join("Data").symlink_metadata()?.file_type().is_symlink()); + assert!(driver::run(&root, driver::Verb::StopAll)?.ok); + if let Some(id) = std::env::args().nth(2) { + let meta = coa_core::registry::metadata_dir_for(&root)?; + return restore(&root, &meta, &backup::get(&meta, &id)?); + } + let result = exercise(&root); + assert!(driver::run(&root, driver::Verb::StopAll)?.ok); + result +} diff --git a/crates/coa-core/examples/coordinated_update_e2e.rs b/crates/coa-core/examples/coordinated_update_e2e.rs new file mode 100644 index 0000000..98b5f77 --- /dev/null +++ b/crates/coa-core/examples/coordinated_update_e2e.rs @@ -0,0 +1,54 @@ +use std::{collections::BTreeMap, path::PathBuf}; +use coa_core::{backup, driver, registry::MetaDir, update::{self, RepackEnv}, Result}; + +fn players(db: &coa_core::db::Db) -> Result { + db.query("SELECT CONCAT(id,':',username,':',HEX(salt),':',HEX(verifier)) FROM acore_auth.account WHERE id=900001; SELECT CONCAT(guid,':',account,':',name,':',level,':',money) FROM acore_characters.characters WHERE guid=900001; SELECT CONCAT(guid,':',itemEntry,':',owner_guid,':',enchantments) FROM acore_characters.item_instance WHERE guid=900001; SELECT CONCAT(guid,':',bag,':',slot,':',item) FROM acore_characters.character_inventory WHERE guid=900001;") +} + +fn exercise(root: &std::path::Path, package: &str) -> Result<()> { + let meta_dir = coa_core::registry::metadata_dir_for(root)?; + let (_, meta) = MetaDir::open(&meta_dir)?; + let expected = backup::with_database(root, |db| [coa_core::realms::Mode::Coa, coa_core::realms::Mode::Wildcard].into_iter().map(|mode| players(&db.clone().for_realm(mode))).collect::>>())?; + assert!(driver::run(root, driver::Verb::StopAll)?.ok); + let source = coa_core::pkgsource::Source::Dir(package.into()); + let preview = update::preview(root, &meta, &source, coa_core::signing::EMBEDDED_PUBLIC_KEY, &BTreeMap::new())?; + assert_eq!(preview.pending_migrations, 0, "Historical SQL must not replay"); + assert!(!preview.items.iter().any(|item| item.action == update::Action::Conflict), "Unresolved candidate files: {:?}", preview.items); + let environment = RepackEnv { root, meta_dir: &meta_dir }; + let outcome = update::apply(&update::Params { + root, meta_dir: &meta_dir, source, trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, + resolutions: BTreeMap::new(), cancel: Default::default(), env: &environment, fail_after_ops: None, + }, &|stage, progress| println!("{progress}% {stage}"))?; + assert_eq!(outcome.txn.state, update::State::Committed, "{:?}", outcome.txn); + backup::with_database(root, |db| { + for (index, mode) in [coa_core::realms::Mode::Coa, coa_core::realms::Mode::Wildcard].into_iter().enumerate() { + let realm = db.clone().for_realm(mode); + assert_eq!(players(&realm)?, expected[index], "Player data changed in {mode:?}"); + let problems = coa_core::schema_check::check(&realm, root)?; + assert!(problems.is_empty(), "{problems:?}"); + } + Ok(()) + })?; + let release = coa_core::squid::release(root).expect("SQUID release metadata"); + println!("SQUID metadata: {release:?}"); + assert_eq!(coa_core::squid::fields(root)?.len(), 40); + let (_, installed) = MetaDir::open(&meta_dir)?; + let repeated = update::preview(root, &installed, &coa_core::pkgsource::Source::Dir(package.into()), coa_core::signing::EMBEDDED_PUBLIC_KEY, &BTreeMap::new())?; + assert_eq!(repeated.pending_migrations, 0); + assert!(repeated.items.iter().all(|item| item.action == update::Action::Skip), "{:?}", repeated.items); + println!("PASS: coordinated update committed, both realms started, player fields preserved, historical SQL skipped, repeated preview clean"); + Ok(()) +} + +fn main() -> Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + let root = PathBuf::from(&args[0]); + assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-chaos-064-transition-")); + assert_eq!(std::fs::read(root.join(".transition-fixture"))?, b"disposable published-064 transition fixture"); + assert_eq!(std::fs::read(root.join(".release-schema-fixture"))?, b"disposable release-schema fixture"); + assert!(!root.join("Data").symlink_metadata()?.file_type().is_symlink()); + assert!(driver::run(&root, driver::Verb::StopAll)?.ok); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| exercise(&root, &args[1]))); + assert!(driver::run(&root, driver::Verb::StopAll)?.ok, "Fixture shutdown failed"); + outcome.map_err(|_| coa_core::Error::Invalid("Disposable coordinated acceptance failed; stopped services and retained recovery copies".into()))? +} diff --git a/crates/coa-core/examples/docker_install.rs b/crates/coa-core/examples/docker_install.rs new file mode 100644 index 0000000..8a25248 --- /dev/null +++ b/crates/coa-core/examples/docker_install.rs @@ -0,0 +1,21 @@ +//! Install a new Docker server from a package folder, for a manual end-to-end check on Linux (needs docker). +//! usage: docker_install +//! The key is the one the package was signed with (the production key is only used by the Manager itself). +use coa_core::docker::install::{install, Params}; +use coa_core::download::Cancel; +use coa_core::install::Source; +use coa_core::registry::Registry; +use std::path::PathBuf; + +fn main() { + let a: Vec = std::env::args().skip(1).collect(); + assert!(a.len() == 4, "usage: docker_install "); + let registry = Registry::at(std::env::temp_dir().join("coa-docker-install/installs.json")); + let t = std::time::Instant::now(); + let done = install( + &Params { source: Source::Dir(PathBuf::from(&a[0])), dest: PathBuf::from(&a[1]), data_dir: PathBuf::from(&a[2]), trusted_key: &a[3], registry: ®istry, cancel: Cancel::default() }, + &|s| eprintln!(" {:>3}% {} {}", s.percent, s.step, s.detail.unwrap_or_default()), + ) + .unwrap_or_else(|e| panic!("install failed: {e}")); + println!("installed {} at {} in {:?}", done.version, done.path, t.elapsed()); +} diff --git a/crates/coa-core/examples/hotfix_history_e2e.rs b/crates/coa-core/examples/hotfix_history_e2e.rs new file mode 100644 index 0000000..8d12d55 --- /dev/null +++ b/crates/coa-core/examples/hotfix_history_e2e.rs @@ -0,0 +1,93 @@ +//! Verify published checksum compatibility on a disposable real database, without running SQL migrations. +use std::{path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}}; +use coa_core::{backup, db::Db, migrations::{self, LedgerRow, Status, Store}, pkgsource::{self, Source}, Result}; + +struct Observed<'a> { db: &'a Db, executions: AtomicUsize } +impl Store for Observed<'_> { + fn load(&self) -> Result> { self.db.load() } + fn put(&self, row: &LedgerRow) -> Result<()> { self.db.put(row) } + fn run_file(&self, _: &str, _: &Path) -> Result<()> { + self.executions.fetch_add(1, Ordering::SeqCst); + panic!("Historical SQL must never be replayed"); + } +} + +fn main() -> Result<()> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let root = PathBuf::from(&args[0]); + assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-chaos-")); + assert_eq!(std::fs::read(root.join(".coa-chaos-fixture"))?, b"disposable update chaos fixture"); + assert_eq!(std::fs::read(root.join(".release-schema-fixture"))?, b"disposable release-schema fixture"); + let source = Source::Dir(PathBuf::from(&args[1])); + let package = pkgsource::fetch_manifest(&source, coa_core::signing::EMBEDDED_PUBLIC_KEY)?.0; + let staging = tempfile::tempdir()?; + let parts = pkgsource::fetch_parts(&source, &package, &staging.path().join("download"), &Default::default(), &|_, _| {})?; + coa_core::package::extract(&parts, &package, &staging.path().join("tree"), &|_, _| {})?; + migrations::verify_files(&package.migrations, &staging.path().join("tree/_migrations"))?; + println!("PASS: all {} signed-package SQL artifacts verified before any database mutation", package.migrations.len()); + let migration = package.migrations.iter().find(|m| m.id == "manager_repair__20261005_missing_wildcard_tables").unwrap(); + let hashes = ["c1d8dbf2271234283a48a39e4b4aea1106b83b70581f1e3c4aa9fff438e8e4d2", "9e4a36245c3f83255415c122c01f08ceb3e3b83898d3d427ac4e2fbcf61e9638"]; + for hash in hashes { assert!(migration.sha256 == hash || migration.compatible_sha256.iter().any(|h| h == hash)); } + let fixture_meta = coa_core::registry::metadata_dir_for(&root)?; + let test_meta = tempfile::tempdir()?; + coa_core::fsx::atomic_write(&test_meta.path().join("install.json"), &std::fs::read(fixture_meta.join("install.json"))?)?; + coa_core::registry::MetaDir::open(test_meta.path())?; + std::fs::create_dir_all(test_meta.path().join("updates"))?; + let result = backup::with_database(&root, |db| { + let mut modes = vec![coa_core::realms::Mode::Coa]; + if coa_core::realms::state(&root)?.wildcard_created { modes.push(coa_core::realms::Mode::Wildcard); } + for mode in modes { + let db = db.clone().for_realm(mode); + let saved = db.load()?.into_iter().find(|row| row.id == migration.id && row.db == migration.db).expect("Published repair must already be applied"); + assert_eq!(saved.status, Status::Applied); + let counts = db.query("SELECT CONCAT((SELECT COUNT(*) FROM acore_characters.characters),':',(SELECT COUNT(*) FROM acore_characters.item_instance),':',(SELECT COUNT(*) FROM acore_auth.account));")?; + let check = || -> Result<()> { + for hash in hashes { + db.put(&LedgerRow { sha256: hash.into(), ..saved.clone() })?; + let observed = Observed { db: &db, executions: AtomicUsize::new(0) }; + migrations::preflight(&db, std::slice::from_ref(migration))?; + let report = migrations::apply_pending(&observed, std::slice::from_ref(migration), &root.join(".unused-hotfix-sql"), &|| panic!("Already applied SQL needs no snapshot"))?; + assert!(report.applied.is_empty()); + assert_eq!(observed.executions.load(Ordering::SeqCst), 0); + assert_eq!(db.load()?.into_iter().find(|r| r.id == migration.id && r.db == migration.db).unwrap().sha256, hash); + } + let meta = test_meta.path(); + let file_hash = coa_core::fsx::sha256_file(&root.join("Core/worldserver.exe"))?; + let install_hash = coa_core::fsx::sha256_file(&meta.join("install.json"))?; + let transaction_count = std::fs::read_dir(meta.join("updates"))?.count(); + for status in [Status::Running, Status::Failed] { + db.put(&LedgerRow { status, ..saved.clone() })?; + let env = coa_core::update::RepackEnv { root: &root, meta_dir: meta }; + let rejected = coa_core::update::apply(&coa_core::update::Params { + root: &root, meta_dir: meta, source: Source::Dir(PathBuf::from(&args[1])), + trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, cancel: Default::default(), + resolutions: Default::default(), env: &env, fail_after_ops: None, + }, &|_, _| {}); + let message = rejected.unwrap_err().to_string(); + assert!(message.contains("partially applied"), "Unexpected rejection: {message}"); + assert_eq!(std::fs::read_dir(meta.join("updates"))?.count(), transaction_count); + assert_eq!(coa_core::fsx::sha256_file(&root.join("Core/worldserver.exe"))?, file_hash); + assert_eq!(coa_core::fsx::sha256_file(&meta.join("install.json"))?, install_hash); + } + db.put(&saved)?; + let env = coa_core::update::RepackEnv { root: &root, meta_dir: meta }; + let pending_before = env.pending_migrations(&package)?; + db.query("DELETE FROM acore_world.coa_manager_migrations WHERE db='characters' AND id='manager_repair__20261005_missing_wildcard_tables';")?; + assert_eq!(env.pending_migrations(&package)?, pending_before + 1, + "Missing history in either realm must remain visible even with identical server files"); + db.put(&saved)?; + assert_eq!(env.pending_migrations(&package)?, pending_before); + Ok(()) + }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(check)); + db.put(&saved)?; + result.map_err(|_| coa_core::Error::Invalid("History regression assertion failed; the injected ledger row was restored.".into()))??; + assert_eq!(db.query("SELECT CONCAT((SELECT COUNT(*) FROM acore_characters.characters),':',(SELECT COUNT(*) FROM acore_characters.item_instance),':',(SELECT COUNT(*) FROM acore_auth.account));")?, counts); + println!("PASS: {} published checksums; Running/Failed rejected before staging; missing migration counted; zero SQL replays; history and player counts preserved", mode.name()); + } + Ok(()) + }); + assert!(coa_core::driver::run(&root, coa_core::driver::Verb::StopAll)?.ok); + result?; + Ok(()) +} diff --git a/crates/coa-core/examples/hotfix_preview_e2e.rs b/crates/coa-core/examples/hotfix_preview_e2e.rs new file mode 100644 index 0000000..63d6e07 --- /dev/null +++ b/crates/coa-core/examples/hotfix_preview_e2e.rs @@ -0,0 +1,16 @@ +//! Read-only update preview against the marked disposable acceptance fixture. +use std::{collections::BTreeMap,path::PathBuf}; +fn main()->coa_core::Result<()> { + let args:Vec<_>=std::env::args().skip(1).collect(); + let root=PathBuf::from(&args[0]); + assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-chaos-064-transition-")); + assert_eq!(std::fs::read(root.join(".transition-fixture"))?,b"disposable published-064 transition fixture"); + let meta_dir=coa_core::registry::metadata_dir_for(&root)?; + let (_,meta)=coa_core::registry::MetaDir::open(&meta_dir)?; + let preview=coa_core::update::preview(&root,&meta,&coa_core::pkgsource::Source::Dir(args[1].clone().into()),coa_core::signing::EMBEDDED_PUBLIC_KEY,&BTreeMap::new())?; + assert_eq!(preview.from_version.as_deref(),Some(preview.to_version.as_str())); + assert_eq!(preview.pending_migrations,0); + assert!(preview.items.iter().all(|item|item.action==coa_core::update::Action::Skip),"{:?}",preview.items.iter().filter(|item|item.action!=coa_core::update::Action::Skip).collect::>()); + println!("PASS: real fixture repeated preview: {} files unchanged, zero pending SQL, no update offered",preview.items.len()); + Ok(()) +} diff --git a/crates/coa-core/examples/hotfix_transition_e2e.rs b/crates/coa-core/examples/hotfix_transition_e2e.rs new file mode 100644 index 0000000..2157732 --- /dev/null +++ b/crates/coa-core/examples/hotfix_transition_e2e.rs @@ -0,0 +1,165 @@ +//! Real signed-package transition on an independent, explicitly marked disposable copy. +//! The Env wrapper instruments only launcher isolation; migration/backup/update code is production code. +use std::{collections::BTreeMap, path::{Path, PathBuf}}; +use coa_core::{backup, driver, fsx, migrations, pkgsource::{self, Source}, registry::{InstallMeta, MetaDir}, update::{self, Env, RepackEnv}, Result}; + +struct Offline<'a>(RepackEnv<'a>); +impl Env for Offline<'_> { + fn ensure_stopped(&self) -> Result<()> { self.0.ensure_stopped() } + fn preflight(&self, m: &coa_core::manifest::Manifest) -> Result<()> { self.0.preflight(m) } + fn verify_snapshot(&self, id: &str) -> Result<()> { self.0.verify_snapshot(id) } + fn snapshot(&self) -> Result { self.0.snapshot() } + fn restore_snapshot(&self, id: &str) -> Result<()> { self.0.restore_snapshot(id) } + fn migrate(&self, m: &coa_core::manifest::Manifest, p: &Path) -> Result { self.0.migrate(m,p) } + fn validate(&self) -> Result<()> { + let path=self.0.root.join("Scripts/manage.py"); + let source=std::fs::read_to_string(&path)?.replace("\r\n", "\n"); + // Explicit test instrumentation prevents relay transmissions and binds services locally. + let guarded=source.replace("def spawn(name, command, cwd, log):\n", "def spawn(name, command, cwd, log):\n for filename in ('worldserver.conf', 'authserver.conf'):\n path = ROOT / 'Core/configs' / filename\n if path.exists():\n text = path.read_text(encoding='utf-8-sig')\n for key, value in (('BindIP', '\"127.0.0.1\"'), ('Ra.IP', '\"127.0.0.1\"'), ('SOAP.Enabled', '0')):\n text = re.sub(r'(?m)^\\s*' + re.escape(key) + r'\\s*=.*$', '', text)\n text += '\\n' + key + ' = ' + value + '\\n'\n path.write_text(text, encoding='utf-8')\n").replace("def relay_worker():\n", "def relay_worker():\n save_process('relay-ready', os.getpid())\n while not (STATE / 'stop-relay').exists():\n time.sleep(0.5)\n return\n"); + assert_ne!(source,guarded,"Fixture launcher isolation failed"); + fsx::atomic_write(&path,guarded.as_bytes())?; + self.0.validate() + } +} +fn players(db: &coa_core::db::Db) -> Result { + db.query("SELECT CONCAT(id,':',username,':',HEX(salt),':',HEX(verifier)) FROM acore_auth.account WHERE id=900001; SELECT CONCAT(guid,':',account,':',name,':',level,':',money) FROM acore_characters.characters WHERE guid=900001; SELECT CONCAT(guid,':',itemEntry,':',owner_guid,':',enchantments) FROM acore_characters.item_instance WHERE guid=900001; SELECT CONCAT(guid,':',bag,':',slot,':',item) FROM acore_characters.character_inventory WHERE guid=900001;") +} +fn main() -> Result<()> { + let args:Vec<_>=std::env::args().skip(1).collect(); + let root=PathBuf::from(&args[0]); + assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-chaos-064-transition-")); + assert_eq!(std::fs::read(root.join(".transition-fixture"))?,b"disposable published-064 transition fixture"); + if args.get(3).is_some_and(|mode| mode=="legacy-recovery") { + return run_fixture(&root,||legacy_recovery(&root)); + } + assert!(!root.join(".transition-started").exists(),"Use a new independent fixture"); + fsx::atomic_write(&root.join(".transition-started"),b"started")?; + let meta=coa_core::registry::metadata_dir_for(&root)?; + assert!(!meta.exists(),"Do not reuse experimental or user metadata"); + run_fixture(&root,||exercise(&root,&meta,&args[1],&args[2])) +} +fn run_fixture(root:&Path,operation:impl FnOnce()->Result<()>)->Result<()> { + let result=std::panic::catch_unwind(std::panic::AssertUnwindSafe(operation)); + assert!(driver::run(root,driver::Verb::StopAll)?.ok,"Fixture shutdown failed"); + result.map_err(|_|coa_core::Error::Invalid("Disposable acceptance assertion failed; services were stopped and recovery copies retained.".into()))? +} +fn legacy_recovery(root:&Path)->Result<()> { + let meta=coa_core::registry::metadata_dir_for(root)?; + assert!(driver::run(root,driver::Verb::StopAll)?.ok); + let expected=backup::with_database(root,|db| [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard].into_iter().map(|mode|players(&db.clone().for_realm(mode))).collect::>>())?; + assert!(driver::run(root,driver::Verb::StopAll)?.ok); + let dirs=std::fs::read_dir(meta.join("updates"))?.collect::>>()?; + assert_eq!(dirs.len(),1,"Requires exactly the preceding successful disposable transition"); + let txn_dir=dirs[0].path(); + let journal=txn_dir.join("txn.json"); + let mut legacy:serde_json::Value=fsx::read_json(&journal)?; + assert_eq!(legacy["state"],"committed"); + let point=legacy["recovery_point"].as_str().unwrap().to_string(); + let point_path=meta.join("backups").join(&point).join("backup.json"); + let mut point_json:serde_json::Value=fsx::read_json(&point_path)?; + point_json["manager_version"]="0.6.4".into(); + for component in point_json["components"].as_array_mut().unwrap() { component.as_object_mut().unwrap().remove("file_sha256"); } + fsx::atomic_write_json(&point_path,&point_json)?; + legacy.as_object_mut().unwrap().remove("recovery_hashes"); + legacy.as_object_mut().unwrap().remove("databases_started"); + legacy["state"]="applied".into(); + fsx::atomic_write_json(&journal,&legacy)?; + assert!(update::ensure_recovered(&meta).is_err(),"Legacy unfinished update must block startup"); + let id=legacy["id"].as_str().unwrap(); + let env=RepackEnv {root,meta_dir:&meta}; + println!("Recovering legacy-format transaction and recovery point on disposable installation"); + assert_eq!(update::rollback(root,&meta,id,&env)?.state,update::State::RolledBack); + for op in legacy["ops"].as_array().unwrap() { + let relative=op["path"].as_str().unwrap(); + if op["had_previous"]==true { + assert_eq!(fsx::sha256_file(&fsx::safe_join(root,relative)?)?,fsx::sha256_file(&fsx::safe_join(&txn_dir.join("before"),relative)?)?,"Legacy file restoration mismatch: {relative}"); + } else { assert!(!fsx::safe_join(root,relative)?.exists()); } + } + assert_eq!(std::fs::read(meta.join("install.json"))?,std::fs::read(txn_dir.join("before/manager-install.json"))?); + backup::with_database(root,|db| { + for (index,mode) in [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard].into_iter().enumerate() { + let db=db.clone().for_realm(mode); + assert_eq!(players(&db)?,expected[index]); + assert_eq!(db.query("SELECT COUNT(*) FROM acore_world.coa_manager_migrations WHERE id IN ('manager_repair__20261005_reconcile_wildcard_tables','manager_repair__20261005_reconcile_missing_class_starts');")?,"0"); + assert_eq!(db.query("SELECT sha256 FROM acore_world.coa_manager_migrations WHERE id='manager_repair__20261005_missing_wildcard_tables';")?,"9e4a36245c3f83255415c122c01f08ceb3e3b83898d3d427ac4e2fbcf61e9638"); + } + Ok(()) + })?; + assert!(driver::run(root,driver::Verb::StopAll)?.ok); + assert!(update::pending_checked(&meta)?.is_none()); + assert!(update::rollback(root,&meta,id,&env).is_err(),"Completed recovery must not run again"); + println!("PASS: legacy journal blocked startup; old files, metadata, migration histories and player fields restored; completed recovery rejects replay. Original baseline had a missing table, so healthy startup is not claimed after rollback."); + Ok(()) +} +fn exercise(root:&Path,meta:&Path,baseline:&str,candidate:&str)->Result<()> { + let mut install:InstallMeta=fsx::read_json(&PathBuf::from("C:/games/coa-schema-fixture-chaos-20261005-v1.manager/install.json"))?; + install.server_path=root.to_string_lossy().into_owned(); + install.id=uuid::Uuid::new_v4().to_string(); + install.manager_version="0.6.4".into(); + MetaDir::create(root,&install)?; + // Private ports, and no inherited PID records or paths to the source database. + let mut cfg:serde_json::Value=fsx::read_json(&root.join("Settings/repack.json"))?; + let mut listeners=Vec::new(); + for key in ["mysqlPort","authPort","worldPort","raPort"] { + let listener=std::net::TcpListener::bind("127.0.0.1:0")?; + cfg[key]=listener.local_addr()?.port().into(); listeners.push(listener); + } + fsx::atomic_write_json(&root.join("Settings/repack.json"),&cfg)?; + let mut profile:serde_json::Value=fsx::read_json(&root.join("Settings/realm-profile.json"))?; + for key in ["secondary_world_port","secondary_ra_port"] { + let listener=std::net::TcpListener::bind("127.0.0.1:0")?; + profile[key]=listener.local_addr()?.port().into(); listeners.push(listener); + } + fsx::atomic_write_json(&root.join("Settings/realm-profile.json"),&profile)?; + drop(listeners); + let state=root.join(".state"); + if state.exists() { std::fs::remove_dir_all(&state)?; } + let secondary_state=root.join(".realms/secondary/.state"); + if secondary_state.exists() { std::fs::remove_dir_all(&secondary_state)?; } + let source=Source::Dir(baseline.into()); + let (manifest,_)=pkgsource::fetch_manifest(&source,coa_core::signing::EMBEDDED_PUBLIC_KEY)?; + let temp=tempfile::tempdir()?; + let parts=pkgsource::fetch_parts(&source,&manifest,&temp.path().join("download"),&Default::default(),&|_,_|{})?; + let tree=temp.path().join("tree"); + coa_core::package::extract(&parts,&manifest,&tree,&|_,_|{})?; + for file in &manifest.files { + if file.path.starts_with("_migrations/") {continue;} + fsx::atomic_write(&fsx::safe_join(root,&file.path)?,&std::fs::read(fsx::safe_join(&tree,&file.path)?)?)?; + install.original_hashes.insert(file.path.clone(),file.sha256.clone()); + } + install.core.version=Some(manifest.version); + fsx::atomic_write_json(&meta.join("install.json"),&install)?; + // Both worlds must remain local, including generated secondary configuration. + for relative in ["Settings/worldserver.conf.template","Settings/authserver.conf.template"] { + let p=root.join(relative); + let mut conf=coa_core::config::parser::ConfFile::parse_bytes(&std::fs::read(&p)?)?; + conf.set("BindIP","\"127.0.0.1\"",&[]); conf.set("Ra.IP","\"127.0.0.1\"",&[]); conf.set("SOAP.Enabled","0",&[]); + fsx::atomic_write(&p,conf.to_text().as_bytes())?; + } + let expected=backup::with_database(root,|db| { + let mut saved=Vec::new(); + for mode in [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard] { + let db=db.clone().for_realm(mode); + saved.push(players(&db)?); + db.query("DROP TABLE IF EXISTS acore_characters.coa_wildcard_skill_card; DELETE FROM acore_world.coa_manager_migrations WHERE id IN ('manager_repair__20261005_reconcile_wildcard_tables','manager_repair__20261005_reconcile_missing_class_starts'); UPDATE acore_world.coa_manager_migrations SET sha256='9e4a36245c3f83255415c122c01f08ceb3e3b83898d3d427ac4e2fbcf61e9638' WHERE id='manager_repair__20261005_missing_wildcard_tables' AND status='applied';")?; + } + Ok(saved) + })?; + let source=Source::Dir(candidate.into()); + let candidate_manifest=pkgsource::fetch_manifest(&source,coa_core::signing::EMBEDDED_PUBLIC_KEY)?.0; + let choices:BTreeMap<_,_>=update::plan(root,&install,&candidate_manifest,&BTreeMap::new(),None)?.into_iter().filter(|p|p.action==update::Action::Conflict).map(|p|(p.path,update::Resolution::Replace)).collect(); + let env=Offline(RepackEnv{root,meta_dir:meta}); + let outcome=update::apply(&update::Params {root,meta_dir:meta,source,trusted_key:coa_core::signing::EMBEDDED_PUBLIC_KEY,cancel:Default::default(),resolutions:choices,env:&env,fail_after_ops:None},&|stage,pct|println!("{pct}% {stage}"))?; + assert_eq!(outcome.txn.state,update::State::Committed,"{:?}",outcome.txn.message); + backup::with_database(root,|db| { + for (index,mode) in [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard].into_iter().enumerate() { + let db=db.clone().for_realm(mode); + assert_eq!(players(&db)?,expected[index],"Player data changed in {}",mode.name()); + assert!(coa_core::schema_check::check(&db,root)?.is_empty()); + assert_eq!(migrations::pending_count(&db,&candidate_manifest.migrations)?,0); + } + Ok(()) + })?; + println!("PASS: signed historical package to candidate; missing Wildcard table repaired in both realms; player fields and inventory preserved; schema and startup verified. Launcher isolation was test instrumentation, not packaged UI acceptance."); + Ok(()) +} diff --git a/crates/coa-core/examples/migrate_e2e.rs b/crates/coa-core/examples/migrate_e2e.rs index 290bd3e..91b1adc 100644 --- a/crates/coa-core/examples/migrate_e2e.rs +++ b/crates/coa-core/examples/migrate_e2e.rs @@ -29,7 +29,7 @@ fn main() { let dst = staging.join(db).join(format!("{id}.sql")); std::fs::create_dir_all(dst.parent().unwrap()).unwrap(); std::fs::write(&dst, &bytes).unwrap(); - list.push(Migration { id, db: db.into(), sha256: fsx::sha256_bytes(&bytes), destructive: false }); + list.push(Migration { compatible_sha256: vec![], id, db: db.into(), sha256: fsx::sha256_bytes(&bytes), destructive: false }); }; for (kind, dirs) in [("auth", ["db_auth", "pending_db_auth"]), ("characters", ["db_characters", "pending_db_characters"]), ("world", ["db_world", "pending_db_world"])] { for d in dirs { diff --git a/crates/coa-core/examples/realm_e2e.rs b/crates/coa-core/examples/realm_e2e.rs index af08337..b5fab7c 100644 --- a/crates/coa-core/examples/realm_e2e.rs +++ b/crates/coa-core/examples/realm_e2e.rs @@ -58,7 +58,7 @@ fn main() -> Result<()> { let path = staged.join(format!("{kind}/manager_realm_fixture.sql")); std::fs::create_dir_all(path.parent().unwrap())?; std::fs::write(&path, sql)?; - manifest.migrations.push(Migration { id: "manager_realm_fixture".into(), db: kind.into(), sha256: coa_core::fsx::sha256_file(&path)?, destructive: false }); + manifest.migrations.push(Migration { compatible_sha256: vec![], id: "manager_realm_fixture".into(), db: kind.into(), sha256: coa_core::fsx::sha256_file(&path)?, destructive: false }); } let result = env.migrate(&manifest, &staged)?; assert!(result.failed.is_none()); diff --git a/crates/coa-core/examples/repair_e2e.rs b/crates/coa-core/examples/repair_e2e.rs index 300a771..06499e2 100644 --- a/crates/coa-core/examples/repair_e2e.rs +++ b/crates/coa-core/examples/repair_e2e.rs @@ -20,7 +20,7 @@ fn main() -> Result<()> { fsx::atomic_write(&update_tree.join(format!("_migrations/world/{id}.sql")), sql.as_bytes())?; let options = |kind, version: &str, migrations| BuildOptions { kind, version: version.into(), core_commit: None, built_at: "fixture".into(), part_size: 1 << 20, bots_commit: None, migrations }; let base = package::build(&base_tree, &base_pkg, &options(Kind::Base, "1.0.0", vec![]), &|_| {})?; - let update = package::build(&update_tree, &update_pkg, &options(Kind::Update, "1.0.1", vec![Migration { id: id.into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }]), &|_| {})?; + let update = package::build(&update_tree, &update_pkg, &options(Kind::Update, "1.0.1", vec![Migration { compatible_sha256: vec![], id: id.into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }]), &|_| {})?; // A test-only trust key, unrelated to production signing material. let key = SigningKey::from_bytes(&[23; 32]); let public = STANDARD.encode(key.verifying_key().as_bytes()); diff --git a/crates/coa-core/examples/update_e2e.rs b/crates/coa-core/examples/update_e2e.rs index 547284e..a398d99 100644 --- a/crates/coa-core/examples/update_e2e.rs +++ b/crates/coa-core/examples/update_e2e.rs @@ -33,7 +33,7 @@ fn main() { let sql = "CREATE TABLE IF NOT EXISTS coa_manager_update_test (id INT PRIMARY KEY); INSERT IGNORE INTO coa_manager_update_test VALUES (1);"; put(&src, "_migrations/world/2026_09_30_00_update_e2e.sql", sql); let mut m = build(&src, &pkg, &BuildOptions { kind: Kind::Update, version: "0.2.0".into(), core_commit: None, built_at: chrono::Utc::now().to_rfc3339(), part_size: 1 << 20, bots_commit: None, migrations: vec![] }, &|_| {}).unwrap(); - m.migrations.push(Migration { id: "2026_09_30_00_update_e2e".into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }); + m.migrations.push(Migration { compatible_sha256: vec![], id: "2026_09_30_00_update_e2e".into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }); std::fs::write(pkg.join("manifest.json"), serde_json::to_vec_pretty(&m).unwrap()).unwrap(); let key = format!("{}/.coa-manager/signing/manifest-signing.key", std::env::var("USERPROFILE").unwrap()); let seed: [u8; 32] = STANDARD.decode(std::fs::read_to_string(key).unwrap().trim()).unwrap().try_into().unwrap(); diff --git a/crates/coa-core/examples/update_recovery_e2e.rs b/crates/coa-core/examples/update_recovery_e2e.rs index 9cbf310..7e24705 100644 --- a/crates/coa-core/examples/update_recovery_e2e.rs +++ b/crates/coa-core/examples/update_recovery_e2e.rs @@ -37,7 +37,7 @@ fn exercise(root: &Path) -> Result<()> { ("world", "probe_world", "DROP TABLE manager_recovery_probe; THIS IS INTENTIONALLY INVALID SQL;"), ] { fsx::atomic_write(&source.join(format!("_migrations/{db}/{id}.sql")), sql.as_bytes())?; - migrations.push(Migration { db: db.into(), id: id.into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }); + migrations.push(Migration { compatible_sha256: vec![], db: db.into(), id: id.into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }); } let pkg = root.join("fixture-package"); let manifest = package::build(&source, &pkg, &BuildOptions { diff --git a/crates/coa-core/examples/wildcard_repair_e2e.rs b/crates/coa-core/examples/wildcard_repair_e2e.rs index d9ebec1..7373c40 100644 --- a/crates/coa-core/examples/wildcard_repair_e2e.rs +++ b/crates/coa-core/examples/wildcard_repair_e2e.rs @@ -22,7 +22,7 @@ fn main() -> Result<()> { let path = old_sql.join(format!("{name}.sql")); let hash = fsx::sha256_file(&path)?; db.put(&LedgerRow { db: "characters".into(), id: name.into(), sha256: hash.clone(), status: Status::Applied, error: None, baseline: false })?; - migrations.push(Migration { db: "characters".into(), id: name.into(), sha256: hash, destructive: false }); + migrations.push(Migration { compatible_sha256: vec![], db: "characters".into(), id: name.into(), sha256: hash, destructive: false }); } for (_, tables) in schema_check::WILDCARD_TABLES.iter().filter(|(kind,_)| *kind == "characters") { for table in *tables { db.query(&format!("DROP TABLE IF EXISTS acore_characters.`{table}`;"))?; } @@ -33,7 +33,7 @@ fn main() -> Result<()> { let files = release_schema::collect(empty_core.path(), None, Some(&repairs))?; assert_eq!(files.len(), 1); let repair = &files[0]; - migrations.push(Migration { db: repair.db.clone(), id: repair.id.clone(), sha256: repair.sha256.clone(), destructive: false }); + migrations.push(Migration { compatible_sha256: vec![], db: repair.db.clone(), id: repair.id.clone(), sha256: repair.sha256.clone(), destructive: false }); let staged = tree.path().join("sql/characters"); fsx::atomic_write(&staged.join(format!("{}.sql", repair.id)), &std::fs::read(&repair.abs)?)?; let report = migrations::apply_pending(&db, &migrations, &tree.path().join("sql"), &|| Ok("disposable fixture".into()))?; diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 5fcad0d..8e21adb 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -51,6 +51,8 @@ pub struct Component { pub sha256: Option, pub tables: Option, pub files: Option>, + #[serde(default)] + pub file_sha256: std::collections::BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -91,7 +93,9 @@ pub fn list(meta: &Path) -> Vec { } pub fn get(meta: &Path, id: &str) -> Result { - fsx::read_json(&point_dir(meta, id)?.join("backup.json")).map_err(|_| Error::Invalid(format!("backup {id} was not found"))) + let point: RecoveryPoint = fsx::read_json(&fsx::ensure_within(meta, &point_dir(meta, id)?.join("backup.json"))?).map_err(|_| Error::Invalid(format!("backup {id} was not found or its metadata is damaged")))?; + if point.id != id || point.schema != 1 { return Err(Error::Invalid(format!("Backup {id} has inconsistent identity or an unsupported schema."))); } + Ok(point) } /// Files that make up "configuration": everything a user or the Manager may have changed, minus secrets. @@ -139,13 +143,16 @@ fn copy_configs(root: &Path, dir: &Path) -> Result { let files = config_files(root); let target = dir.join("files"); let mut bytes = 0; + let mut file_sha256 = std::collections::BTreeMap::new(); for rel in &files { let src = fsx::safe_join(root, rel)?; let dst = fsx::safe_join(&target, rel)?; - fs::create_dir_all(dst.parent().unwrap())?; - bytes += fs::copy(&src, &dst)?; + let content = fs::read(&src)?; + fsx::atomic_write(&dst, &content)?; + bytes += content.len() as u64; + file_sha256.insert(rel.clone(), fsx::sha256_bytes(&content)); } - Ok(Component { name: "configs".into(), path: "files".into(), bytes, sha256: None, tables: None, files: Some(files) }) + Ok(Component { name: "configs".into(), path: "files".into(), bytes, sha256: None, tables: None, files: Some(files), file_sha256 }) } /// Make sure the database is up for `f`. If we had to start it (and nothing else is running), stop it again. @@ -215,7 +222,7 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt let tables = db.tables(schema)?.len(); let file = format!("{name}.sql.zst"); let (bytes, sha) = db.dump_to(schema, &partial.join(&file))?; - Ok(Component { name, path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None }) + Ok(Component { name, path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None, file_sha256: Default::default() }) })?; components.push(component); } @@ -255,6 +262,7 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt /// Keep the newest `keep` automatic recovery points; manual and safety ones are never pruned. pub fn prune_automatic(meta: &Path, keep: usize) { + if crate::update::ensure_recovered(meta).is_err() { return; } let autos: Vec<_> = list(meta).into_iter().filter(|p| p.trigger == Trigger::Automatic).collect(); for p in autos.into_iter().skip(keep) { if let Ok(dir) = point_dir(meta, &p.id) { @@ -273,8 +281,21 @@ pub fn verify(meta: &Path, id: &str) -> Result { let point = get(meta, id)?; let dir = point_dir(meta, id)?; let mut problems = Vec::new(); + let mut names = std::collections::BTreeSet::new(); + let mut schemas = std::collections::BTreeSet::new(); for c in &point.components { - let path = dir.join(&c.path); + if !names.insert(&c.name) { problems.push(format!("{} appears more than once", c.name)); } + let path = fsx::ensure_within(&dir, &fsx::safe_join(&dir, &c.path)?)?; + if c.name != "configs" && (c.sha256.is_none() || c.tables.is_none()) { problems.push(format!("{} lacks database integrity metadata", c.name)); } + if c.name == "configs" { + if c.sha256.is_some() || c.files.is_none() { problems.push("configuration integrity metadata is inconsistent".into()); } + } else { + let schema = if c.name.contains('-') || c.name == "playerbots" { db::schema_of(&c.name) } else { point.realm.schema(&c.name) }; + match schema { + Ok(schema) if schemas.insert(schema) => {} + _ => problems.push(format!("{} has an unknown or duplicate database target", c.name)), + } + } match (&c.sha256, &c.files) { (Some(sha), _) => match fsx::sha256_file(&path) { Ok(actual) if actual.eq_ignore_ascii_case(sha) => {} @@ -283,12 +304,16 @@ pub fn verify(meta: &Path, id: &str) -> Result { }, (None, Some(files)) => { for f in files { + if !c.file_sha256.is_empty() && !c.file_sha256.contains_key(f) { problems.push(format!("configuration file {f} lacks its checksum")); } if !fsx::safe_join(&path, f).map(|p| p.is_file()).unwrap_or(false) { problems.push(format!("configuration file {f} is missing")); + } else if let Some(expected) = c.file_sha256.get(f) { + let file = fsx::ensure_within(&dir, &fsx::safe_join(&path, f)?)?; + if fsx::sha256_file(&file)?.as_str() != expected { problems.push(format!("configuration file {f} is damaged")); } } } } - _ => {} + _ => problems.push(format!("{} lacks an integrity inventory", c.name)), } } Ok(VerifyReport { ok: problems.is_empty(), problems }) @@ -296,6 +321,8 @@ pub fn verify(meta: &Path, id: &str) -> Result { /// Delete one recovery point (its own folder only). pub fn delete(meta: &Path, id: &str) -> Result<()> { + let _lock = crate::update::operation_lock(meta)?; + crate::update::ensure_recovered(meta)?; if crate::update::unfinished(meta).is_some_and(|t| t.recovery_point.as_deref() == Some(id)) { return Err(Error::Invalid("This recovery point is required by an unfinished update and cannot be deleted.".into())); } @@ -405,6 +432,36 @@ mod tests { (dir, root, meta) } + #[test] + fn backup_deletion_obeys_the_installation_lock() { + let (_d, root, meta) = setup(); + let point = create(&root, &meta, Kind::Config, Trigger::Manual, None, &|_| {}).unwrap(); + let lock = crate::update::operation_lock(&meta).unwrap(); + assert!(delete(&meta, &point.id).is_err()); + assert!(get(&meta, &point.id).is_ok()); + drop(lock); + delete(&meta, &point.id).unwrap(); + assert!(get(&meta, &point.id).is_err()); + } + + #[test] + fn damaged_configuration_is_detected_before_restore_and_copies_are_retained() { + let (_d, root, meta) = setup(); + let point = create(&root, &meta, Kind::Config, Trigger::Automatic, None, &|_| {}).unwrap(); + let file = point_dir(&meta, &point.id).unwrap().join("files/Core/configs/worldserver.conf"); + fs::write(&file, b"corrupted").unwrap(); + assert!(!verify(&meta, &point.id).unwrap().ok); + let original = fs::read(root.join("Core/configs/worldserver.conf")).unwrap(); + assert!(restore_configs(&root, &meta, &point.id).is_err()); + assert_eq!(fs::read(root.join("Core/configs/worldserver.conf")).unwrap(), original); + assert_eq!(list(&meta).len(), 1); + fs::create_dir_all(meta.join("updates/broken")).unwrap(); + fs::write(meta.join("updates/broken/txn.json"), b"{damaged").unwrap(); + assert!(delete(&meta, &point.id).is_err()); + prune_automatic(&meta, 0); + assert!(file.exists()); + } + #[test] fn config_backup_excludes_secrets_and_clutter_and_restores_byte_exact() { let (_d, root, meta) = setup(); diff --git a/crates/coa-core/src/cleanbase.rs b/crates/coa-core/src/cleanbase.rs index 9519ff7..ced4b04 100644 --- a/crates/coa-core/src/cleanbase.rs +++ b/crates/coa-core/src/cleanbase.rs @@ -181,7 +181,7 @@ pub fn build(p: &Params, say: &dyn Fn(&str)) -> Result<()> { if let Some(b) = p.bots { sql.extend(collect_bots_sql(b)?); } - let migrations: Vec<_> = sql.iter().map(|f| crate::manifest::Migration { id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect(); + let migrations: Vec<_> = sql.iter().map(|f| crate::manifest::Migration { compatible_sha256: vec![], id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect(); let staging = std::env::temp_dir().join("coa-cleanbase-sql"); let _ = fs::remove_dir_all(&staging); for f in &sql { diff --git a/crates/coa-core/src/diag.rs b/crates/coa-core/src/diag.rs index 9447d42..0466bff 100644 --- a/crates/coa-core/src/diag.rs +++ b/crates/coa-core/src/diag.rs @@ -172,19 +172,158 @@ pub fn squash_repeated_config_warnings(text: &str) -> String { s } +const SENSITIVE: [&str; 7] = ["password", "passwd", "secret", "token", "apikey", "api_key", "databaseinfo"]; + /// Remove things that must never leave the machine from log text. pub fn redact(text: &str) -> String { let mut out = Vec::new(); for line in text.lines() { let l = line.to_lowercase(); - let sensitive = ["password", "passwd", "secret", "token", "apikey", "api_key", "databaseinfo"].iter().any(|k| l.contains(k)); + let sensitive = SENSITIVE.iter().any(|k| l.contains(k)); out.push(if sensitive { "[line removed: may contain a secret]".to_string() } else { line.to_string() }); } out.join("\n") } +/// The same for JSON: values under a sensitive key and strings that look like `password=...` are replaced, and the +/// result stays valid JSON (removing a whole line would break the file for whoever reads it). Text that is not JSON +/// falls back to the line rule. +pub fn redact_json(text: &str) -> String { + fn scrub(v: &mut serde_json::Value) { + use serde_json::Value; + match v { + Value::Object(map) => { + for (key, value) in map.iter_mut() { + let k = key.to_lowercase(); + if SENSITIVE.iter().any(|s| k.contains(s)) && !value.is_object() && !value.is_array() { + *value = Value::String("[redacted]".into()); + } else { + scrub(value); + } + } + } + Value::Array(items) => items.iter_mut().for_each(scrub), + Value::String(s) => { + let l = s.to_lowercase(); + if ["password=", "passwd=", "secret=", "token=", "apikey=", "api_key=", "password:"].iter().any(|k| l.contains(k)) { + *s = "[redacted]".into(); + } + } + _ => {} + } + } + match serde_json::from_str::(text) { + Ok(mut v) => { + scrub(&mut v); + serde_json::to_string_pretty(&v).unwrap_or_else(|_| redact(text)) + } + Err(_) => redact(text), + } +} + +/// Keep the events of a `wevtutil ... /f:text` listing that mention one of `needles` (compared without case), in the +/// order listed, at most `max` of them. +pub fn filter_event_blocks(listing: &str, needles: &[&str], max: usize) -> String { + let mut blocks: Vec> = Vec::new(); + for line in listing.lines() { + if line.starts_with("Event[") || blocks.is_empty() { + blocks.push(Vec::new()); + } + if let Some(b) = blocks.last_mut() { b.push(line); } + } + blocks + .into_iter() + .map(|b| b.join("\n")) + .filter(|b| { let l = b.to_lowercase(); needles.iter().any(|n| l.contains(n)) }) + .take(max) + .collect::>() + .join("\n\n") +} + +/// Drop the lines of an event listing that name the person or the computer. +pub fn drop_identity_lines(listing: &str) -> String { + listing + .lines() + .filter(|l| { let t = l.trim_start(); !(t.starts_with("User:") || t.starts_with("User Name:") || t.starts_with("Computer:")) }) + .collect::>() + .join("\n") +} + +/// `wevtutil /uni:true` writes UTF-16 with a byte order mark; anything else is read as UTF-8 (lossy). +fn decode_console_output(bytes: &[u8]) -> String { + if bytes.starts_with(&[0xFF, 0xFE]) { + let units: Vec = bytes[2..].chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); + String::from_utf16_lossy(&units) + } else { + String::from_utf8_lossy(bytes).into_owned() + } +} + +/// Crashes and hangs Windows recorded for the server programs in the last two weeks. This is the only trace of a crash +/// that ends the process without the server's own crash report (a stack buffer overrun, for example). The lines that +/// name the user and the computer are left out. +#[cfg(windows)] +fn windows_events() -> String { + use std::os::windows::process::CommandExt; + let query = "*[System[(Provider[@Name='Application Error' or @Name='Windows Error Reporting' or @Name='Application Hang']) and TimeCreated[timediff(@SystemTime) <= 1209600000]]]"; + let out = std::process::Command::new("wevtutil") + .args(["qe", "Application", &format!("/q:{query}"), "/c:300", "/rd:true", "/f:text", "/uni:true"]) + .creation_flags(0x0800_0000) + .output(); + match out { + Ok(o) if o.status.success() => { + let text = filter_event_blocks(&decode_console_output(&o.stdout), &["worldserver", "authserver", "mysqld", "coa server manager", "coa-server-manager"], 40); + if text.is_empty() { "No crash or hang of the server programs was recorded by Windows in the last 14 days.".into() } else { drop_identity_lines(&text) } + } + Ok(o) => format!("Windows event log could not be read: {}", decode_console_output(&o.stderr).trim()), + Err(e) => format!("Windows event log could not be read: {e}"), + } +} + +#[cfg(not(windows))] +fn windows_events() -> String { + String::new() +} + +/// Newest files of a folder with the given extension (compared without case): path, size, modified time. +fn newest_files(dir: &Path, ext: &str, limit: usize) -> Vec<(PathBuf, u64, std::time::SystemTime)> { + let Ok(rd) = fs::read_dir(dir) else { return Vec::new() }; + let mut v: Vec<_> = rd + .flatten() + .filter(|e| e.path().extension().is_some_and(|x| x.eq_ignore_ascii_case(ext))) + .filter_map(|e| { let m = e.metadata().ok()?; m.is_file().then(|| (e.path(), m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH))) }) + .collect(); + v.sort_by(|a, b| b.2.cmp(&a.2)); + v.truncate(limit); + v +} + +/// Which server programs run right now and from where, next to what the Manager made of it. A program started from a +/// folder or a copy the Manager does not know shows up here even when the Manager reports "port in use". +fn processes_report(root: &Path, ports: &layout::Ports) -> String { + let here = root.to_string_lossy().to_lowercase().replace('/', "\\"); + let listen = process::listeners_detailed(); + let mut out = String::from("Server programs running now (any folder):\n"); + let found = process::find_by_file_names(&["worldserver.exe", "authserver.exe", "mysqld.exe"]); + if found.is_empty() { out.push_str(" none\n"); } + for p in found { + let ports_of: Vec = listen.iter().filter(|l| l.pid == p.pid).map(|l| l.port.to_string()).collect(); + let inside = p.exe.to_lowercase().replace('/', "\\").starts_with(&here); + out.push_str(&format!(" pid {} {} | listening on: {} | {}\n", p.pid, p.exe, if ports_of.is_empty() { "-".into() } else { ports_of.join(", ") }, if inside { "inside this server folder" } else { "OUTSIDE this server folder" })); + } + let o = process::observe(root, ports); + out.push_str("\nThe Manager's view:\n"); + for s in [Some(&o.mysql), Some(&o.auth), Some(&o.world), o.secondary_world.as_ref()].into_iter().flatten() { + out.push_str(&format!(" {:<16} {:?} port {} {}{}\n", s.name, s.state, s.port, s.pid.map(|p| format!("pid {p}")).unwrap_or_default(), s.conflict.as_ref().map(|c| format!(" | port held by pid {} {}", c.pid, c.exe.clone().unwrap_or_default())).unwrap_or_default())); + } + out +} + /// Zip with what a maintainer needs to debug a problem, with secrets removed. Returns the number of files inside. +/// One call collects everything: the Manager's and the server's logs, crash reports and small crash dumps, Windows' +/// own record of crashes, the update journals, the running server programs and the settings the client depends on. pub fn export_package(root: &Path, meta_dir: &Path, manager_log: &Path, meta: &InstallMeta, report: &Report, out_zip: &Path) -> Result { + const DUMP_FILE_MAX: u64 = 30 * 1024 * 1024; let file = fs::File::create(out_zip)?; let mut zip = zip::ZipWriter::new(file); let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); @@ -195,17 +334,38 @@ pub fn export_package(root: &Path, meta_dir: &Path, manager_log: &Path, meta: &I n += 1; Ok(()) }; + let ports = layout::read_ports(root); let summary = serde_json::json!({ "manager": crate::MANAGER_VERSION, + "exported_utc": chrono::Utc::now().to_rfc3339(), "core": meta.core, "bots": meta.bots, + "squid_bots": crate::squid::release(root), "kind": meta.kind, "layout": meta.layout, "checks": report.checks, "wow_client_set": meta.client_path.is_some(), }); add("summary.json", serde_json::to_string_pretty(&summary)?.as_bytes())?; - for (name, path) in [("manager.log", manager_log.to_path_buf()), ("manager-install.log", meta_dir.join("logs/manager.log")), ("Server.log.tail", root.join("Core/Logs/Server.log")), ("Errors.log.tail", root.join("Core/Logs/Errors.log")), ("Auth.log.tail", root.join("Core/Logs/Auth.log")), ("world-console.log.tail", root.join("Core/Logs/world-console.log")), ("mysql-error.log.tail", root.join("mysql/logs/mysql-error.log"))] { + + // Logs. The newest update logs of the repack's own updater are added under their own names. + let mut logs: Vec<(String, PathBuf)> = vec![ + ("manager.log".into(), manager_log.to_path_buf()), + ("manager-install.log".into(), meta_dir.join("logs/manager.log")), + ("Server.log.tail".into(), root.join("Core/Logs/Server.log")), + ("Errors.log.tail".into(), root.join("Core/Logs/Errors.log")), + ("Auth.log.tail".into(), root.join("Core/Logs/Auth.log")), + ("world-console.log.tail".into(), root.join("Core/Logs/world-console.log")), + ("CoaBots.log.tail".into(), root.join("Core/Logs/CoaBots.log")), + ("Playerbots.log.tail".into(), root.join("Core/Logs/Playerbots.log")), + ("supervisor.log.tail".into(), root.join("Core/Logs/supervisor.log")), + ("mysql-error.log.tail".into(), root.join("mysql/logs/mysql-error.log")), + ]; + let updater_logs = newest_files(&root.join("Core/Logs"), "log", usize::MAX).into_iter().filter(|(p, _, _)| p.file_name().is_some_and(|f| f.to_string_lossy().to_lowercase().starts_with("update-"))); + for (p, _, _) in updater_logs.take(2) { + logs.push((format!("{}.tail", p.file_name().unwrap_or_default().to_string_lossy()), p)); + } + for (name, path) in logs { // A module that reads a missing setting on every tick fills a log with one warning, thousands of times; read far // enough back to see past that, fold the repeats, then keep the last part. let bytes = tail(&path, 8 * 1024 * 1024); @@ -213,9 +373,44 @@ pub fn export_package(root: &Path, meta_dir: &Path, manager_log: &Path, meta: &I let text = squash_repeated_config_warnings(&String::from_utf8_lossy(&bytes)); let keep = text.len().saturating_sub(512 * 1024); let start = (keep..text.len()).find(|i| text.is_char_boundary(*i)).unwrap_or(text.len()); - add(name, redact(&text[start..]).as_bytes())?; + add(&name, redact(&text[start..]).as_bytes())?; } } + + // Crash reports of the server (text, and the small dumps next to them), and what Windows recorded for the programs. + let crashes = root.join("Core/Crashes"); + let mut listing = String::new(); + for (p, len, _) in newest_files(&crashes, "txt", 10) { + let name = p.file_name().unwrap_or_default().to_string_lossy().into_owned(); + listing.push_str(&format!("{name} ({len} bytes)\n")); + let text = String::from_utf8_lossy(&tail(&p, 1024 * 1024)).into_owned(); + add(&format!("crashes/{name}"), redact(&text).as_bytes())?; + } + for (p, len, _) in newest_files(&crashes, "dmp", 3) { + let name = p.file_name().unwrap_or_default().to_string_lossy().into_owned(); + if len > DUMP_FILE_MAX { + listing.push_str(&format!("{name} ({len} bytes) not included: larger than {} MB\n", DUMP_FILE_MAX >> 20)); + } else if let Ok(bytes) = fs::read(&p) { + listing.push_str(&format!("{name} ({len} bytes) included\n")); + add(&format!("crashes/{name}"), &bytes)?; + } + } + add("crashes.txt", if listing.is_empty() { "No crash reports in Core/Crashes.".to_string() } else { listing }.as_bytes())?; + let events = windows_events(); + if !events.is_empty() { add("windows-events.txt", events.as_bytes())?; } + add("processes.txt", processes_report(root, &ports).as_bytes())?; + + // The newest update journals: the state of each update and the reason it failed. + let mut journals: Vec<_> = fs::read_dir(meta_dir.join("updates")).into_iter().flatten().flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && e.file_name().to_string_lossy().chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))) + .collect(); + journals.sort_by_key(|e| std::cmp::Reverse(e.file_name())); + for e in journals.into_iter().take(5) { + if let Ok(text) = fs::read_to_string(e.path().join("txn.json")) { + add(&format!("updates/{}-txn.json", e.file_name().to_string_lossy()), redact_json(&text).as_bytes())?; + } + } + // Which files the server's own configuration folders hold, and the two CoA switches that decide whether the game // client can talk to the world server at all. let mut present = String::new(); @@ -243,10 +438,10 @@ pub fn export_package(root: &Path, meta_dir: &Path, manager_log: &Path, meta: &I } } if let Ok(b) = fs::read(root.join("RELEASE.json")) { - add("RELEASE.json", redact(&String::from_utf8_lossy(&b)).as_bytes())?; + add("RELEASE.json", redact_json(&String::from_utf8_lossy(&b)).as_bytes())?; } if let Ok(b) = fs::read(meta_dir.join("logs/database-checks.json")) { - add("database-checks.json", redact(&String::from_utf8_lossy(&b)).as_bytes())?; + add("database-checks.json", redact_json(&String::from_utf8_lossy(&b)).as_bytes())?; } zip.finish().map_err(|e| Error::Invalid(e.to_string()))?; Ok(n) @@ -288,6 +483,68 @@ real error assert!(!t.contains("PASS") && !t.contains("abc")); } + #[test] + fn json_redaction_keeps_the_file_valid_and_hides_the_secrets() { + let t = redact_json(r#"{"id":"rev_20261006_token_cache","password":"hunter2","nested":{"apiKey":"k","note":"login with password=abc"},"list":[{"secret":1},"ok"]}"#); + let v: serde_json::Value = serde_json::from_str(&t).expect("still JSON"); + assert_eq!(v["id"], "rev_20261006_token_cache", "an id that merely contains a word is kept"); + assert_eq!(v["password"], "[redacted]"); + assert_eq!(v["nested"]["apiKey"], "[redacted]"); + assert_eq!(v["nested"]["note"], "[redacted]"); + assert_eq!(v["list"][0]["secret"], "[redacted]"); + assert_eq!(v["list"][1], "ok"); + assert!(!t.contains("hunter2") && !t.contains("abc")); + assert!(redact_json("not json\nPassword=1").contains("[line removed"), "text falls back to the line rule"); + } + + #[test] + fn windows_events_are_filtered_to_the_server_programs() { + let listing = "Event[0]:\n Provider Name: Application Error\n Description: Faulting application name: worldserver.exe, version: 0.0.0.0\n\nEvent[1]:\n Provider Name: Application Error\n Description: Faulting application name: notepad.exe\n\nEvent[2]:\n Description: Faulting application name: MYSQLD.EXE\n"; + let out = filter_event_blocks(listing, &["worldserver", "mysqld"], 10); + assert!(out.contains("worldserver.exe") && out.contains("MYSQLD.EXE") && !out.contains("notepad")); + assert_eq!(filter_event_blocks(listing, &["worldserver", "mysqld"], 1).matches("Event[").count(), 1); + } + + #[test] + fn event_text_loses_the_user_and_computer_lines_and_reads_utf16() { + let out = drop_identity_lines("Event[0]\n Source: Application Error\n User: S-1-5-21-1\n User Name: PC\\Dion\n Computer: PC\n Description: worldserver.exe"); + assert!(out.contains("Source") && out.contains("worldserver.exe")); + assert!(!out.contains("S-1-5") && !out.contains("Dion") && !out.contains("Computer")); + let mut bytes = vec![0xFF, 0xFE]; + for u in "Сбой worldserver".encode_utf16() { bytes.extend_from_slice(&u.to_le_bytes()); } + assert_eq!(decode_console_output(&bytes), "Сбой worldserver"); + assert_eq!(decode_console_output(b"plain"), "plain"); + } + + #[test] + fn the_package_carries_crash_reports_update_journals_and_the_process_view() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + layout::testkit::fake_repack(&root); + fs::create_dir_all(root.join("Core/Crashes")).unwrap(); + fs::write(root.join("Core/Crashes/567e_worldserver.exe_[4-10_21-25-31].txt"), "Exception code: C0000005\nRCX:0\n").unwrap(); + fs::write(root.join("Core/Crashes/567e_worldserver.exe_[4-10_21-25-31].dmp"), b"MDMP-small").unwrap(); + fs::write(root.join("Core/Logs/CoaBots.log"), "bot line\n").unwrap(); + fs::write(root.join("Core/Logs/update-1.8-20261005.log"), "updater line\n").unwrap(); + let meta_dir = d.path().join("srv.manager"); + fs::create_dir_all(meta_dir.join("updates/20261006-082021-0_261006_1-abc")).unwrap(); + fs::write(meta_dir.join("updates/20261006-082021-0_261006_1-abc/txn.json"), r#"{"state":"rolled-back","message":"Database update 2026_02_24_00 failed: duplicate column"}"#).unwrap(); + fs::create_dir_all(meta_dir.join("updates/not a journal!")).unwrap(); + let meta = InstallMeta::new(InstallKind::Imported, &root); + let report = run(&root, &meta); + let zip_path = d.path().join("diag.zip"); + export_package(&root, &meta_dir, &d.path().join("none.log"), &meta, &report, &zip_path).unwrap(); + let mut z = zip::ZipArchive::new(fs::File::open(&zip_path).unwrap()).unwrap(); + let names: Vec = (0..z.len()).map(|i| z.by_index(i).unwrap().name().to_string()).collect(); + for want in ["crashes.txt", "processes.txt", "CoaBots.log.tail", "update-1.8-20261005.log.tail", "updates/20261006-082021-0_261006_1-abc-txn.json", "crashes/567e_worldserver.exe_[4-10_21-25-31].txt", "crashes/567e_worldserver.exe_[4-10_21-25-31].dmp"] { + assert!(names.iter().any(|n| n == want), "{want} is in the package: {names:?}"); + } + assert!(!names.iter().any(|n| n.contains("not a journal"))); + let mut listing = String::new(); + z.by_name("crashes.txt").unwrap().read_to_string(&mut listing).unwrap(); + assert!(listing.contains("included")); + } + #[test] fn verify_reports_missing_and_changed_managed_files_only() { let d = tempfile::tempdir().unwrap(); @@ -323,6 +580,8 @@ real error let d = tempfile::tempdir().unwrap(); let root = d.path().join("srv"); layout::testkit::fake_repack(&root); + fs::create_dir_all(root.join("Extras/SquidPlayerbots")).unwrap(); + fs::write(root.join("Extras/SquidPlayerbots/release.json"), r#"{"tag":"v1.8","commit":"48c4786a","password":"upstream-secret"}"#).unwrap(); fs::write(root.join("Core/Logs/Errors.log"), "boom\nDatabase password=hunter2 rejected\n").unwrap(); fs::write(root.join("Core/configs/worldserver.conf"), "LoginDatabaseInfo = \"127.0.0.1;3307;acore;SECRETPW;auth\"\nRate.XP.Kill = 1\n").unwrap(); let meta_dir = d.path().join("srv.manager"); @@ -340,6 +599,7 @@ real error all.push_str(&s); } assert!(all.contains("boom") && all.contains("Rate.XP.Kill"), "keys and ordinary log lines are included"); - assert!(!all.contains("hunter2") && !all.contains("SECRETPW")); + assert!(all.contains("v1.8") && all.contains("48c4786a")); + assert!(!all.contains("hunter2") && !all.contains("SECRETPW") && !all.contains("upstream-secret")); } } diff --git a/crates/coa-core/src/docker/install.rs b/crates/coa-core/src/docker/install.rs new file mode 100644 index 0000000..8e883a0 --- /dev/null +++ b/crates/coa-core/src/docker/install.rs @@ -0,0 +1,574 @@ +//! Installing a new server as a Docker installation, from a Linux package. +//! +//! The order of events is the one of `install::install_base`: nothing appears at the destination until the package is +//! signed-off, downloaded, verified, extracted and its database built, all of it in a sibling `.installing` +//! folder. But this is code of its own: the Windows installer is not touched and nothing here is specific to the +//! Windows layout. What both installers use is platform-neutral: the signed manifest, the download, the extraction, +//! the migration ledger and the registry. +//! +//! The databases start from the package's own dumps (`Database/baseline/.sql.zst`, the format of the Manager's +//! backups), as a Windows package starts from its prepared database: they hold the state in which every migration of the +//! package is already applied, and the installer records them as such. They cannot be rebuilt from the repository's +//! SQL files: some migrations are guards that only apply to the maintainers' own database. The game data is not part +//! of the package: the person points at a folder they already have. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::cli::{Docker, SystemDocker}; +use super::{Config, MARKER}; +use crate::db::{self, Account, Db}; +use crate::download::Cancel; +use crate::driver::Verb; +use crate::error::{Error, Result}; +use crate::fsx; +use crate::install::{free_port, problem, random_hex, Installed, Preflight, Step}; +use crate::manifest::{self, Migration}; +use crate::migrations; +use crate::package::BASELINE_DIR; +use crate::pkgsource::{fetch_manifest, fetch_parts, Source}; +use crate::registry::{metadata_dir_for, InstallKind, InstallMeta, MetaDir, Registry, LAYOUT_DOCKER_V1}; + +/// Created in the staging folder so a leftover of an earlier attempt can be told from somebody else's folder. +const STAGING_MARKER: &str = ".coa-installing"; +/// The three game databases: kind (the name of the dump), schema. +const DATABASES: [(&str, &str); 3] = [("auth", "acore_auth"), ("characters", "acore_characters"), ("world", "acore_world")]; +/// Ports the game uses by default on this computer. +const DEFAULT_PORTS: (u16, u16, u16) = (3724, 8085, 3443); + +pub struct Params<'a> { + pub source: Source, + pub dest: PathBuf, + /// A folder with the game data (dbc, maps, vmaps, mmaps) that the person already has. Used in place, read-only. + pub data_dir: PathBuf, + /// Public key to verify the manifest with (production: `signing::EMBEDDED_PUBLIC_KEY`). + pub trusted_key: &'a str, + pub registry: &'a Registry, + pub cancel: Cancel, +} + +/// Checks whether `dest` and the game data folder are sensible for a new server on Linux. +pub fn preflight(dest: &Path, data_dir: &Path, needed_bytes: u64, registry: &Registry) -> Preflight { + let mut problems = Vec::new(); + let s = dest.to_string_lossy(); + + if !dest.is_absolute() { + problems.push(problem("relative", "Choose a full folder path, such as /home/you/CoaServer.")); + } + if s.contains(':') { + problems.push(problem("colon", "Choose a folder whose path has no colon in it.")); + } + let trimmed = s.trim_end_matches('/'); + const SYSTEM: [&str; 13] = ["", "/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/proc", "/run", "/sbin", "/sys", "/usr", "/var"]; + if SYSTEM.iter().any(|p| trimmed == *p || (!p.is_empty() && trimmed.starts_with(&format!("{p}/")))) { + problems.push(problem("system_folder", "Please choose a normal folder for your games, not a system location.")); + } + if let Some(home) = std::env::var_os("HOME") { + let home = home.to_string_lossy().trim_end_matches('/').to_string(); + let special = ["", "/Documents", "/Desktop", "/Downloads", "/Pictures", "/Music", "/Videos", "/.config", "/.local", "/.local/share"]; + if !home.is_empty() && special.iter().any(|sfx| trimmed == format!("{home}{sfx}")) { + problems.push(problem("personal_folder", "This folder contains unrelated files. Choose another folder or create a new CoA Server folder.")); + } + } + + if dest.exists() { + match fs::read_dir(dest) { + Ok(mut rd) => { + if rd.next().is_some() { + match crate::layout::scan(dest).map(|r| r.classification) { + Ok(crate::layout::Classification::Incompatible) | Err(_) => { + if dest.join("Wow.exe").is_file() || dest.join("Ascension.exe").is_file() { + problems.push(problem("client_folder", "This is a game client folder, not a place for a server.")); + } else { + problems.push(problem("not_empty", "This folder contains unrelated files. Choose another folder or create a new CoA Server folder.")); + } + } + Ok(_) => problems.push(problem("already_server", "A server is already in this folder. Use \"I already have a server\" to add it instead.")), + } + } + } + Err(_) => problems.push(problem("unreadable", "This folder cannot be read. Choose another one.")), + } + } + + if let Ok(list) = registry.list() { + for (_, existing) in list { + if let (Ok(a), Ok(b)) = (fsx::canonicalize_lenient(dest), fsx::canonicalize_lenient(&existing)) { + if fsx::starts_with_ci(&a, &b) || fsx::starts_with_ci(&b, &a) { + problems.push(problem("registered", "A server is already registered at or around this location.")); + break; + } + } + } + } + + problems.extend(data_problems(data_dir)); + + let free_bytes = fsx::free_space(dest).unwrap_or(0); + let need = needed_bytes.saturating_add(needed_bytes / 5).saturating_add(512 * 1024 * 1024); + if free_bytes < need { + problems.push(problem("space", &format!("Not enough free space: about {} GB needed, {} GB available.", need / (1 << 30) + 1, free_bytes / (1 << 30)))); + } + Preflight { ok: problems.is_empty(), problems, free_bytes } +} + +fn data_problems(data_dir: &Path) -> Vec { + let s = data_dir.to_string_lossy(); + if !data_dir.is_absolute() || s.contains(':') { + return vec![problem("data_path", "Choose the game data folder with its full path, without a colon in it.")]; + } + let missing: Vec<&str> = ["dbc", "maps"].into_iter().filter(|d| !data_dir.join(d).is_dir()).collect(); + if missing.is_empty() { + Vec::new() + } else { + vec![problem("data_missing", &format!("This folder does not look like the game data: {} not found in it. It should hold dbc, maps, vmaps and mmaps.", missing.join(" and ")))] + } +} + +/// Port the game should use: the usual one when it is free on this computer, else any free one. +fn pick_port(preferred: u16) -> Result { + if std::net::TcpListener::bind(("127.0.0.1", preferred)).is_ok() { + Ok(preferred) + } else { + free_port() + } +} + +/// Settings of a new installation: which containers belong to it, where the game data is, the ports, the passwords. +/// Written before anything runs, so the database can be started from the staging folder. +struct Settings { + app_password: String, + world_port: u16, +} + +fn write_settings(root: &Path, data_dir: &Path) -> Result { + let project = random_hex(10); + let (auth, world, ra) = (pick_port(DEFAULT_PORTS.0)?, pick_port(DEFAULT_PORTS.1)?, pick_port(DEFAULT_PORTS.2)?); + let (root_password, app_password) = (random_hex(48), random_hex(48)); + fs::create_dir_all(root.join("Settings"))?; + let docker = Config { project, bind_address: "127.0.0.1".into(), mysql_image: super::MYSQL_IMAGE.into(), data_dir: Some(data_dir.to_string_lossy().into_owned()) }; + fsx::atomic_write_json(&root.join(MARKER), &docker)?; + // Same file and keys as a repack, so the console, the ports and the settings screens work unchanged. The database is + // not published on the host; its port is only there to fill the key. + fsx::atomic_write_json( + &root.join("Settings/repack.json"), + &serde_json::json!({ "mysqlPort": 3307, "authPort": auth, "worldPort": world, "raPort": ra, "raUsername": db::SERVICE_ACCOUNT, "raPassword": "" }), + )?; + let secrets = root.join("Settings/database.json"); + fsx::atomic_write_json(&secrets, &serde_json::json!({ "rootPassword": root_password, "appPassword": app_password }))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&secrets, fs::Permissions::from_mode(0o600))?; + } + Ok(Settings { app_password, world_port: world }) +} + +/// The package must hold what a Docker installation is made of; say what is missing instead of failing later. +fn check_extracted(root: &Path) -> Result<()> { + let mut missing = Vec::new(); + for rel in ["Core/worldserver", "Core/authserver", "Core/configs/worldserver.conf.dist", "Core/configs/authserver.conf.dist"] { + if !root.join(rel).is_file() { + missing.push(rel.to_string()); + } + } + for (kind, _) in DATABASES { + if !root.join(BASELINE_DIR).join(format!("{kind}.sql.zst")).is_file() { + missing.push(format!("{BASELINE_DIR}/{kind}.sql.zst")); + } + } + if missing.is_empty() { + Ok(()) + } else { + Err(Error::Invalid(format!("This package is not a complete Linux server package; missing: {}.", missing.join(", ")))) + } +} + +/// Record every migration of the package as applied without running it, in a few statements (one `docker exec` for each +/// of more than a thousand would take minutes). Same ledger rows as `migrations::baseline`. +fn baseline_statements(list: &[Migration]) -> Result> { + let ok = |t: &str| !t.is_empty() && t.len() <= 190 && t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')); + let mut rows = Vec::with_capacity(list.len()); + for m in list { + if !matches!(m.db.as_str(), "auth" | "characters" | "world") || !ok(&m.id) || m.sha256.len() != 64 || !m.sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(Error::InvalidManifest(format!("migration {:?} is not valid", m.id))); + } + rows.push(format!("('{}','{}','{}','applied',NOW(),NULL,1)", m.db, m.id, m.sha256)); + } + Ok(rows + .chunks(400) + .map(|c| format!("REPLACE INTO `acore_world`.`coa_manager_migrations` (`db`,`id`,`sha256`,`status`,`applied_at`,`error`,`baseline`) VALUES {};", c.join(","))) + .collect()) +} + +/// Create the three schemas and the game servers' account, load the starting databases and record the package's migrations +/// as applied. The database container is started from `root` and stopped again by the caller. +fn build_database(d: &dyn Docker, root: &Path, list: &[Migration], s: &Settings, say: &dyn Fn(u8, String)) -> Result<()> { + let started = super::run_with(d, root, Verb::StartMysql)?; + if !started.ok { + return Err(Error::Invalid(started.human.map(|h| h.message.to_string()).unwrap_or_else(|| "The database could not be started.".into()))); + } + let db = Db::from_repack(root, Account::Admin)?; + let mut sql = String::new(); + for (_, schema) in DATABASES { + sql.push_str(&format!("CREATE DATABASE `{schema}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n")); + } + // The pattern also covers the schemas of other realms (acore_world_wildcard, ...). + sql.push_str(&format!("CREATE USER 'acore'@'%' IDENTIFIED BY '{}';\nGRANT ALL PRIVILEGES ON `acore\\_%`.* TO 'acore'@'%';\nFLUSH PRIVILEGES;\n", s.app_password)); + db.query(&sql)?; + + for (i, (kind, schema)) in DATABASES.iter().enumerate() { + say(83 + (i * 4) as u8, format!("Loading the {kind} database")); + db.import_from(schema, &root.join(BASELINE_DIR).join(format!("{kind}.sql.zst")))?; + } + + say(95 - 3, format!("Recording {} database updates as applied", list.len())); + // The ledger table is created by the first read of it. + migrations::Store::load(&db)?; + for statement in baseline_statements(list)? { + db.query(&statement)?; + } + + // The realm advertises the port the game server really uses on this computer. + db.query(&format!("UPDATE acore_auth.realmlist SET port={} WHERE id=1;", s.world_port))?; + // The server console gets its own account with a random password. + let console_pw = db.provision_service_account()?; + db::write_console_credentials(root, &console_pw)?; + if !Db::from_repack(root, Account::Admin)?.ping() { + return Err(Error::Invalid("The database did not answer after it was set up.".into())); + } + Ok(()) +} + +pub fn install(p: &Params, report: &dyn Fn(Step)) -> Result { + install_with(&SystemDocker, p, report) +} + +pub fn install_with(d: &dyn Docker, p: &Params, report: &dyn Fn(Step)) -> Result { + let say = |step: &'static str, percent: u8, detail: Option| report(Step { step, percent, detail }); + let dest = p.dest.clone(); + let staging_root = { + let mut n = dest.file_name().ok_or_else(|| Error::Invalid("bad destination".into()))?.to_os_string(); + n.push(".installing"); + dest.with_file_name(n) + }; + let meta_dir = metadata_dir_for(&dest)?; + + say("Checking your computer", 2, None); + super::check_docker(d)?; + // The manifest first: it is signed, and tells us how much space we need. + let (m, manifest_bytes) = fetch_manifest(&p.source, p.trusted_key)?; + if !matches!(m.kind, manifest::Kind::Base | manifest::Kind::Update) || !m.compatible_with_manager(crate::MANAGER_VERSION) { + return Err(Error::Invalid("This package needs a newer version of CoA Server Manager.".into())); + } + let archive = m.archive.clone().ok_or_else(|| Error::InvalidManifest("no archive".into()))?; + let download_size: u64 = archive.parts.iter().map(|x| x.size).sum(); + + let pre = preflight(&dest, &p.data_dir, archive.unpacked_size + download_size, p.registry); + if !pre.ok { + return Err(Error::Invalid(pre.problems.iter().map(|x| x.message.clone()).collect::>().join(" "))); + } + + let parts_dir = fetch_parts(&p.source, &m, &meta_dir.join("staging").join("download"), &p.cancel, &|frac, detail| say("Downloading server", 5 + (frac * 45.0) as u8, detail))?; + + if staging_root.exists() { + if staging_root.join(STAGING_MARKER).is_file() { + fs::remove_dir_all(&staging_root)?; // leftover of an earlier failed attempt that we created + } else { + return Err(Error::Invalid(format!("{} already exists and was not created by the Manager.", staging_root.display()))); + } + } + fs::create_dir_all(&staging_root)?; + fs::write(staging_root.join(STAGING_MARKER), b"")?; + + let mut project: Option = None; + let result = (|| -> Result { + say("Unpacking", 50, None); + crate::package::extract(&parts_dir, &m, &staging_root, &|done, total| say("Unpacking", 50 + (done * 30 / total.max(1)) as u8, None))?; + check_extracted(&staging_root)?; + + say("Preparing database", 82, None); + let settings = write_settings(&staging_root, &p.data_dir)?; + project = Some(Config::load(&staging_root)?); + let progress = |percent: u8, detail: String| say("Preparing database", percent, Some(detail)); + let built = build_database(d, &staging_root, &m.migrations, &settings, &progress); + let _ = super::run_with(d, &staging_root, Verb::StopAll); + built?; + fs::remove_file(staging_root.join(STAGING_MARKER))?; + + // Commit: the only moment the destination changes. The database lives in a docker volume, so it does not care + // that the folder moves. + say("Finishing", 95, None); + if dest.exists() { + fs::remove_dir(&dest)?; // only succeeds for an empty folder (preflight verified) + } + fs::rename(&staging_root, &dest)?; + super::ensure_main_configs(&dest)?; + crate::config::materialize_module_configs(&dest)?; + + let mut meta = InstallMeta::new(InstallKind::New, &dest); + meta.layout = LAYOUT_DOCKER_V1.into(); + meta.core.commit = m.core.commit.clone(); + meta.core.version = Some(m.version.clone()); + meta.database.schemas = DATABASES.iter().map(|(_, s)| s.to_string()).collect(); + meta.managed_files = m.files.iter().map(|f| f.path.clone()).collect(); + for f in &m.files { + meta.original_hashes.insert(f.path.clone(), f.sha256.clone()); + } + let md = MetaDir::create(&dest, &meta)?; + fsx::atomic_write(&md.root.join("manifests").join("base.json"), &manifest_bytes)?; + p.registry.register(&meta.id, &dest)?; + say("Ready", 100, None); + Ok(Installed { id: meta.id, path: dest.to_string_lossy().into_owned(), version: m.version.clone() }) + })(); + + if result.is_err() && staging_root.join(STAGING_MARKER).exists() { + // Unfinished work of ours; the destination was never touched. Give back what docker holds for it. + if let Some(cfg) = &project { + super::destroy(d, cfg); + } + let _ = fs::remove_dir_all(&staging_root); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::docker::cli::{Call, Output}; + use crate::package::{self, BuildOptions}; + use base64::Engine; + use ed25519_dalek::{Signer, SigningKey}; + use std::cell::RefCell; + + struct Fake { + calls: RefCell>>, + unavailable: bool, + } + + impl Fake { + fn new(unavailable: bool) -> Fake { + Fake { calls: Default::default(), unavailable } + } + } + + impl Docker for Fake { + fn run(&self, call: &Call) -> Result { + self.calls.borrow_mut().push(call.args.clone()); + if self.unavailable { + return Err(Error::Invalid("docker could not be started: not found".into())); + } + Ok(Output { code: Some(0), stdout: "27.3.1".into(), stderr: String::new() }) + } + + fn pause(&self, _d: std::time::Duration) {} + } + + fn reg(d: &Path) -> Registry { + Registry::at(d.join("reg/installs.json")) + } + + fn codes(p: &Preflight) -> Vec<&'static str> { + p.problems.iter().map(|x| x.code).collect() + } + + fn data_folder(d: &Path) -> PathBuf { + let data = d.join("data"); + fs::create_dir_all(data.join("dbc")).unwrap(); + fs::create_dir_all(data.join("maps")).unwrap(); + data + } + + /// A signed package folder holding `files`, and the key that verifies it. + fn signed_package(d: &Path, files: &[(&str, &str)]) -> (PathBuf, String) { + let src = d.join("src"); + for (rel, content) in files { + let p = src.join(rel); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(p, content).unwrap(); + } + let pkg = d.join("pkg"); + let opts = BuildOptions { kind: manifest::Kind::Update, version: "0.1.0".into(), core_commit: None, built_at: "x".into(), part_size: 1 << 20, bots_commit: None, migrations: vec![] }; + package::build(&src, &pkg, &opts, &|_| {}).unwrap(); + let key = SigningKey::generate(&mut rand_core::OsRng); + let engine = base64::engine::general_purpose::STANDARD; + let sig = engine.encode(key.sign(&fs::read(pkg.join("manifest.json")).unwrap()).to_bytes()); + fs::write(pkg.join("manifest.json.sig"), sig).unwrap(); + (pkg, engine.encode(key.verifying_key().to_bytes())) + } + + const FULL: [(&str, &str); 7] = [ + ("Core/worldserver", "w"), + ("Core/authserver", "a"), + ("Core/configs/worldserver.conf.dist", "x"), + ("Core/configs/authserver.conf.dist", "x"), + ("Database/baseline/auth.sql.zst", "dump"), + ("Database/baseline/characters.sql.zst", "dump"), + ("Database/baseline/world.sql.zst", "dump"), + ]; + + #[cfg(unix)] + #[test] + fn the_destination_rules_fit_a_linux_computer() { + let d = tempfile::tempdir().unwrap(); + let data = data_folder(d.path()); + let ok = |dest: &Path| codes(&preflight(dest, &data, 1000, ®(d.path()))); + assert!(!ok(&d.path().join("CoaServer")).iter().any(|c| *c != "space"), "a new folder is fine"); + for bad in ["/usr/local/coa", "/etc", "/", "/proc/x"] { + assert!(ok(Path::new(bad)).contains(&"system_folder"), "{bad}"); + } + assert!(ok(Path::new("relative/dir")).contains(&"relative")); + assert!(ok(Path::new("/srv/with:colon")).contains(&"colon")); + let existing = d.path().join("existing"); + fs::create_dir_all(&existing).unwrap(); + fs::write(existing.join("notes.txt"), b"x").unwrap(); + assert!(ok(&existing).contains(&"not_empty")); + let r = reg(d.path()); + r.register("1", &existing).unwrap(); + assert!(codes(&preflight(&existing.join("inner"), &data, 1000, &r)).contains(&"registered")); + // The same name in another case is another folder on Linux. + assert!(!codes(&preflight(&d.path().join("EXISTING"), &data, 1000, &r)).contains(&"registered")); + } + + // The tests marked for unix use absolute folders of the temporary directory, which on Windows start with a drive letter and a + // colon; the Docker backend refuses a colon in a folder it mounts (it would be read as a separator of `--volume`) and only runs + // on Linux, so Windows has nothing to check here. + #[cfg(unix)] + #[test] + fn the_game_data_folder_must_look_like_game_data() { + let d = tempfile::tempdir().unwrap(); + let reg = reg(d.path()); + let dest = d.path().join("srv"); + let empty = d.path().join("empty"); + fs::create_dir_all(&empty).unwrap(); + assert!(codes(&preflight(&dest, &empty, 1, ®)).contains(&"data_missing")); + assert!(codes(&preflight(&dest, Path::new("relative/data"), 1, ®)).contains(&"data_path")); + assert!(!codes(&preflight(&dest, &data_folder(d.path()), 1, ®)).iter().any(|c| c.starts_with("data"))); + } + + #[cfg(unix)] + #[test] + fn new_settings_name_the_containers_and_keep_the_secrets_private() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + let data = data_folder(d.path()); + let s = write_settings(&root, &data).unwrap(); + + let cfg = Config::load(&root).unwrap(); + assert_eq!(cfg.bind_address, "127.0.0.1", "this computer only until the person opens it"); + assert_eq!(cfg.project.len(), 10); + assert_eq!(cfg.data_path(&root), data); + + let ports = crate::layout::read_ports(&root); + assert_eq!(ports.world, s.world_port); + assert!(ports.auth > 0 && ports.ra > 0); + let repack: serde_json::Value = fsx::read_json(&root.join("Settings/repack.json")).unwrap(); + assert_eq!(repack["raUsername"], db::SERVICE_ACCOUNT); + + let (root_pw, app_pw) = db::credentials(&root).unwrap(); + assert!(root_pw.len() == 48 && app_pw.len() == 48 && root_pw != app_pw); + assert_eq!(app_pw, s.app_password); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(root.join("Settings/database.json")).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "the passwords are for their owner only"); + } + // Two installations never share a project name. + let other = write_settings(&d.path().join("srv2"), &data).unwrap(); + assert_ne!(Config::load(&d.path().join("srv2")).unwrap().project, cfg.project); + let _ = other; + } + + #[test] + fn a_busy_default_port_is_replaced_by_a_free_one() { + let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let busy = held.local_addr().unwrap().port(); + let got = pick_port(busy).unwrap(); + assert_ne!(got, busy); + drop(held); + assert_eq!(pick_port(busy).unwrap(), busy, "free again: the usual port is used"); + } + + #[test] + fn a_package_is_checked_for_everything_an_installation_needs() { + let d = tempfile::tempdir().unwrap(); + let root = d.path(); + assert!(check_extracted(root).unwrap_err().to_string().contains("Core/worldserver")); + for (rel, c) in FULL { + let p = root.join(rel); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(p, c).unwrap(); + } + check_extracted(root).unwrap(); + fs::remove_file(root.join("Database/baseline/world.sql.zst")).unwrap(); + assert!(check_extracted(root).unwrap_err().to_string().contains("Database/baseline/world.sql.zst")); + } + + fn migration(db: &str, id: &str) -> Migration { + Migration { db: db.into(), id: id.into(), sha256: "ab".repeat(32), destructive: false, compatible_sha256: vec![] } + } + + #[test] + fn the_package_migrations_are_recorded_as_applied_in_a_few_statements() { + let list: Vec = (0..1000).map(|i| migration(["auth", "characters", "world"][i % 3], &format!("rev_{i}"))).collect(); + let statements = baseline_statements(&list).unwrap(); + assert_eq!(statements.len(), 3, "400 rows at most in a statement"); + assert!(statements[0].starts_with("REPLACE INTO `acore_world`.`coa_manager_migrations`")); + assert_eq!(statements.iter().map(|s| s.matches("'applied'").count()).sum::(), 1000); + assert!(statements[0].contains("('auth','rev_0',") && statements[0].contains(",NOW(),NULL,1)"), "marked as a baseline"); + assert!(baseline_statements(&[]).unwrap().is_empty()); + } + + #[test] + fn a_migration_that_could_carry_sql_is_refused_before_anything_is_written() { + for bad in [migration("world", "x'); DROP TABLE y;--"), migration("mysql", "ok"), migration("world", ""), Migration { sha256: "zz".into(), ..migration("world", "ok") }] { + assert!(baseline_statements(&[migration("auth", "fine"), bad]).is_err()); + } + } + + #[test] + fn without_docker_nothing_is_downloaded_or_created() { + let d = tempfile::tempdir().unwrap(); + let (pkg, key) = signed_package(d.path(), &FULL); + let dest = d.path().join("dest"); + let r = reg(d.path()); + let fake = Fake::new(true); + let err = install_with(&fake, &Params { source: Source::Dir(pkg), dest: dest.clone(), data_dir: data_folder(d.path()), trusted_key: &key, registry: &r, cancel: Cancel::default() }, &|_| {}).unwrap_err(); + assert!(err.to_string().to_lowercase().contains("docker"), "{err}"); + assert!(!dest.exists() && !dest.with_file_name("dest.installing").exists()); + } + + #[cfg(unix)] + #[test] + fn a_package_without_the_server_is_refused_and_leaves_nothing() { + let d = tempfile::tempdir().unwrap(); + let (pkg, key) = signed_package(d.path(), &[("Core/configs/worldserver.conf.dist", "x"), ("README.txt", "hello")]); + let dest = d.path().join("dest"); + let r = reg(d.path()); + let fake = Fake::new(false); + let err = install_with(&fake, &Params { source: Source::Dir(pkg), dest: dest.clone(), data_dir: data_folder(d.path()), trusted_key: &key, registry: &r, cancel: Cancel::default() }, &|_| {}).unwrap_err(); + assert!(err.to_string().contains("not a complete Linux server package"), "{err}"); + assert!(!dest.exists() && !dest.with_file_name("dest.installing").exists()); + assert!(r.list().unwrap().is_empty()); + } + + #[cfg(unix)] + #[test] + fn a_failed_database_setup_gives_back_what_docker_holds_and_leaves_no_server() { + let d = tempfile::tempdir().unwrap(); + let (pkg, key) = signed_package(d.path(), &FULL); + let dest = d.path().join("dest"); + let r = reg(d.path()); + // This fake docker answers every command with success and no container, so the database never becomes ready. + let fake = Fake::new(false); + let err = install_with(&fake, &Params { source: Source::Dir(pkg), dest: dest.clone(), data_dir: data_folder(d.path()), trusted_key: &key, registry: &r, cancel: Cancel::default() }, &|_| {}).unwrap_err(); + assert!(err.to_string().to_lowercase().contains("database"), "{err}"); + assert!(!dest.exists() && !dest.with_file_name("dest.installing").exists(), "no half-installed server"); + assert!(r.list().unwrap().is_empty()); + let calls = fake.calls.borrow(); + let removed = |suffix: &str| calls.iter().any(|c| c[0] == "rm" && c.last().is_some_and(|n| n.starts_with("coa-") && n.ends_with(suffix))); + assert!(removed("-db") && removed("-world") && removed("-auth"), "containers removed: {calls:?}"); + assert!(calls.iter().any(|c| c[..2] == ["volume", "rm"]) && calls.iter().any(|c| c[..2] == ["network", "rm"])); + } +} diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index cdac849..bbf813a 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -80,6 +80,26 @@ fn docker(d: &dyn Docker, args: &[&str], timeout: Duration) -> Step Result { + let mut log = Log::default(); + match preflight(d, &mut log) { + Ok(()) => Ok(log.0.join(" ")), + Err(f) => Err(crate::error::Error::Invalid(format!("{} {}", f.code.human().message, f.output.trim()).trim().to_string())), + } +} + +/// Remove everything docker holds for an installation: containers, network and the database volume. Used when an +/// installation fails before it is finished, and later to uninstall. Missing pieces are not an error. +pub(crate) fn destroy(d: &dyn Docker, cfg: &Config) { + let n = cfg.names(); + for name in [&n.auth, &n.world, &n.db] { + let _ = d.run(&Call::new(&["rm", "--force", name], QUICK)); + } + let _ = d.run(&Call::new(&["network", "rm", &n.network], QUICK)); + let _ = d.run(&Call::new(&["volume", "rm", &n.volume], QUICK)); +} + fn preflight(d: &dyn Docker, log: &mut Log) -> Step<()> { let o = docker(d, &["version", "--format", "{{.Server.Version}}"], QUICK)?; if !o.ok() { @@ -204,6 +224,7 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L } ensure_image(d, log)?; + super::ensure_main_configs(root).map_err(|e| Failure { code: ErrorCode::ServerFilesIncomplete, output: e.to_string() })?; std::fs::create_dir_all(root.join("Core/Logs")).map_err(|e| Failure { code: ErrorCode::ServerFilesIncomplete, output: e.to_string() })?; let owner = owner_of(root); let host = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); @@ -212,7 +233,7 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L if !state.get(&n.world).is_some_and(Container::running) { remove(d, &n.world); let mut call = Call::new(&[], Duration::from_secs(60)); - call.args = game_args(cfg, &n, GameKind::World, &host, ports.world, ports.ra, owner.as_deref()); + call.args = game_args(cfg, &n, GameKind::World, &host, &cfg.data_path(&host), ports.world, ports.ra, owner.as_deref()); call.env = database_env(&secrets, true); run_container(d, &call, &n.world, log)?; } @@ -221,7 +242,7 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L if !state.get(&n.auth).is_some_and(Container::running) { remove(d, &n.auth); let mut call = Call::new(&[], Duration::from_secs(60)); - call.args = game_args(cfg, &n, GameKind::Auth, &host, ports.auth, 0, owner.as_deref()); + call.args = game_args(cfg, &n, GameKind::Auth, &host, &cfg.data_path(&host), ports.auth, 0, owner.as_deref()); call.env = database_env(&secrets, false); run_container(d, &call, &n.auth, log)?; } @@ -320,8 +341,11 @@ fn db_args(cfg: &Config, n: &Names) -> Vec { a.extend(["--volume".into(), format!("{}:/var/lib/mysql", n.volume)]); // The password comes from the environment of the docker client, not from this command line. a.extend(["--env".into(), "MYSQL_ROOT_PASSWORD".into()]); - // "mysqladmin ping" succeeds as soon as the server answers, with or without a login. - a.extend(["--health-cmd".into(), "mysqladmin ping --silent".into()]); + // "mysqladmin ping" succeeds as soon as the server answers, with or without a login. It must go over TCP, like + // everything the Manager sends: while a new database initialises, the image first runs a temporary server that + // answers on its socket but takes no network connection, and a socket ping would call the container healthy for a few + // seconds in which every TCP connection is still refused. + a.extend(["--health-cmd".into(), "mysqladmin ping --protocol=tcp --host=127.0.0.1 --silent".into()]); a.extend(["--health-interval".into(), "5s".into(), "--health-timeout".into(), "5s".into(), "--health-retries".into(), "40".into()]); a.extend(["--stop-timeout".into(), "60".into()]); a.push(cfg.mysql_image.clone()); @@ -345,7 +369,7 @@ fn database_env(s: &Secrets, world: bool) -> Vec<(String, String)> { env } -fn game_args(cfg: &Config, n: &Names, kind: GameKind, host: &Path, port: u16, ra_port: u16, owner: Option<&str>) -> Vec { +fn game_args(cfg: &Config, n: &Names, kind: GameKind, host: &Path, data: &Path, port: u16, ra_port: u16, owner: Option<&str>) -> Vec { let (name, alias, binary, conf, inner_port) = match kind { GameKind::World => (&n.world, "world", "./worldserver", "configs/worldserver.conf", WORLD_PORT), GameKind::Auth => (&n.auth, "auth", "./authserver", "configs/authserver.conf", AUTH_PORT), @@ -370,7 +394,7 @@ fn game_args(cfg: &Config, n: &Names, kind: GameKind, host: &Path, port: u16, ra // the container and stops the server when it cannot find one. a.extend(env("AC_UPDATES_ENABLE_DATABASES", "0")); if kind == GameKind::World { - a.extend(["--volume".into(), format!("{}:{DATA}:ro", host.join("Data").display())]); + a.extend(["--volume".into(), format!("{}:{DATA}:ro", data.display())]); a.extend(env("AC_DATA_DIR", DATA)); // The remote console is only ever reachable from this computer. a.extend(["--publish".into(), format!("127.0.0.1:{ra_port}:{RA_PORT}")]); @@ -674,6 +698,17 @@ mod tests { assert!(runs[1].args.windows(2).any(|w| w == ["--env", "AC_WORLD_DATABASE_INFO"]), "name only, the value is in the environment"); } + #[test] + fn the_database_is_only_healthy_once_it_takes_tcp_connections() { + let (_d, root) = server("t1"); + let sim = Sim::new(); + assert!(start_all(&sim, &root).ok); + let db = &sim.calls_of("run")[0].args; + let i = db.iter().position(|a| a == "--health-cmd").expect("the database has a health check"); + // Found on a new volume: a socket-only ping reported healthy about four seconds before TCP worked. + assert!(db[i + 1].contains("--protocol=tcp") && db[i + 1].contains("--host=127.0.0.1"), "{}", db[i + 1]); + } + #[test] fn the_world_container_is_wired_like_the_repack_layout() { let (_d, root) = server("t1"); @@ -714,8 +749,8 @@ mod tests { let (_d, root) = server("ports"); let cfg = Config::load(&root).unwrap(); let n = cfg.names(); - let world = game_args(&cfg, &n, GameKind::World, &root, 18085, 13443, None); - let auth = game_args(&cfg, &n, GameKind::Auth, &root, 13724, 0, None); + let world = game_args(&cfg, &n, GameKind::World, &root, &root.join("Data"), 18085, 13443, None); + let auth = game_args(&cfg, &n, GameKind::Auth, &root, &root.join("Data"), 13724, 0, None); assert!(world.iter().any(|a| a == "127.0.0.1:18085:8085")); assert!(world.iter().any(|a| a == "127.0.0.1:13443:3443")); assert!(world.iter().any(|a| a == "AC_WORLD_SERVER_PORT=8085")); diff --git a/crates/coa-core/src/docker/mod.rs b/crates/coa-core/src/docker/mod.rs index ffa10ce..04cc121 100644 --- a/crates/coa-core/src/docker/mod.rs +++ b/crates/coa-core/src/docker/mod.rs @@ -15,10 +15,12 @@ //! ``` mod cli; +pub mod install; mod lifecycle; pub use cli::{Call, Docker, Output, SystemDocker}; -pub use lifecycle::{observe, observe_with, run, run_with}; +pub use lifecycle::{check_docker, observe, observe_with, run, run_with}; +pub(crate) use lifecycle::destroy; use std::net::IpAddr; use std::path::Path; @@ -31,7 +33,7 @@ use crate::layout::{self, Classification, ScanReport}; /// Present in a Docker installation, absent from a repack. pub const MARKER: &str = "Settings/docker.json"; -const MYSQL_IMAGE: &str = "mysql:8.4"; +pub(crate) const MYSQL_IMAGE: &str = "mysql:8.4"; const RUNTIME_DOCKERFILE: &str = include_str!("Dockerfile.runtime"); pub fn is_docker(root: &Path) -> bool { @@ -57,6 +59,10 @@ pub struct Config { pub bind_address: String, #[serde(default = "mysql_image")] pub mysql_image: String, + /// Where the game data (dbc, maps, vmaps, mmaps) is, when it is not the `Data` folder of the server. Used in place and + /// mounted read-only, so one copy can serve several servers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_dir: Option, } pub(crate) struct Names { @@ -74,6 +80,11 @@ impl Config { Ok(cfg) } + /// The folder mounted as the game data. + pub fn data_path(&self, root: &Path) -> std::path::PathBuf { + self.data_dir.as_deref().map(std::path::PathBuf::from).unwrap_or_else(|| root.join("Data")) + } + fn validate(&self) -> Result<()> { let name_ok = !self.project.is_empty() && self.project.len() <= 32 @@ -85,6 +96,12 @@ impl Config { if self.bind_address.parse::().is_err() { return Err(Error::Invalid(format!("{} is not an IP address.", self.bind_address))); } + if let Some(d) = &self.data_dir { + // The folder goes into a `--volume host:container:ro` option, where a colon would be read as a separator. + if !Path::new(d).is_absolute() || d.contains(':') { + return Err(Error::Invalid("The game data folder must be a full path without a colon.".into())); + } + } let image_ok = !self.mysql_image.is_empty() && self.mysql_image.chars().all(|c| c.is_ascii_alphanumeric() || "._/:@-".contains(c)); if !image_ok { return Err(Error::Invalid("The database image name is not valid.".into())); @@ -103,6 +120,21 @@ impl Config { } } +/// Create the world and auth configuration files from their `.dist` templates when they do not exist. A package only +/// carries the templates, and on Windows the launcher writes the active files at every start; here nothing else would. +/// An existing file is never touched, so the person's settings survive updates. Returns the files created. +pub fn ensure_main_configs(root: &Path) -> Result> { + let mut created = Vec::new(); + for name in ["worldserver", "authserver"] { + let (conf, dist) = (root.join(format!("Core/configs/{name}.conf")), root.join(format!("Core/configs/{name}.conf.dist"))); + if !conf.exists() && dist.is_file() { + std::fs::copy(&dist, &conf)?; + created.push(format!("{name}.conf")); + } + } + Ok(created) +} + /// Name of the runtime image: it follows the content of its Dockerfile, so changing the libraries builds a new image. pub(crate) fn runtime_image() -> String { format!("coa-runtime:{}", &fsx::sha256_bytes(RUNTIME_DOCKERFILE.as_bytes())[..12]) @@ -123,7 +155,8 @@ pub(crate) fn scan(root: &Path) -> Result { let bot_active = modules_dir.join("mod_coa_playerbots.conf"); let bot_conf = if bot_active.is_file() { bot_active } else { modules_dir.join("mod_coa_playerbots.conf.dist") }; let bot_config_keys = layout::count_bot_keys(&bot_conf); - let has_data = layout::exists(root, "Data/dbc") && layout::exists(root, "Data/maps"); + let data = cfg.as_ref().map(|c| c.data_path(root)).unwrap_or_else(|_| root.join("Data")); + let has_data = data.join("dbc").is_dir() && data.join("maps").is_dir(); let has_confs = layout::exists(root, "Core/configs/worldserver.conf") && layout::exists(root, "Core/configs/authserver.conf"); let items = vec![ @@ -162,3 +195,30 @@ pub(crate) fn scan(root: &Path) -> Result { }) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn the_main_configs_are_created_from_their_templates_and_never_overwritten() { + let d = tempfile::tempdir().unwrap(); + let cfg = d.path().join("Core/configs"); + fs::create_dir_all(&cfg).unwrap(); + fs::write(cfg.join("worldserver.conf.dist"), "Setting = default\n").unwrap(); + fs::write(cfg.join("authserver.conf.dist"), "Auth = default\n").unwrap(); + + assert_eq!(ensure_main_configs(d.path()).unwrap(), ["worldserver.conf", "authserver.conf"]); + assert_eq!(fs::read_to_string(cfg.join("worldserver.conf")).unwrap(), "Setting = default\n"); + + // The person's own settings survive the next start. + fs::write(cfg.join("worldserver.conf"), "Setting = mine\n").unwrap(); + assert!(ensure_main_configs(d.path()).unwrap().is_empty()); + assert_eq!(fs::read_to_string(cfg.join("worldserver.conf")).unwrap(), "Setting = mine\n"); + // Without a template there is nothing to create and nothing fails. + fs::remove_file(cfg.join("authserver.conf")).unwrap(); + fs::remove_file(cfg.join("authserver.conf.dist")).unwrap(); + assert!(ensure_main_configs(d.path()).unwrap().is_empty()); + } +} diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 573f699..b3c8a1e 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -31,8 +31,9 @@ impl Verb { } } - fn timeout(self) -> Duration { + fn timeout(self, root: &Path) -> Duration { match self { + Verb::StartAll | Verb::StartWorld if squid_enabled(root) => Duration::from_secs(2700), Verb::StartAll | Verb::StartWorld => Duration::from_secs(420), Verb::StartMysql => Duration::from_secs(120), Verb::StopAll => Duration::from_secs(240), @@ -40,6 +41,14 @@ impl Verb { } } +fn squid_enabled(root: &Path) -> bool { + let active = root.join("Core/configs/modules/playerbots.conf"); + let path = if active.exists() { active } else { root.join("Core/configs/modules/playerbots.conf.dist") }; + std::fs::read(path).ok().and_then(|bytes| crate::config::parser::ConfFile::parse_bytes(&bytes).ok()) + .is_some_and(|config| config.get("AiPlayerbot.Enabled").is_none_or(|value| + !matches!(value.trim().trim_matches('"').to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off"))) +} + #[derive(Debug, Clone, Serialize)] pub struct DriverOutcome { pub ok: bool, @@ -149,7 +158,7 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result bool { || patch_launcher_imports(&s).is_ok_and(|p| p.as_bytes() == actual)) } +/// Recover legacy metadata only when undoing supported Manager edits reproduces the recorded hash. +pub(crate) fn launcher_matches_recorded(recorded: &str, actual: &[u8]) -> bool { + let Ok(text) = std::str::from_utf8(actual) else { return false; }; + let without_import = text.replacen( + "ROOT = Path(__file__).resolve().parents[1]\nsys.path.insert(0, str(Path(__file__).resolve().parent))", + "ROOT = Path(__file__).resolve().parents[1]", 1, + ); + for candidate in [Some(without_import.clone()), crate::realms::unpatch_launcher(&without_import)].into_iter().flatten() { + if crate::fsx::sha256_bytes(candidate.as_bytes()).eq_ignore_ascii_case(recorded) + && launcher_matches(&candidate, actual) { return true; } + } + false +} + pub(crate) fn startup_failure(root: &Path, started: &DriverOutcome) -> String { let code = started.code.filter(|c| *c != ErrorCode::Unknown) .or_else(|| crate::health::diagnose_installation(root)); @@ -216,6 +239,18 @@ pub(crate) fn startup_failure(root: &Path, started: &DriverOutcome) -> String { mod tests { use super::*; + #[test] + fn large_squid_provisioning_has_time_to_finish_without_extending_shutdown() { + let folder = tempfile::tempdir().unwrap(); + let modules = folder.path().join("Core/configs/modules"); + std::fs::create_dir_all(&modules).unwrap(); + std::fs::write(modules.join("playerbots.conf.dist"), "AiPlayerbot.Enabled = 1\n").unwrap(); + assert_eq!(Verb::StartAll.timeout(folder.path()), Duration::from_secs(2700)); + assert_eq!(Verb::StopAll.timeout(folder.path()), Duration::from_secs(240)); + std::fs::write(modules.join("playerbots.conf"), "AiPlayerbot.Enabled = 0\n").unwrap(); + assert_eq!(Verb::StartWorld.timeout(folder.path()), Duration::from_secs(420)); + } + #[test] fn translates_known_launcher_messages() { assert_eq!(translate("RuntimeError: Port 8085 is already used. Stop the other server"), ErrorCode::PortInUse); diff --git a/crates/coa-core/src/friends.rs b/crates/coa-core/src/friends.rs index 738f1bd..9b8a71a 100644 --- a/crates/coa-core/src/friends.rs +++ b/crates/coa-core/src/friends.rs @@ -31,11 +31,25 @@ pub struct Settings { pub mode: Mode, /// The address friends type into their client for this mode. pub host: Option, + /// Independent of the effective host of the current mode. None means automatic LAN detection. + #[serde(default)] + pub lan_address_override: Option, } impl Default for Settings { fn default() -> Self { - Settings { mode: Mode::Local, host: None } + Settings { mode: Mode::Local, host: None, lan_address_override: None } + } +} + +impl Settings { + /// Only LAN application changes the LAN preference. Other modes keep it for the next LAN session. + pub fn select_mode(&mut self, mode: Mode, host: String, lan_override: Option) { + if mode == Mode::Lan { + self.lan_address_override = lan_override; + } + self.mode = mode; + self.host = Some(host); } } @@ -233,7 +247,7 @@ CoA.AllowRemoteClients = 0 fn a_shared_server_gets_its_bind_address_back_before_start_and_a_local_one_is_left_alone() { let (_d, root, meta) = setup(); assert!(!ensure_bind(&root, &meta).unwrap(), "local mode: nothing to do"); - save(&meta, &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()) }).unwrap(); + save(&meta, &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()), ..Settings::default() }).unwrap(); assert!(ensure_bind(&root, &meta).unwrap(), "templates were reset to 127.0.0.1 -> reopened"); assert!(bind_is_open(&root)); assert!(!ensure_bind(&root, &meta).unwrap(), "already open: idempotent"); @@ -243,11 +257,39 @@ CoA.AllowRemoteClients = 0 fn settings_round_trip_and_default_to_local() { let d = tempfile::tempdir().unwrap(); assert_eq!(load(d.path()).mode, Mode::Local); - save(d.path(), &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()) }).unwrap(); + save(d.path(), &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()), ..Settings::default() }).unwrap(); let s = load(d.path()); assert_eq!((s.mode, s.host.as_deref()), (Mode::Private, Some("100.64.1.2"))); } + #[test] + fn old_settings_load_without_migrating_effective_host_to_override() { + let s: Settings = serde_json::from_str(r#"{"mode":"lan","host":"192.168.1.50"}"#).unwrap(); + assert_eq!(s.mode, Mode::Lan); + assert_eq!(s.host.as_deref(), Some("192.168.1.50")); + assert_eq!(s.lan_address_override, None); + } + + #[test] + fn lan_preference_survives_save_load_and_every_other_mode_and_can_be_cleared() { + let d = tempfile::tempdir().unwrap(); + let mut s = Settings::default(); + s.select_mode(Mode::Lan, "192.168.1.50".into(), Some("192.168.1.50".into())); + for (mode, host) in [(Mode::Private, "100.101.20.5"), (Mode::Direct, "203.0.113.9"), (Mode::Local, "127.0.0.1")] { + s.select_mode(mode, host.into(), None); + save(d.path(), &s).unwrap(); + s = load(d.path()); + assert_eq!(s.lan_address_override.as_deref(), Some("192.168.1.50")); + assert_eq!(s.host.as_deref(), Some(host)); + let lan = crate::net::resolve_lan_host(s.lan_address_override.as_deref(), Some("192.168.0.169".parse().unwrap())).unwrap(); + s.select_mode(Mode::Lan, lan, s.lan_address_override.clone()); + assert_eq!(s.host.as_deref(), Some("192.168.1.50")); + } + s.select_mode(Mode::Lan, "192.168.0.169".into(), None); + save(d.path(), &s).unwrap(); + assert_eq!(load(d.path()).lan_address_override, None); + } + #[test] fn friend_package_contains_only_whitelisted_files() { let (d, root, _m) = setup(); @@ -274,4 +316,23 @@ CoA.AllowRemoteClients = 0 } assert!(make_friend_package(&root, "bad host; x", false, &d.path().join("x.zip")).is_err()); } + + #[test] + fn manual_lan_host_survives_startup_bind_restoration_and_drives_friend_package() { + let (d, root, meta) = setup(); + let mut s = Settings::default(); + s.select_mode(Mode::Lan, "192.168.1.50".into(), Some("192.168.1.50".into())); + save(&meta, &s).unwrap(); + assert!(ensure_bind(&root, &meta).unwrap()); + assert!(bind_is_open(&root)); + let restored = load(&meta); + assert_eq!(restored.host.as_deref(), Some("192.168.1.50")); + assert_eq!(restored.lan_address_override.as_deref(), Some("192.168.1.50")); + let package = d.path().join("manual-lan.zip"); + make_friend_package(&root, restored.host.as_deref().unwrap(), false, &package).unwrap(); + let mut zip = zip::ZipArchive::new(fs::File::open(package).unwrap()).unwrap(); + let mut realm = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("realmlist.wtf").unwrap(), &mut realm).unwrap(); + assert_eq!(realm, "set realmlist 192.168.1.50\r\n"); + } } diff --git a/crates/coa-core/src/fsx.rs b/crates/coa-core/src/fsx.rs index 983fa0c..47925a1 100644 --- a/crates/coa-core/src/fsx.rs +++ b/crates/coa-core/src/fsx.rs @@ -123,7 +123,7 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { let tmp = parent.join(format!( ".{}.{}.tmp", path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - std::process::id() + uuid::Uuid::new_v4().simple() )); let write = || -> std::io::Result<()> { let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?; @@ -135,13 +135,41 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { let _ = fs::remove_file(&tmp); return Err(e.into()); } - if let Err(e) = fs::rename(&tmp, path) { + if let Err(e) = durable_replace(&tmp, path) { let _ = fs::remove_file(&tmp); return Err(e.into()); } Ok(()) } +/// The temporary file must already be flushed, on the same volume as the destination. +pub(crate) fn durable_replace(from: &Path, to: &Path) -> std::io::Result<()> { + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH}; + fn wide(path: &Path) -> std::io::Result> { + let absolute = std::path::absolute(path)?; + let text = absolute.as_os_str().to_string_lossy().replace('/', "\\"); + let extended = if text.starts_with("\\\\?\\") { text } + else if let Some(unc) = text.strip_prefix("\\\\") { format!("\\\\?\\UNC\\{unc}") } + else { format!("\\\\?\\{text}") }; + Ok(std::ffi::OsStr::new(&extended).encode_wide().chain(Some(0)).collect()) + } + let src = wide(from)?; + let dst = wide(to)?; + if unsafe { MoveFileExW(src.as_ptr(), dst.as_ptr(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) } == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + } + #[cfg(not(windows))] + { + fs::rename(from, to)?; + File::open(to.parent().unwrap())?.sync_all() + } +} + pub fn atomic_write_json(path: &Path, value: &T) -> Result<()> { let mut bytes = serde_json::to_vec_pretty(value)?; bytes.push(b'\n'); @@ -199,6 +227,16 @@ pub fn require_space(path: &Path, needed: u64) -> Result<()> { mod tests { use super::*; + #[test] + fn durable_replacement_handles_long_transaction_backup_paths() { + let dir = tempfile::tempdir().unwrap(); + let parent = dir.path().join("a".repeat(90)).join("b".repeat(90)); + let path = parent.join("configuration-with-a-long-name.template"); + atomic_write(&path, b"old").unwrap(); + atomic_write(&path, b"new").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"new"); + } + #[test] fn safe_join_accepts_normal_and_rejects_traversal() { let root = Path::new("C:/srv"); diff --git a/crates/coa-core/src/install.rs b/crates/coa-core/src/install.rs index bea801d..2e88aa4 100644 --- a/crates/coa-core/src/install.rs +++ b/crates/coa-core/src/install.rs @@ -35,7 +35,7 @@ pub struct Preflight { pub free_bytes: u64, } -fn problem(code: &'static str, message: &str) -> Problem { +pub(crate) fn problem(code: &'static str, message: &str) -> Problem { Problem { code, message: message.into() } } @@ -133,7 +133,7 @@ pub struct Installed { pub version: String, } -fn random_hex(n: usize) -> String { +pub(crate) fn random_hex(n: usize) -> String { let mut s = String::new(); while s.len() < n { s.push_str(&uuid::Uuid::new_v4().simple().to_string()); @@ -148,7 +148,7 @@ pub(crate) fn bootstrap_database(root: &Path) -> Result<()> { with_scratch_ports(root, || bootstrap_database_inner(root)) } -fn free_port() -> Result { +pub(crate) fn free_port() -> Result { Ok(std::net::TcpListener::bind("127.0.0.1:0")?.local_addr()?.port()) } diff --git a/crates/coa-core/src/layout.rs b/crates/coa-core/src/layout.rs index 678c6df..f4359d3 100644 --- a/crates/coa-core/src/layout.rs +++ b/crates/coa-core/src/layout.rs @@ -315,6 +315,7 @@ pub fn scan(root: &Path) -> Result { (None, None) }; + if crate::squid::imported_repack(&root) { notes.push("import.squidRepackWarning".into()); } Ok(ScanReport { path: root.to_string_lossy().into_owned(), classification, diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index f2c7f02..6c88b12 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -38,6 +38,7 @@ pub mod realmlist; pub mod realms; pub mod registry; pub mod release; +pub mod report; pub mod release_schema; pub mod update; pub mod upnp; @@ -47,3 +48,5 @@ pub mod srp6; pub use error::{Error, ErrorCode, Result}; pub const MANAGER_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub mod squid; diff --git a/crates/coa-core/src/manifest.rs b/crates/coa-core/src/manifest.rs index 9f5fac4..b87dbe9 100644 --- a/crates/coa-core/src/manifest.rs +++ b/crates/coa-core/src/manifest.rs @@ -48,6 +48,8 @@ pub struct FileEntry { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Migration { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub compatible_sha256: Vec, pub id: String, pub db: String, pub sha256: String, @@ -134,6 +136,11 @@ impl Manifest { if parse_version(&self.min_manager_version).is_none() { return bad(format!("bad minManagerVersion {:?}", self.min_manager_version)); } + for migration in &self.migrations { + if !is_sha256(&migration.sha256) || migration.compatible_sha256.iter().any(|h| !is_sha256(h) || h == &"0".repeat(64)) { + return bad(format!("{}: invalid migration checksum compatibility", migration.id)); + } + } for (label, rev) in [("core", Some(&self.core)), ("bots", self.bots.as_ref())] { if let Some(Revision { commit: Some(c) }) = rev { if !is_commit(c) { diff --git a/crates/coa-core/src/migrations.rs b/crates/coa-core/src/migrations.rs index d7d8d9b..7341bc4 100644 --- a/crates/coa-core/src/migrations.rs +++ b/crates/coa-core/src/migrations.rs @@ -35,6 +35,7 @@ pub struct LedgerRow { /// Storage for the ledger and the ability to run a SQL file; implemented by the real database and by tests. pub trait Store { fn load(&self) -> Result>; + fn load_readonly(&self) -> Result> { self.load() } fn put(&self, row: &LedgerRow) -> Result<()>; fn run_file(&self, schema: &str, file: &Path) -> Result<()>; } @@ -52,7 +53,13 @@ impl Store for Db { self.query(&format!( "CREATE TABLE IF NOT EXISTS `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` (`db` VARCHAR(32) NOT NULL, `id` VARCHAR(190) NOT NULL, `sha256` CHAR(64) NOT NULL, `status` VARCHAR(16) NOT NULL, `applied_at` DATETIME NOT NULL, `error` TEXT NULL, `baseline` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`db`,`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;" ))?; - let out = self.query(&format!("SELECT `db`,`id`,`sha256`,`status`,`baseline`,COALESCE(HEX(`error`),'') FROM `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` ORDER BY `db`,`id`;"))?; + self.load_readonly() + } + + fn load_readonly(&self) -> Result> { + let out = if self.tables(LEDGER_SCHEMA)?.iter().any(|name| name == LEDGER_TABLE) { + self.query(&format!("SELECT `db`,`id`,`sha256`,`status`,`baseline`,COALESCE(HEX(`error`),'') FROM `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` ORDER BY `db`,`id`;"))? + } else { String::new() }; let mut rows = Vec::new(); // Files the core's own updater (or whoever prepared this database) already recorded count as applied. for (kind, schema) in db::SCHEMAS { @@ -149,6 +156,50 @@ fn recorded<'a>(rows: &'a [LedgerRow], m: &Migration) -> Option<&'a LedgerRow> { fn hash_changed(row: &LedgerRow, m: &Migration) -> bool { row.status == Status::Applied && row.sha256 != "0".repeat(64) && !row.sha256.eq_ignore_ascii_case(&m.sha256) + && !m.compatible_sha256.iter().any(|hash| hash.eq_ignore_ascii_case(&row.sha256)) +} + +/// Inspect recorded history without creating a ledger or executing migrations. +pub fn preflight(store: &dyn Store, list: &[Migration]) -> Result<()> { + check_history(&store.load_readonly()?, list) +} + +pub fn pending_count(store: &dyn Store, list: &[Migration]) -> Result { + let rows = store.load_readonly()?; + check_history(&rows, list)?; + Ok(list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).count()) +} + +fn verified_file(m: &Migration, dir: &Path) -> Result<(&'static str, std::path::PathBuf, bool)> { + if !ident_ok(&m.id) { return Err(Error::InvalidManifest(format!("migration id {:?} is not allowed", m.id))); } + let schema = db::schema_of(&m.db)?; + let path = fsx::safe_join(dir, &format!("{}/{}.sql", m.db, m.id))?; + let bytes = std::fs::read(&path).map_err(|_| Error::Invalid(format!("migration file {} is missing", m.id)))?; + let actual = fsx::sha256_bytes(&bytes); + if !actual.eq_ignore_ascii_case(&m.sha256) { return Err(Error::HashMismatch { path: path.display().to_string(), expected: m.sha256.clone(), actual }); } + Ok((schema, path, m.destructive || looks_destructive(&String::from_utf8_lossy(&bytes)))) +} + +/// Check every migration artifact, including applied history, before replacing server files. +pub fn verify_files(list: &[Migration], dir: &Path) -> Result<()> { + let mut seen = std::collections::BTreeSet::new(); + for m in list { + if !seen.insert((&m.db, &m.id)) { return Err(Error::InvalidManifest(format!("duplicate migration {} ({})", m.id, m.db))); } + verified_file(m, dir)?; + } + Ok(()) +} + +fn check_history(rows: &[LedgerRow], list: &[Migration]) -> Result<()> { + for m in list { + if recorded(rows, m).is_some_and(|r| matches!(r.status, Status::Running | Status::Failed)) { + return Err(Error::Invalid(format!("Database update {} ({}) failed or was interrupted and may be partially applied. Restore its recovery point before retrying.", m.id, m.db))); + } + if recorded(rows, m).is_some_and(|r| hash_changed(r, m)) { + return Err(Error::Invalid(format!("Applied database update {} ({}) has a different checksum. A new migration is required; it was not replayed.", m.id, m.db))); + } + } + Ok(()) } /// Record migrations as already present without running them (adopting an existing server whose database @@ -178,14 +229,7 @@ pub struct ApplyReport { pub fn apply_pending(store: &dyn Store, list: &[Migration], dir: &Path, snapshot: &dyn Fn() -> Result) -> Result { let rows = store.load()?; // The list order is the release's order of application (released updates, then pending, then modules). - for m in list { - if recorded(&rows, m).is_some_and(|r| matches!(r.status, Status::Running | Status::Failed)) { - return Err(Error::Invalid(format!("Database update {} ({}) failed or was interrupted and may be partially applied. Restore its recovery point before retrying.", m.id, m.db))); - } - if recorded(&rows, m).is_some_and(|r| hash_changed(r, m)) { - return Err(Error::Invalid(format!("Applied database update {} ({}) has a different checksum. A new migration is required; it was not replayed.", m.id, m.db))); - } - } + check_history(&rows, list)?; let todo: Vec<&Migration> = list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).collect(); tracing::info!(total = list.len(), pending = todo.len(), skipped = list.len() - todo.len(), "database migration plan"); @@ -193,17 +237,8 @@ pub fn apply_pending(store: &dyn Store, list: &[Migration], dir: &Path, snapshot let mut files = Vec::new(); let mut any_destructive = false; for m in &todo { - if !ident_ok(&m.id) { - return Err(Error::InvalidManifest(format!("migration id {:?} is not allowed", m.id))); - } - let schema = db::schema_of(&m.db)?; - let path = fsx::safe_join(dir, &format!("{}/{}.sql", m.db, m.id))?; - let bytes = std::fs::read(&path).map_err(|_| Error::Invalid(format!("migration file {} is missing", m.id)))?; - let actual = fsx::sha256_bytes(&bytes); - if !actual.eq_ignore_ascii_case(&m.sha256) { - return Err(Error::HashMismatch { path: path.display().to_string(), expected: m.sha256.clone(), actual }); - } - any_destructive |= m.destructive || looks_destructive(&String::from_utf8_lossy(&bytes)); + let (schema, path, destructive) = verified_file(m, dir)?; + any_destructive |= destructive; files.push((*m, schema, path)); } @@ -273,7 +308,7 @@ mod tests { for (db, id, sql) in files { std::fs::create_dir_all(d.path().join(db)).unwrap(); std::fs::write(d.path().join(db).join(format!("{id}.sql")), sql).unwrap(); - list.push(Migration { id: id.to_string(), db: db.to_string(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }); + list.push(Migration { compatible_sha256: vec![], id: id.to_string(), db: db.to_string(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }); } (d, list) } @@ -304,6 +339,60 @@ mod tests { assert!(store.ran.borrow().is_empty()); } + #[test] + fn staged_files_reject_missing_corrupt_duplicate_and_invalid_migrations() { + let (dir, list) = setup(&[("characters", "repair", "SELECT 1;\n")]); + verify_files(&list, dir.path()).unwrap(); + let mut bad = list.clone(); + bad[0].id = "../outside".into(); + assert!(verify_files(&bad, dir.path()).is_err()); + bad = list.clone(); + bad.push(list[0].clone()); + assert!(verify_files(&bad, dir.path()).is_err()); + std::fs::write(dir.path().join("characters/repair.sql"), b"SELECT 2;\n").unwrap(); + assert!(verify_files(&list, dir.path()).is_err()); + std::fs::remove_file(dir.path().join("characters/repair.sql")).unwrap(); + assert!(verify_files(&list, dir.path()).is_err()); + } + + #[test] + fn preflight_uses_readonly_history_and_rejects_conflicts_without_writes() { + struct ReadOnly<'a>(&'a Mem); + impl Store for ReadOnly<'_> { + fn load(&self) -> Result> { panic!("preflight must use readonly access") } + fn load_readonly(&self) -> Result> { self.0.load() } + fn put(&self, _: &LedgerRow) -> Result<()> { panic!("preflight must not rewrite history") } + fn run_file(&self, _: &str, _: &Path) -> Result<()> { panic!("preflight must not run SQL") } + } + let (_d, list) = setup(&[("characters", "repair", "SELECT 1;\n")]); + for state in [Status::Applied, Status::Running, Status::Failed] { + let store = Mem::default(); + store.rows.borrow_mut().push(LedgerRow { db: "characters".into(), id: "repair".into(), sha256: "a".repeat(64), status: state, error: None, baseline: false }); + assert!(preflight(&ReadOnly(&store), &list).is_err()); + assert_eq!(store.rows.borrow()[0].sha256, "a".repeat(64)); + assert_eq!(store.rows.borrow()[0].status, state); + } + } + + #[test] + fn published_compatibility_preserves_history_and_never_replays_sql() { + let (d, mut list) = setup(&[("characters", "repair", "SELECT 1;\n")]); + let store = Mem::default(); + let old = fsx::sha256_bytes(b"SELECT 1;\r\n"); + store.rows.borrow_mut().push(LedgerRow { db: "characters".into(), id: "repair".into(), sha256: old.clone(), status: Status::Applied, error: None, baseline: false }); + assert!(apply_pending(&store, &list, d.path(), &no_snapshot).is_err()); + list[0].compatible_sha256.push(old.clone()); + assert_eq!(status(&store, &list).unwrap()[0].status, Status::Applied); + assert!(apply_pending(&store, &list, d.path(), &no_snapshot).unwrap().applied.is_empty()); + assert!(store.ran.borrow().is_empty()); + assert_eq!(store.rows.borrow()[0].sha256, old); + store.rows.borrow_mut()[0].status = Status::Running; + assert!(apply_pending(&store, &list, d.path(), &no_snapshot).is_err()); + store.rows.borrow_mut()[0].status = Status::Failed; + assert!(apply_pending(&store, &list, d.path(), &no_snapshot).is_err()); + assert!(store.ran.borrow().is_empty()); + } + #[test] fn applies_in_order_once_and_records_the_ledger() { let (d, list) = setup(&[("world", "2026_09_01_00_a", "CREATE TABLE t (id INT);"), ("world", "2026_09_02_00_b", "ALTER TABLE t ADD c INT;")]); diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index 20585f3..9564696 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -87,6 +87,7 @@ fn truthy(v: &str) -> bool { pub struct ModuleView { pub id: String, pub name: String, + pub version: Option, pub description: BTreeMap, pub repo: String, /// The module's configuration is present on this server (otherwise it is not part of this server build). @@ -137,7 +138,11 @@ fn list_in(cat: &[Entry], root: &Path) -> Vec { !e.switchable || read(&conf).or_else(|| read(&dist)).and_then(|c| c.get(&e.enable_key).map(truthy)).unwrap_or(e.default_on) }; let blocked = wildcard && matches!(e.id.as_str(), "companions" | "playerbots"); + let version = if e.id == "playerbots" { crate::squid::release(root).map(|release| { + [release.tag, release.commit.map(|commit| commit.chars().take(8).collect())].into_iter().flatten().collect::>().join(" · ") + }) } else { None }; ModuleView { + version, compatibility: if blocked { "unsupported" } else if wildcard && e.id != "client-compat" { "experimental" } else { "compatible" }.into(), id: e.id, name: e.name, @@ -243,13 +248,14 @@ fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) - Ok(()) } -#[derive(Debug, Serialize, PartialEq, Eq)] +#[derive(Debug, Serialize, PartialEq)] pub struct Setting { pub key: String, pub value: String, pub default: Option, /// The documentation the module ships above the setting. pub doc: String, + pub field: Option, } /// Every active setting of the module with its current value, its documented default and its description. @@ -263,6 +269,7 @@ fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result> { let conf = dir(root).join(&name); let dist = read(&dir(root).join(format!("{name}.dist"))); let active = read(&conf).or_else(|| dist.clone()).ok_or_else(|| Error::Invalid("This module is not part of this server.".into()))?; + let fields = if id == "playerbots" { crate::squid::fields(root)? } else { Default::default() }; let mut effective = active.clone(); if let Some(defaults) = &dist { for (key, value) in defaults.entries() { @@ -275,8 +282,9 @@ fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result> { .entries() .map(|(k, v)| Setting { key: k.to_string(), - value: v.to_string(), - default: dist.as_ref().and_then(|d| d.get(k)).map(str::to_string), + value: fields.get(k).and_then(|field| field.default_if_missing.as_ref()).filter(|_| active.get(k).is_none()).and_then(crate::squid::scalar).unwrap_or_else(|| v.to_string()), + default: fields.get(k).and_then(|field| crate::squid::scalar(&field.default)).or_else(|| dist.as_ref().and_then(|d| d.get(k)).map(str::to_string)), + field: fields.get(k).cloned(), doc: dist.as_ref().map(|d| d.doc_for(k)).filter(|d| !d.is_empty()).unwrap_or_else(|| active.doc_for(k)).join(" "), }) .collect()) @@ -293,9 +301,11 @@ fn save_settings_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, changes: let conf = active_file(root, &e)?; let mut file = ConfFile::parse_bytes(&fs::read(&conf)?)?; let dist = read(&dir(root).join(format!("{}.dist", conf_name(root, &e)))); + let fields = if id == "playerbots" { crate::squid::fields(root)? } else { Default::default() }; let mut changed = Vec::new(); for (key, value) in changes { let value = value.trim(); + if let Some(field) = fields.get(key) { crate::squid::validate(field, value)?; } if e.switchable && key == &e.enable_key { return Err(Error::Invalid("Use the module switch to turn this module on or off.".into())); } @@ -374,6 +384,27 @@ mod tests { (root, meta) } + #[test] + fn squid_missing_keys_use_code_defaults_and_saves_enforce_json_ranges() { + let d = tempfile::tempdir().unwrap(); + let root = d.path(); + fs::create_dir_all(dir(root)).unwrap(); + fs::write(dir(root).join("playerbots.conf"), "AiPlayerbot.Enabled = 0\n").unwrap(); + fs::write(dir(root).join("playerbots.conf.dist"), "AiPlayerbot.Enabled = 0\nAiPlayerbot.MinRandomBots = 500\n").unwrap(); + fs::write(dir(root).join("playerbots.conf.settings.json"), r#"{"format":1,"groups":{"population":"Population"},"settings":[{"key":"AiPlayerbot.MinRandomBots","type":"int","group":"population","title":"Minimum bots","description":"Lower bound","default":500,"default_if_missing":50,"min":0,"max":5000}]}"#).unwrap(); + let cat = catalog(); + let options = settings_in(&cat, root, "playerbots").unwrap(); + let option = options.iter().find(|setting| setting.key == "AiPlayerbot.MinRandomBots").unwrap(); + assert_eq!(option.value, "50"); + assert_eq!(option.default.as_deref(), Some("500")); + assert_eq!(option.field.as_ref().unwrap().group_title.as_deref(), Some("Population")); + let before = fs::read(dir(root).join("playerbots.conf")).unwrap(); + assert!(save_settings_in(&cat, root, root, "playerbots", &BTreeMap::from([("AiPlayerbot.MinRandomBots".into(), "5001".into())])).is_err()); + assert_eq!(fs::read(dir(root).join("playerbots.conf")).unwrap(), before); + save_settings_in(&cat, root, root, "playerbots", &BTreeMap::from([("AiPlayerbot.MinRandomBots".into(), "42".into())])).unwrap(); + assert_eq!(settings_in(&cat, root, "playerbots").unwrap().iter().find(|setting| setting.key == "AiPlayerbot.MinRandomBots").unwrap().value, "42"); + } + #[test] fn bot_systems_are_mutually_exclusive_in_both_directions_and_manual_configs() { let d = tempfile::tempdir().unwrap(); @@ -456,7 +487,7 @@ mod tests { let (root, meta) = server(d.path()); let s = settings_in(&test_catalog(), &root, "war-games").unwrap(); assert_eq!(s.len(), 2); - assert_eq!(s[1], Setting { key: "WarGames.ChallengeSeconds".into(), value: "60".into(), default: Some("60".into()), doc: "How long a challenge stays open.".into() }); + assert_eq!(s[1], Setting { key: "WarGames.ChallengeSeconds".into(), value: "60".into(), default: Some("60".into()), doc: "How long a challenge stays open.".into(), field: None }); assert_eq!(s[0].doc, "Turns War Games on. Second line."); let ok = BTreeMap::from([("WarGames.ChallengeSeconds".to_string(), "90".to_string())]); diff --git a/crates/coa-core/src/net.rs b/crates/coa-core/src/net.rs index b1fb7c5..65df3ef 100644 --- a/crates/coa-core/src/net.rs +++ b/crates/coa-core/src/net.rs @@ -19,6 +19,51 @@ pub fn lan_ip() -> Option { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LanAddress { + pub interface: String, + pub address: String, + pub is_default: bool, +} + +/// Suitable unicast IPv4 addresses; shared carrier/Tailscale space belongs to Private mode. +pub fn is_lan_address(ip: Ipv4Addr) -> bool { + !ip.is_unspecified() && !ip.is_loopback() && !ip.is_link_local() + && !ip.is_multicast() && !ip.is_broadcast() && ip.octets()[0] != 0 + && ip.octets()[0] < 240 && !is_cgnat_range(ip) +} + +pub fn validate_lan_address(address: &str) -> Result { + address.parse::().ok().filter(|ip| is_lan_address(*ip)) + .ok_or_else(|| Error::Invalid("Invalid IPv4 address for local network.".into())) +} + +/// An absent/disconnected manual address is intentional: never fall back to another adapter. +pub fn resolve_lan_host(manual: Option<&str>, automatic: Option) -> Result { + match manual { + Some(address) => validate_lan_address(address).map(|ip| ip.to_string()), + None => automatic.filter(|ip| is_lan_address(*ip)).map(|ip| ip.to_string()) + .ok_or_else(|| Error::Invalid("This computer has no usable local network address.".into())), + } +} + +fn lan_candidates(rows: impl IntoIterator, automatic: Option) -> Vec { + let mut rows: Vec<_> = rows.into_iter().filter(|(_, ip)| is_lan_address(*ip)).collect(); + rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); + let mut seen = std::collections::HashSet::new(); + rows.into_iter().filter(|(_, ip)| seen.insert(*ip)).map(|(interface, ip)| LanAddress { + interface, address: ip.to_string(), is_default: Some(ip) == automatic, + }).collect() +} + +/// Native OS enumeration (GetAdaptersAddresses on Windows, getifaddrs on Unix). +pub fn lan_addresses(automatic: Option) -> Result> { + let interfaces = if_addrs::get_if_addrs()?; + Ok(lan_candidates(interfaces.into_iter().filter(|i| i.is_oper_up()).filter_map(|i| { + match i.ip() { IpAddr::V4(ip) => Some((i.name, ip)), _ => None } + }), automatic)) +} + pub fn is_private(ip: Ipv4Addr) -> bool { ip.is_private() || ip.is_loopback() || ip.is_link_local() } @@ -132,6 +177,42 @@ mod tests { s.parse().unwrap() } + #[test] + fn lan_validation_and_resolution_never_replace_manual_addresses() { + for good in ["192.168.1.20", "10.0.0.5", "172.16.2.4"] { + assert!(validate_lan_address(good).is_ok()); + } + for bad in ["abc", "999.1.1.1", "0.0.0.0", "0.1.2.3", "127.0.0.1", "127.9.1.2", "169.254.10.20", "100.101.20.5", "224.0.0.1", "255.255.255.255", "240.0.0.1", "192.168.1.1'; DROP TABLE realmlist"] { + assert!(validate_lan_address(bad).is_err(), "{bad}"); + } + let automatic = Some(ip("192.168.0.169")); + assert_eq!(resolve_lan_host(None, automatic).unwrap(), "192.168.0.169"); + assert_eq!(resolve_lan_host(Some("192.168.1.50"), automatic).unwrap(), "192.168.1.50"); + assert_eq!(resolve_lan_host(Some("192.168.1.50"), None).unwrap(), "192.168.1.50"); + assert!(resolve_lan_host(Some("invalid"), automatic).is_err()); + assert!(resolve_lan_host(None, None).is_err()); + assert!(resolve_lan_host(None, Some(ip("169.254.1.2"))).is_err()); + assert!(resolve_lan_host(None, Some(ip("100.100.1.2"))).is_err()); + } + + #[test] + fn candidates_are_filtered_deduplicated_deterministic_and_default_marked() { + let rows = vec![ + ("Wi-Fi", "192.168.0.25"), ("Ethernet 2", "192.168.1.50"), + ("Ethernet", "192.168.0.169"), ("Duplicate", "192.168.1.50"), + ("Loopback", "127.0.0.2"), ("APIPA", "169.254.1.2"), + ("Tailscale", "100.101.20.5"), ("Empty", "0.0.0.0"), + ("Hyper-V Virtual", "10.0.0.5"), + ]; + let make = |rows: Vec<(&str, &str)>| lan_candidates(rows.into_iter().map(|(name, address)| (name.into(), ip(address))), Some(ip("192.168.0.169"))); + let result = make(rows.clone()); + assert_eq!(result.len(), 4); + assert_eq!(result.iter().filter(|a| a.is_default).count(), 1); + assert!(result.iter().any(|a| a.interface == "Hyper-V Virtual")); + assert!(result.iter().any(|a| a.is_default && a.address == "192.168.0.169")); + assert_eq!(result, make(rows.into_iter().rev().collect())); + } + #[test] fn address_classes() { assert!(is_private(ip("192.168.1.5")) && is_private(ip("10.0.0.2")) && is_private(ip("172.16.4.4")) && is_private(ip("127.0.0.1"))); diff --git a/crates/coa-core/src/package.rs b/crates/coa-core/src/package.rs index e03b745..26e54e8 100644 --- a/crates/coa-core/src/package.rs +++ b/crates/coa-core/src/package.rs @@ -21,6 +21,12 @@ pub const DEFAULT_PART_SIZE: u64 = 1_900_000_000; /// Archive path of the one-time database credentials shipped with a base package. pub const BOOTSTRAP_CREDENTIALS: &str = "Settings/database.bootstrap.json"; +/// Folder of a Linux (Docker) package that holds the starting databases: `.sql.zst` for `auth`, `characters` +/// and `world`, in the format of the Manager's own backups. They hold the state in which every migration of the package is +/// already applied (a world database cannot be rebuilt from the repository's SQL files: some migrations are guarded and +/// only apply to the maintainers' own database). +pub const BASELINE_DIR: &str = "Database/baseline"; + /// Files that never belong in a shipped package: per-install state, logs, secrets, old binaries. pub fn excluded(rel: &str) -> bool { if rel.replace('\\', "/").to_lowercase().starts_with(".realms/") { return true; } @@ -41,7 +47,7 @@ pub fn excluded(rel: &str) -> bool { || l == "mysql/my.ini" || l == "mysql/mysql.pid" || l == "mysql/data.7z" - || l.starts_with("core/configs/") && !l.ends_with(".dist") // generated at first start from Settings templates + || l.starts_with("core/configs/") && !l.ends_with(".dist") && l != "core/configs/modules/playerbots.conf.settings.json" // generated at first start from Settings templates || name.ends_with(".pdb") || name.ends_with(".bak") || name.ends_with(".log") @@ -56,7 +62,7 @@ fn policy_for(rel: &str) -> (Owner, ReplacePolicy) { let l = rel.to_lowercase(); if l.starts_with("_migrations/") { (Owner::Core, ReplacePolicy::NeverTouch) // staged for the migration runner, never copied into the server - } else if l.ends_with(".dist") { + } else if l.ends_with(".dist") || l == "core/configs/modules/playerbots.conf.settings.json" { (Owner::Core, ReplacePolicy::Replace) } else if l.starts_with("mysql/data/") { (Owner::User, ReplacePolicy::NeverTouch) @@ -370,6 +376,20 @@ mod tests { v } + #[test] + fn upstream_settings_metadata_ships_and_updates_but_active_configs_stay_private() { + let d = tempfile::tempdir().unwrap(); + let src = d.path().join("src"); + fs::create_dir_all(src.join("Core/configs/modules")).unwrap(); + fs::write(src.join("Core/configs/modules/playerbots.conf.settings.json"), "{}\n").unwrap(); + fs::write(src.join("Core/configs/modules/playerbots.conf"), "PlayerbotsDatabaseInfo = private\n").unwrap(); + let out = d.path().join("package"); + let manifest = build(&src, &out, &BuildOptions { kind: Kind::Update, version: "1.0.0".into(), core_commit: None, built_at: "test".into(), part_size: 1 << 20, bots_commit: None, migrations: vec![] }, &|_| {}).unwrap(); + assert_eq!(manifest.files.len(), 1); + assert_eq!(manifest.files[0].path, "Core/configs/modules/playerbots.conf.settings.json"); + assert_eq!(manifest.files[0].policy, ReplacePolicy::Replace); + } + fn source() -> (tempfile::TempDir, PathBuf) { let d = tempfile::tempdir().unwrap(); let src = d.path().join("src"); diff --git a/crates/coa-core/src/process.rs b/crates/coa-core/src/process.rs index 6f157b0..646eead 100644 --- a/crates/coa-core/src/process.rs +++ b/crates/coa-core/src/process.rs @@ -250,6 +250,19 @@ pub fn is_alive(record: &ProcessIdentity) -> bool { identity(record.pid).map(|cur| cur.same_as(record)).unwrap_or(false) } +/// Every running process whose image file is named one of `names` (compared without case), wherever it lives. Only for +/// diagnostics: a process found this way is not trusted to belong to any installation. +pub fn find_by_file_names(names: &[&str]) -> Vec { + sys::all_pids() + .into_iter() + .filter_map(identity) + .filter(|p| { + let file = p.exe.rsplit(['\\', '/']).next().unwrap_or(""); + names.iter().any(|n| file.eq_ignore_ascii_case(n)) + }) + .collect() +} + /// 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('/', "\\"); diff --git a/crates/coa-core/src/realms.rs b/crates/coa-core/src/realms.rs index baa8cc2..f99fb84 100644 --- a/crates/coa-core/src/realms.rs +++ b/crates/coa-core/src/realms.rs @@ -287,25 +287,35 @@ pub fn prepare_launcher(root: &Path) -> Result<()> { Ok(()) } +const LAUNCHER_REALM_REPLACEMENTS: &[(&str, &str)] = &[ + ("(\"WorldDatabaseInfo\", \"acore_world\")", "(\"WorldDatabaseInfo\", \"acore_world_wildcard\" if coa_realm() == 'wildcard' else \"acore_world\")"), + ("(\"CharacterDatabaseInfo\", \"acore_characters\")", "(\"CharacterDatabaseInfo\", \"acore_characters_wildcard\" if coa_realm() == 'wildcard' else \"acore_characters\")"), + ("SET name='AzerothCore',address=", "SET name='{coa_realm_name()}',address="), + ("WHERE id=1;", "WHERE id={coa_realm_id()};"), + ("mysql(\"UPDATE acore_auth.realmlist SET flag=0", "mysql(f\"UPDATE acore_auth.realmlist SET flag=0"), +]; + +const LAUNCHER_REALM_INSERT: &str = "# coa-manager-realm-profiles-v1\ndef coa_realm():\n path = ROOT / 'Settings/realm-profile.json'\n return json.loads(path.read_text(encoding='utf-8')).get('active', 'coa') if path.exists() else 'coa'\n\ndef coa_realm_id():\n return 2 if coa_realm() == 'wildcard' else 1\n\ndef coa_realm_name():\n return 'Wildcard' if coa_realm() == 'wildcard' else 'Conquest of Azeroth'\n\n"; + pub(crate) fn patch_launcher(source: &str) -> Result { if source.contains("# coa-manager-realm-profiles-v1") { return Ok(source.into()); } - let replacements = [ - ("(\"WorldDatabaseInfo\", \"acore_world\")", "(\"WorldDatabaseInfo\", \"acore_world_wildcard\" if coa_realm() == 'wildcard' else \"acore_world\")"), - ("(\"CharacterDatabaseInfo\", \"acore_characters\")", "(\"CharacterDatabaseInfo\", \"acore_characters_wildcard\" if coa_realm() == 'wildcard' else \"acore_characters\")"), - ("SET name='AzerothCore',address=", "SET name='{coa_realm_name()}',address="), - ("WHERE id=1;", "WHERE id={coa_realm_id()};"), - ("mysql(\"UPDATE acore_auth.realmlist SET flag=0", "mysql(f\"UPDATE acore_auth.realmlist SET flag=0"), - ]; let mut out = source.to_string(); - for (from, to) in replacements { + for &(from, to) in LAUNCHER_REALM_REPLACEMENTS { if !out.contains(from) { return Err(Error::Invalid("This launcher does not support realm profiles. Update the server package first.".into())); } out = out.replace(from, to); } - let insert = "# coa-manager-realm-profiles-v1\ndef coa_realm():\n path = ROOT / 'Settings/realm-profile.json'\n return json.loads(path.read_text(encoding='utf-8')).get('active', 'coa') if path.exists() else 'coa'\n\ndef coa_realm_id():\n return 2 if coa_realm() == 'wildcard' else 1\n\ndef coa_realm_name():\n return 'Wildcard' if coa_realm() == 'wildcard' else 'Conquest of Azeroth'\n\n"; // Insert before the entry point so ROOT, json and functions are all defined when called. let marker = "if __name__ == \"__main__\":"; if !out.contains(marker) { return Err(Error::Invalid("Unrecognised server launcher entry point.".into())); } - Ok(out.replace(marker, &format!("{insert}{marker}"))) + Ok(out.replace(marker, &format!("{LAUNCHER_REALM_INSERT}{marker}"))) +} + +/// Reverse only our exact realm transformation; callers must verify the recovered original hash. +pub(crate) fn unpatch_launcher(source: &str) -> Option { + if !source.contains(LAUNCHER_REALM_INSERT) { return None; } + let mut original = source.replacen(LAUNCHER_REALM_INSERT, "", 1); + for &(from, to) in LAUNCHER_REALM_REPLACEMENTS { original = original.replace(to, from); } + patch_launcher(&original).ok().filter(|patched| patched == source).map(|_| original) } pub fn before_start(root: &Path) -> Result<()> { diff --git a/crates/coa-core/src/registry.rs b/crates/coa-core/src/registry.rs index e3e79a2..e59dff4 100644 --- a/crates/coa-core/src/registry.rs +++ b/crates/coa-core/src/registry.rs @@ -14,6 +14,7 @@ use crate::fsx; pub const META_SCHEMA: u32 = 1; pub const LAYOUT_REPACK_V1: &str = "repack-v1"; +pub const LAYOUT_DOCKER_V1: &str = "docker-v1"; const META_SUBDIRS: [&str; 6] = ["manifests", "backups", "migrations", "logs", "cache", "staging"]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/coa-core/src/release.rs b/crates/coa-core/src/release.rs index 427b03c..ba8b5f1 100644 --- a/crates/coa-core/src/release.rs +++ b/crates/coa-core/src/release.rs @@ -87,6 +87,21 @@ pub fn collect_core_sql(core: &Path) -> Result> { Ok(out) } +/// Dump the three game databases of `server` (a repack or a Docker server, started if needed) into `out` as the starting +/// databases of a Linux package: `.sql.zst`. The databases must be in the state in which every migration of the +/// package is applied. Returns (kind, compressed bytes) per database. +pub fn export_baseline(server: &Path, out: &Path) -> Result> { + fs::create_dir_all(out)?; + crate::backup::with_database(server, |db| { + let mut done = Vec::new(); + for (kind, schema) in crate::db::SCHEMAS { + let (bytes, _) = db.dump_to(schema, &out.join(format!("{kind}.sql.zst")))?; + done.push((kind.to_string(), bytes)); + } + Ok(done) + }) +} + /// SQL a bots module checkout ships for the characters database (its `dist/sql`). pub fn collect_bots_sql(bots: &Path) -> Result> { let mut out = Vec::new(); @@ -98,7 +113,7 @@ pub fn collect_bots_sql(bots: &Path) -> Result> { } fn to_migrations(files: &[SqlFile]) -> Vec { - files.iter().map(|f| Migration { id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect() + files.iter().map(|f| Migration { compatible_sha256: vec![], id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect() } pub struct UpdateParams<'a> { diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index 3b9c429..d4c161e 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -47,7 +47,7 @@ pub fn capture_release(fixture: &Path, tree: &Path, sql: &[SqlFile]) -> Result<( return Err(Error::Invalid("Refusing to apply release SQL to a non-fixture server.".into())); } let staging = tempfile::tempdir()?; - let migrations: Vec<_> = sql.iter().map(|f| Migration { db: f.db.clone(), id: f.id.clone(), sha256: f.sha256.clone(), destructive: false }).collect(); + let migrations: Vec<_> = sql.iter().map(|f| Migration { compatible_sha256: vec![], db: f.db.clone(), id: f.id.clone(), sha256: f.sha256.clone(), destructive: false }).collect(); for file in sql { let path = fsx::safe_join(staging.path(), &format!("{}/{}.sql", file.db, file.id))?; fsx::atomic_write(&path, &std::fs::read(&file.abs)?)?; diff --git a/crates/coa-core/src/report.rs b/crates/coa-core/src/report.rs new file mode 100644 index 0000000..0509052 --- /dev/null +++ b/crates/coa-core/src/report.rs @@ -0,0 +1,116 @@ +//! Where a problem report goes. A report about the bots goes to the repository of the bot system in question, anything +//! else to the Manager's own. The repositories come from the module catalog, so there is one list to keep right. + +use std::path::Path; + +use serde::Serialize; + +pub const MANAGER_REPO: &str = "Corfirean/coa-server-manager"; + +/// One place a report can be filed. `id` is what the page sends back; `repo` is `owner/name` on GitHub. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct Target { + pub id: &'static str, + pub repo: String, + /// The report form asks for the Manager's labels only where we own the repository. + pub ours: bool, +} + +/// `https://github.com/owner/name/tree/main/x` -> `owner/name`. +fn repo_of(url: &str) -> Option { + let rest = url.strip_prefix("https://github.com/")?; + let mut parts = rest.split('/'); + let (owner, name) = (parts.next()?, parts.next()?); + let ok = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)); + (ok(owner) && ok(name)).then(|| format!("{owner}/{name}")) +} + +fn catalog_repo(id: &str) -> Option { + crate::modules::catalog().into_iter().find(|e| e.id == id).and_then(|e| repo_of(&e.repo)) +} + +/// The three places, in the order the page lists them. A bot system whose repository is unknown falls back to the Manager's. +pub fn targets() -> Vec { + let mut v = vec![Target { id: "manager", repo: MANAGER_REPO.into(), ours: true }]; + for (id, module) in [("companions", "companions"), ("squid", "playerbots")] { + if let Some(repo) = catalog_repo(module) { + v.push(Target { id, repo: repo.clone(), ours: repo.starts_with("Corfirean/") }); + } + } + v +} + +pub fn target(id: &str) -> Option { + targets().into_iter().find(|t| t.id == id) +} + +/// True for the "new issue" page of one of the targets: exactly that page, optionally with a query. +pub fn is_new_issue_url(url: &str) -> bool { + targets().iter().any(|t| { + let base = format!("https://github.com/{}/issues/new", t.repo); + url == base || url.starts_with(&format!("{base}?")) + }) +} + +/// What the report form fills in about the bot systems of this server. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct Bots { + /// The target the form should start with: the bot system that is switched on, else the Manager. + pub suggested: &'static str, + /// The release of SQUID Playerbots, when it is the one switched on. + pub squid_version: Option, +} + +pub fn bots(root: &Path) -> Bots { + let list = crate::modules::list(root); + let on = |id: &str| list.iter().find(|m| m.id == id).filter(|m| m.installed && m.enabled); + let squid = on("playerbots"); + Bots { + suggested: if squid.is_some() { "squid" } else if on("companions").is_some() { "companions" } else { "manager" }, + squid_version: squid.and_then(|m| m.version.clone()).filter(|v| !v.is_empty()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_three_targets_come_from_the_catalog() { + let t = targets(); + let repo = |id: &str| t.iter().find(|x| x.id == id).map(|x| x.repo.clone()); + assert_eq!(repo("manager").as_deref(), Some("Corfirean/coa-server-manager")); + assert_eq!(repo("companions").as_deref(), Some("Corfirean/mod-coa-playerbots")); + assert_eq!(repo("squid").as_deref(), Some("Zyth45/mod-playerbots")); + assert!(t.iter().find(|x| x.id == "squid").is_some_and(|x| !x.ours)); + } + + #[test] + fn repository_names_are_read_from_module_links() { + assert_eq!(repo_of("https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/x").as_deref(), Some("Corfirean/azerothcore-wotlk-coa")); + assert_eq!(repo_of("https://github.com/Zyth45/mod-playerbots").as_deref(), Some("Zyth45/mod-playerbots")); + assert_eq!(repo_of("https://example.com/a/b"), None); + assert_eq!(repo_of("https://github.com/a"), None); + } + + #[test] + fn only_the_new_issue_pages_of_the_targets_are_allowed() { + assert!(is_new_issue_url("https://github.com/Zyth45/mod-playerbots/issues/new")); + assert!(is_new_issue_url("https://github.com/Corfirean/mod-coa-playerbots/issues/new?title=a&body=b")); + assert!(!is_new_issue_url("https://github.com/Zyth45/mod-playerbots/issues/newer")); + assert!(!is_new_issue_url("https://github.com/Zyth45/other/issues/new")); + assert!(!is_new_issue_url("https://evil.example/Corfirean/coa-server-manager/issues/new")); + } + + #[test] + fn the_suggested_target_follows_the_bot_system_that_is_on() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + crate::layout::testkit::fake_repack(&root); + let conf = root.join("Core/configs/modules"); + std::fs::write(conf.join("mod_coa_playerbots.conf"), "CoaBots.Enable = 1\n").unwrap(); + assert_eq!(bots(&root).suggested, "companions"); + std::fs::write(conf.join("mod_coa_playerbots.conf"), "CoaBots.Enable = 0\n").unwrap(); + assert_eq!(bots(&root).suggested, "manager"); + } +} diff --git a/crates/coa-core/src/squid.rs b/crates/coa-core/src/squid.rs new file mode 100644 index 0000000..5ae6897 --- /dev/null +++ b/crates/coa-core/src/squid.rs @@ -0,0 +1,155 @@ +use std::path::Path; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct Release { + pub tag: Option, + pub commit: Option, +} + +pub fn imported_repack(root: &Path) -> bool { + root.join("CoA-Bots/release.json").is_file() +} + +pub fn release(root: &Path) -> Option { + for name in ["Extras/SquidPlayerbots/release.json", "Core/configs/modules/playerbots.conf.settings.json", "CoA-Bots/release.json"] { + let Ok(bytes) = std::fs::read(root.join(name)) else { continue; }; + if bytes.len() > 1024 * 1024 { continue; } + let Ok(json) = serde_json::from_slice::(&bytes) else { continue; }; + let safe = |key: &str| json.get(key).and_then(|value| value.as_str()) + .filter(|value| !value.is_empty() && value.len() <= 80 && value.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))).map(str::to_owned); + let tag = safe("tag").or_else(|| safe("version")); + let revision = if name == "CoA-Bots/release.json" { + json.get("botsModuleRevision").and_then(|value| value.as_str()).and_then(|value| value.split_whitespace().next()).map(str::to_owned).or_else(|| safe("commit")) + } else if name.ends_with(".settings.json") { None } else { safe("commit") }; + let commit = revision.filter(|value| value.len() >= 7 && value.len() <= 40 && value.chars().all(|c| c.is_ascii_hexdigit())); + if tag.is_some() || commit.is_some() { return Some(Release { tag, commit }); } + } + None +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct Field { + pub key: String, + #[serde(rename = "type")] + pub kind: String, + pub title: String, + pub description: String, + #[serde(default)] + pub group: String, + #[serde(default)] + pub group_title: Option, + pub default: serde_json::Value, + #[serde(default)] + pub default_if_missing: Option, + pub min: Option, + pub max: Option, +} + +#[derive(Deserialize)] +struct Settings { + format: u32, + settings: Vec, + #[serde(default)] + groups: std::collections::BTreeMap, +} + +pub fn fields(root: &Path) -> crate::Result> { + let path = root.join("Core/configs/modules/playerbots.conf.settings.json"); + if !path.is_file() { return Ok(Default::default()); } + let Ok(bytes) = std::fs::read(path) else { return Ok(Default::default()); }; + if bytes.len() > 1024 * 1024 { return Ok(Default::default()); } + let Ok(settings) = serde_json::from_slice::(&bytes) else { return Ok(Default::default()); }; + if settings.format != 1 { return Ok(Default::default()); } + let mut fields = std::collections::BTreeMap::new(); + for value in settings.settings { + let Ok(mut field) = serde_json::from_value::(value) else { continue; }; + field.group_title = settings.groups.get(&field.group).cloned(); + if !matches!(field.kind.as_str(), "bool" | "int" | "float" | "string") + || field.key.len() > 200 || field.key.is_empty() + || !field.key.chars().all(|c| c.is_ascii_alphanumeric() || "._".contains(c)) + || field.min.zip(field.max).is_some_and(|(min,max)| min > max) + || !default_type(&field.kind, &field.default) + || field.default_if_missing.as_ref().is_some_and(|value| !default_type(&field.kind, value)) + || fields.contains_key(&field.key) { continue; } + fields.insert(field.key.clone(), field); + } + Ok(fields) +} + +fn default_type(kind: &str, value: &serde_json::Value) -> bool { + match kind { + "bool" => value.is_boolean(), + "int" => value.is_i64() || value.is_u64(), + "float" => value.is_number(), + "string" => value.is_string(), + _ => false, + } +} + +pub fn scalar(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Bool(value) => Some(if *value { "1" } else { "0" }.into()), + serde_json::Value::String(value) => Some(value.clone()), + serde_json::Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +pub fn validate(field: &Field, value: &str) -> crate::Result<()> { + let valid = match field.kind.as_str() { + "bool" => matches!(value, "0" | "1"), + "int" | "float" => value.parse::().is_ok_and(|number| number.is_finite() + && (field.kind != "int" || number.fract() == 0.0) + && field.min.is_none_or(|min| number >= min) && field.max.is_none_or(|max| number <= max)), + "string" => true, + _ => false, + }; + if valid { Ok(()) } else { Err(crate::Error::Invalid(format!("{} must match its documented SquidBots type and range.", field.key))) } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn upstream_settings_preserve_types_ranges_and_distinct_defaults() { + let root = tempfile::tempdir().unwrap(); + crate::fsx::atomic_write(&root.path().join("Core/configs/modules/playerbots.conf.settings.json"), br#"{"format":1,"settings":[{"key":"AiPlayerbot.MinRandomBots","type":"int","group":"population","title":"Minimum bots","description":"Lower bound","default":500,"default_if_missing":50,"min":0,"max":5000}]}"#).unwrap(); + let fields = fields(root.path()).unwrap(); + let field = &fields["AiPlayerbot.MinRandomBots"]; + assert_eq!(scalar(&field.default).as_deref(), Some("500")); + assert_eq!(scalar(field.default_if_missing.as_ref().unwrap()).as_deref(), Some("50")); + assert!(validate(field, "5000").is_ok()); + for value in ["5001", "-1", "0.5", "NaN"] { assert!(validate(field, value).is_err()); } + } + #[test] + fn unsupported_metadata_falls_back_and_bad_entries_do_not_hide_valid_fields() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("Core/configs/modules/playerbots.conf.settings.json"); + for data in [r#"{"format":99,"settings":[]}"#, "invalid json", r#"{"format":1,"settings":{}}"#] { + crate::fsx::atomic_write(&path, data.as_bytes()).unwrap(); + assert!(fields(root.path()).unwrap().is_empty()); + } + crate::fsx::atomic_write(&path, br#"{"format":1,"settings":[{"key":"Broken","type":"enum","title":"Broken","description":"Unknown type","default":1},{"key":"Malformed"},{"key":"WrongDefault","type":"int","title":"Wrong","description":"Bad default","default":"oops"},{"key":"Good","type":"bool","title":"Good","description":"Good field","default":true}]}"#).unwrap(); + let parsed = fields(root.path()).unwrap(); + assert_eq!(parsed.len(), 1); + assert!(parsed.contains_key("Good")); + } + + #[test] + fn imported_revision_uses_its_first_word_and_empty_tags_are_hidden() { + let root = tempfile::tempdir().unwrap(); + crate::fsx::atomic_write(&root.path().join("CoA-Bots/release.json"), br#"{"tag":"","botsModuleRevision":"48c4786a 2026-10-01 (coa branch)","commit":"abcdef01"}"#).unwrap(); + assert_eq!(release(root.path()).unwrap(), Release { tag: None, commit: Some("48c4786a".into()) }); + crate::fsx::atomic_write(&root.path().join("Core/configs/modules/playerbots.conf.settings.json"), br#"{"tag":"v1.8.1","commit":"11111111"}"#).unwrap(); + assert_eq!(release(root.path()).unwrap(), Release { tag: Some("v1.8.1".into()), commit: None }); + } + + #[test] + fn diagnostics_only_expose_release_identifiers() { + let root = tempfile::tempdir().unwrap(); + crate::fsx::atomic_write(&root.path().join("CoA-Bots/release.json"), br#"{"tag":"v1.8","commit":"48c4786a","password":"secret"}"#).unwrap(); + assert!(imported_repack(root.path())); + assert_eq!(release(root.path()).unwrap(), Release { tag: Some("v1.8".into()), commit: Some("48c4786a".into()) }); + } +} diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 35412ff..a048010 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -77,6 +77,8 @@ pub struct Txn { pub databases_started: bool, pub ops: Vec, pub message: Option, + #[serde(default)] + pub recovery_hashes: BTreeMap, } fn legacy_databases_started() -> bool { true } @@ -84,6 +86,8 @@ fn legacy_databases_started() -> bool { true } /// Everything the transaction needs from the outside world; tests provide a fake. pub trait Env { fn ensure_stopped(&self) -> Result<()>; + fn preflight(&self, _manifest: &Manifest) -> Result<()> { Ok(()) } + fn verify_snapshot(&self, _id: &str) -> Result<()> { Ok(()) } /// Create a recovery point for the databases and configuration; returns its id. fn snapshot(&self) -> Result; fn restore_snapshot(&self, id: &str) -> Result<()>; @@ -108,32 +112,72 @@ fn save(meta: &Path, t: &Txn) -> Result<()> { } pub fn load(meta: &Path, id: &str) -> Result { - fsx::read_json(&txn_dir(meta, id)?.join("txn.json")) + read_journal(&txn_dir(meta, id)?.join("txn.json")) +} + +fn read_journal(path: &Path) -> Result { + let txn: Txn = fsx::read_json(path).map_err(|e| Error::Invalid(format!("Update journal {} cannot be read: {e}. Available recovery copies were preserved.", path.display())))?; + if path.parent().and_then(Path::file_name).and_then(|id| id.to_str()) != Some(txn.id.as_str()) { return Err(Error::Invalid("Update journal identity is inconsistent.".into())); } + txn_dir(path.parent().unwrap(), &txn.id)?; + if let Some(id) = &txn.recovery_point { + if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) || id.contains("..") { return Err(Error::Invalid("Update journal contains an invalid recovery point identity.".into())); } + } + let valid_hash = |hash: &str| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()); + let mut seen = std::collections::BTreeSet::new(); + for op in &txn.ops { + fsx::safe_join(path.parent().unwrap(), &op.path)?; + if !seen.insert(op.path.replace('\\', "/").to_ascii_lowercase()) || !valid_hash(&op.new_sha256) + || (op.done && !op.started && !matches!(op.action, Action::Skip | Action::Conflict)) + || (txn.state == State::Prepared && op.started) + || (txn.state == State::Committed && !op.done) { + return Err(Error::Invalid("Update journal contains inconsistent file operations.".into())); + } + } + if txn.recovery_hashes.values().any(|hash| !valid_hash(hash)) { return Err(Error::Invalid("Update journal contains invalid recovery hashes.".into())); } + for key in txn.recovery_hashes.keys().filter(|key| !matches!(key.as_str(), "@install" | "@point")) { fsx::safe_join(path.parent().unwrap(), key)?; } + Ok(txn) +} + +#[derive(Serialize, Deserialize)] +struct CleanupMarker { id: String, state: State } + +fn cleanup_marker(meta: &Path, id: &str) -> Result { + txn_dir(meta, id)?; + Ok(meta.join("update-cleanup").join(format!("{id}.json"))) +} + +fn journals(meta: &Path) -> Result> { + let entries = match fs::read_dir(updates_dir(meta)) { Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), Err(e) => return Err(e.into()) }; + let mut all = Vec::new(); + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_symlink() { return Err(Error::Invalid("Linked update storage cannot be recovered safely.".into())); } + if !entry.file_type()?.is_dir() { continue; } + let dir = fsx::ensure_within(meta, &entry.path())?; + let path = dir.join("txn.json"); + if !path.is_file() { + let id = entry.file_name().to_string_lossy().into_owned(); + let marker = cleanup_marker(meta, &id)?; + if fsx::read_json::(&marker).is_ok_and(|m| m.id == id && matches!(m.state, State::Committed | State::RolledBack)) { continue; } + return Err(Error::Invalid(format!("Update {id} has no journal. Preserve its recovery copies and inspect it before starting or changing the server."))); + } + all.push(read_journal(&path)?); + } + all.sort_by(|a, b| b.id.cmp(&a.id)); + Ok(all) +} + +pub fn pending_checked(meta: &Path) -> Result> { + Ok(journals(meta)?.into_iter().find(|t| !matches!(t.state, State::Committed | State::RolledBack | State::Prepared) && (t.state != State::Failed || t.databases_started || t.ops.iter().any(|o| o.started)))) } /// A transaction that started but never reached a final state (crash, power loss). The UI offers rollback. pub fn unfinished(meta: &Path) -> Option { - let rd = fs::read_dir(updates_dir(meta)).ok()?; - let mut all: Vec = rd.flatten().filter_map(|e| fsx::read_json::(&e.path().join("txn.json")).ok()).collect(); - all.sort_by(|a, b| b.id.cmp(&a.id)); - all.into_iter().find(|t| !matches!(t.state, State::Committed | State::RolledBack | State::Prepared) && (t.state != State::Failed || t.databases_started || t.ops.iter().any(|o| o.started))) + pending_checked(meta).ok().flatten() } pub fn ensure_recovered(meta: &Path) -> Result<()> { - match fs::read_dir(updates_dir(meta)) { - Ok(entries) => { - for entry in entries { - let entry = entry?; - let journal = entry.path().join("txn.json"); - if journal.exists() { - fsx::read_json::(&journal).map_err(|e| Error::Invalid(format!("Update journal {} cannot be read: {e}. Recover it before changing or starting the server.", journal.display())))?; - } - } - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e.into()), - } - if let Some(t) = unfinished(meta) { + if let Some(t) = pending_checked(meta)? { return Err(Error::Invalid(format!("Resolve unfinished update {} before changing or starting the server.", t.id))); } Ok(()) @@ -172,8 +216,23 @@ pub fn plan(root: &Path, meta: &InstallMeta, manifest: &Manifest, resolutions: & if would_add { (Action::MergeConfig, Some("new settings are added; your values are kept".into())) } else { (Action::Skip, Some("no new settings".into())) } } (_, Some(c)) if c.eq_ignore_ascii_case(&f.sha256) => (Action::Skip, Some("already up to date".into())), + (_, Some(_)) if f.path == "Scripts/manage.py" && staged.is_some_and(|tree| { + match (fs::read_to_string(tree.join(&f.path)), fs::read(&target)) { + (Ok(signed), Ok(actual)) => fsx::sha256_bytes(signed.as_bytes()) == f.sha256 && crate::driver::launcher_matches(&signed, &actual), + _ => false, + } + }) => (Action::Skip, Some("already up to date with Manager integration".into())), (ReplacePolicy::Replace | ReplacePolicy::ReplaceIfPristine, Some(c)) => { - if recorded.map(|r| r.eq_ignore_ascii_case(c)).unwrap_or(false) { + let manager_launcher = f.path == "Scripts/manage.py" && recorded.is_some_and(|hash| { + fs::read(&target).is_ok_and(|bytes| crate::driver::launcher_matches_recorded(hash, &bytes)) + }); + if f.path == "Core/worldserver.exe" && meta.kind == crate::registry::InstallKind::Imported && crate::squid::imported_repack(root) { + match resolutions.get(&f.path) { + Some(Resolution::Replace) => (Action::Replace, Some("Replacing this binary stops the original SquidBots launcher/updater from accepting its hash.".into())), + Some(Resolution::Keep) => (Action::Skip, Some("kept the original SquidBots binary".into())), + None => (Action::Conflict, Some("This SquidBots repack verifies its worldserver hash. Replacing it stops Start_All_Bots/coa_update from working. Choose explicitly whether to replace this binary.".into())), + } + } else if recorded.map(|r| r.eq_ignore_ascii_case(c)).unwrap_or(false) || manager_launcher { (Action::Replace, None) } else if f.policy == ReplacePolicy::ReplaceIfPristine { (Action::Skip, Some("modified outside CoA Server Manager; kept".into())) @@ -198,24 +257,72 @@ pub struct Preview { pub items: Vec, pub conflicts: Vec, pub migrations: usize, + pub pending_migrations: usize, pub download_bytes: u64, } -/// Check the signed manifest and describe the update without downloading the payload. +/// Describe the update; a managed launcher may need a one-time authenticated payload check. pub fn preview(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &str, resolutions: &BTreeMap) -> Result { + let meta_dir = crate::registry::metadata_dir_for(root)?; + let _lock = operation_lock(&meta_dir)?; + ensure_recovered(&meta_dir)?; let (m, _) = fetch_manifest(source, trusted_key)?; check_manifest(&m)?; - let items = plan(root, meta, &m, resolutions, None)?; + reject_downgrade(meta, &m)?; + let mut items = plan(root, meta, &m, resolutions, None)?; + if items.iter().any(|item| item.path == "Scripts/manage.py" && item.action != Action::Skip) + && launcher_already_integrated(root, &meta_dir, meta, &m, source)? { + if let Some(item) = items.iter_mut().find(|item| item.path == "Scripts/manage.py") { + item.action = Action::Skip; + item.reason = Some("already up to date with Manager integration".into()); + } + } + let pending_migrations = (RepackEnv { root, meta_dir: &meta_dir }).pending_migrations(&m)?; Ok(Preview { from_version: meta.core.version.clone(), to_version: m.version.clone(), conflicts: items.iter().filter(|i| i.action == Action::Conflict).map(|i| i.path.clone()).collect(), items, migrations: m.migrations.len(), + pending_migrations, download_bytes: m.archive.as_ref().map(|a| a.parts.iter().map(|p| p.size).sum()).unwrap_or(0), }) } +fn launcher_already_integrated(root: &Path, meta_dir: &Path, meta: &InstallMeta, manifest: &Manifest, source: &Source) -> Result { + let Some(file) = manifest.files.iter().find(|file| file.path == "Scripts/manage.py") else { return Ok(false); }; + let actual = sha_if_exists(&root.join(&file.path)); + if actual.as_ref() != meta.original_hashes.get(&file.path) || actual.is_none() { return Ok(false); } + let cache = meta_dir.join("manifests").join(format!("launcher-{}.py", file.sha256)); + let signed = match fs::read(&cache) { + Ok(bytes) if fsx::sha256_bytes(&bytes) == file.sha256 => bytes, + _ => { + let temp = tempfile::tempdir()?; + let parts = fetch_parts(source, manifest, &temp.path().join("download"), &Cancel::default(), &|_, _| {})?; + package::extract(&parts, manifest, &temp.path().join("tree"), &|_, _| {})?; + let bytes = fs::read(temp.path().join("tree").join(&file.path))?; + if fsx::sha256_bytes(&bytes) != file.sha256 { return Err(Error::Invalid("The signed launcher failed verification.".into())); } + // This derived cache is optional; failure to save it does not change the server. + let _ = fsx::atomic_write(&cache, &bytes); + bytes + } + }; + Ok(std::str::from_utf8(&signed).ok().is_some_and(|signed| { + fs::read(root.join(&file.path)).ok().is_some_and(|actual| crate::driver::launcher_matches(signed, &actual)) + })) +} + +fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { + if let Some(installed) = meta.core.version.as_deref() { + if let (Some(have), Some(candidate)) = (crate::manifest::parse_version(installed), crate::manifest::parse_version(&manifest.version)) { + if candidate < have { + return Err(Error::Invalid(format!("Installed server {installed} is newer than package {}. Use its recovery point to undo an update; older packages cannot be applied.", manifest.version))); + } + } + } + Ok(()) +} + fn check_manifest(m: &Manifest) -> Result<()> { if m.kind != Kind::Update { return Err(Error::Invalid("This is not an update package.".into())); @@ -262,6 +369,8 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { let (m, manifest_bytes) = fetch_manifest(&p.source, p.trusted_key)?; tracing::info!(root = %root.display(), from_version = ?meta.core.version, to_version = %m.version, migrations = m.migrations.len(), "server update selected"); check_manifest(&m)?; + reject_downgrade(&meta, &m)?; + p.env.preflight(&m)?; let archive = m.archive.clone().ok_or_else(|| Error::InvalidManifest("no archive".into()))?; let id = format!("{}-{}-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"), m.version.replace('.', "_"), uuid::Uuid::new_v4().simple()); let tdir = txn_dir(meta_dir, &id)?; @@ -273,7 +382,8 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { fsx::atomic_write(&tdir.join("manifest.json.sig"), &signature)?; fsx::atomic_write(&before.join("manager-install.json"), &fs::read(meta_dir.join("install.json"))?)?; - let mut txn = Txn { id: id.clone(), state: State::Prepared, from_version: meta.core.version.clone(), to_version: m.version.clone(), recovery_point: None, databases_started: false, ops: Vec::new(), message: None }; + let mut txn = Txn { id: id.clone(), state: State::Prepared, from_version: meta.core.version.clone(), to_version: m.version.clone(), recovery_point: None, databases_started: false, ops: Vec::new(), message: None, recovery_hashes: BTreeMap::new() }; + txn.recovery_hashes.insert("@install".into(), fsx::sha256_file(&before.join("manager-install.json"))?); save(meta_dir, &txn)?; // Nothing has touched the installation yet; failures up to here just discard the staging area. @@ -282,6 +392,7 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { let parts = fetch_parts(&p.source, &m, &meta_dir.join("staging").join("download"), &p.cancel, &|f, _| step(report, "Downloading the update", 5 + (f * 35.0) as u8))?; step(report, "Verifying the update", 42); package::extract(&parts, &m, &tree, &|_, _| {})?; + crate::migrations::verify_files(&m.migrations, &tree.join("_migrations"))?; Ok(tree.clone()) })(); let tree = match staged { @@ -311,6 +422,8 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { p.env.ensure_stopped()?; step(report, "Saving a recovery point", 52); txn.recovery_point = Some(p.env.snapshot().map_err(|e| Error::Invalid(format!("The update was not applied because the safety backup failed: {e}")))?); + let point_meta = meta_dir.join("backups").join(txn.recovery_point.as_deref().unwrap()).join("backup.json"); + if point_meta.is_file() { txn.recovery_hashes.insert("@point".into(), fsx::sha256_file(&point_meta)?); } txn.ops = items .iter() @@ -449,6 +562,19 @@ fn fail_after_apply(p: &Params, txn: &mut Txn, before: &Path, tree: &Path, why: } fn restore_transaction(root: &Path, meta: &Path, before: &Path, txn: &mut Txn, env: &dyn Env) -> Result<()> { + for (rel, expected) in &txn.recovery_hashes { + let saved = match rel.as_str() { + "@install" => before.join("manager-install.json"), + "@point" => meta.join("backups").join(txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The recovery point identity is missing.".into()))?).join("backup.json"), + _ => fsx::safe_join(before, rel)?, + }; + if fsx::sha256_file(&saved)? != *expected { return Err(Error::Invalid(format!("Recovery copy {rel} is damaged; no restoration was started."))); } + } + if let Some(id) = &txn.recovery_point { env.verify_snapshot(id)?; } + for op in txn.ops.iter().filter(|op| op.started && op.had_previous) { + if !fsx::safe_join(before, &op.path)?.is_file() { return Err(Error::Invalid(format!("The recovery copy of {} is missing; no restoration was started.", op.path))); } + } + if !before.join("manager-install.json").is_file() { return Err(Error::Invalid("The saved installation metadata is missing; no restoration was started.".into())); } env.ensure_stopped()?; if txn.databases_started { let id = txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The database recovery point is missing.".into()))?; @@ -476,6 +602,7 @@ fn apply_ops(root: &Path, tree: &Path, before: &Path, files: &[FileEntry], txn: let keep = fsx::safe_join(before, &path)?; fs::create_dir_all(keep.parent().unwrap())?; fsx::atomic_write(&keep, &fs::read(&target)?)?; + txn.recovery_hashes.insert(path.clone(), fsx::sha256_file(&keep)?); } // Journal only after the old bytes are safely saved; a crash during backup must not // turn an untouched file into a rollback operation with a missing saved copy. @@ -566,7 +693,10 @@ pub(crate) fn begin_repair(root: &Path, meta_dir: &Path, version: &str, backup: if had_previous { fsx::atomic_write(&fsx::safe_join(&before, &file.path)?, &fs::read(&target)?)?; } ops.push(Op { path: file.path.clone(), action: if had_previous { Action::Replace } else { Action::Create }, reason: None, new_sha256: file.sha256.clone(), had_previous, started: true, done: false }); } - let txn = Txn { id, state: State::Applying, from_version: Some(version.into()), to_version: version.into(), recovery_point: Some(backup.into()), databases_started: false, ops, message: Some("Repair is in progress.".into()) }; + let mut recovery_hashes = BTreeMap::new(); + recovery_hashes.insert("@install".into(), fsx::sha256_file(&before.join("manager-install.json"))?); + for op in ops.iter().filter(|op| op.had_previous) { recovery_hashes.insert(op.path.clone(), fsx::sha256_file(&fsx::safe_join(&before, &op.path)?)?); } + let txn = Txn { id, state: State::Applying, from_version: Some(version.into()), to_version: version.into(), recovery_point: Some(backup.into()), databases_started: false, ops, message: Some("Repair is in progress.".into()), recovery_hashes }; save(meta_dir, &txn)?; Ok(txn) } @@ -585,10 +715,15 @@ pub(crate) fn finish_repair(root: &Path, meta_dir: &Path, txn: &mut Txn, success fn prune(meta_dir: &Path, keep: usize) { let Ok(rd) = fs::read_dir(updates_dir(meta_dir)) else { return }; - let mut dirs: Vec<_> = rd.flatten().filter(|e| fsx::read_json::(&e.path().join("txn.json")).is_ok_and(|t| matches!(t.state, State::Committed | State::RolledBack))).map(|e| e.path()).collect(); + let mut dirs: Vec<_> = rd.flatten().filter(|e| read_journal(&e.path().join("txn.json")).is_ok_and(|t| matches!(t.state, State::Committed | State::RolledBack))).map(|e| e.path()).collect(); dirs.sort(); while dirs.len() > keep { - let _ = fs::remove_dir_all(dirs.remove(0)); + let dir = dirs.remove(0); + if let Ok(txn) = read_journal(&dir.join("txn.json")) { + if let Ok(marker) = cleanup_marker(meta_dir, &txn.id) { + if fsx::atomic_write_json(&marker, &CleanupMarker { id: txn.id, state: txn.state }).is_ok() && fs::remove_dir_all(&dir).is_ok() { let _ = fs::remove_file(marker); } + } + } } } @@ -599,6 +734,20 @@ pub struct RepackEnv<'a> { } impl RepackEnv<'_> { + pub fn pending_migrations(&self, manifest: &Manifest) -> Result { + if manifest.migrations.is_empty() { return Ok(0); } + crate::backup::with_database(self.root, |db| { + let realms = crate::realms::state(self.root)?; + let mut modes = vec![realms.active]; + if realms.wildcard_created { modes.push(if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }); } + let mut count = 0; + for (index, mode) in modes.into_iter().enumerate() { + let list: Vec<_> = manifest.migrations.iter().filter(|m| index == 0 || m.db != "auth").cloned().collect(); + count += crate::migrations::pending_count(&db.clone().for_realm(mode), &list)?; + } + Ok(count) + }) + } pub(crate) fn verify_recovery_point(&self, point: &crate::backup::RecoveryPoint) -> Result<()> { if point.kind != crate::backup::Kind::Full || !["characters", "auth", "world", "configs"].iter().all(|name| point.components.iter().any(|c| c.name == *name)) { return Err(Error::Invalid("The update recovery point does not contain every required database and configuration.".into())); @@ -628,6 +777,18 @@ impl RepackEnv<'_> { } impl Env for RepackEnv<'_> { + fn preflight(&self, manifest: &Manifest) -> Result<()> { + self.pending_migrations(manifest).map(|_| ()) + } + + fn verify_snapshot(&self, id: &str) -> Result<()> { + let point = crate::backup::get(self.meta_dir, id)?; + if point.kind != crate::backup::Kind::Full || !["characters", "auth", "world", "configs"].iter().all(|name| point.components.iter().any(|c| c.name == *name)) { + return Err(Error::Invalid("The recovery point is incomplete; no restoration was started.".into())); + } + if !crate::backup::verify(self.meta_dir, id)?.ok { return Err(Error::Invalid("The recovery point is damaged; available copies were preserved.".into())); } + Ok(()) + } fn ensure_stopped(&self) -> Result<()> { use crate::process::{observe, ServiceState}; let o = observe(self.root, &crate::layout::read_ports(self.root)); @@ -765,16 +926,20 @@ mod tests { snapshot_ok: Cell, calls: RefCell>, migrate_fail: Cell, + preflight_fail: Cell, restore_fail: Cell, } impl Fake { fn ok() -> Fake { - Fake { stopped: Cell::new(true), healthy: Cell::new(true), snapshot_ok: Cell::new(true), calls: Default::default(), migrate_fail: Cell::new(false), restore_fail: Cell::new(false) } + Fake { stopped: Cell::new(true), healthy: Cell::new(true), snapshot_ok: Cell::new(true), calls: Default::default(), migrate_fail: Cell::new(false), preflight_fail: Cell::new(false), restore_fail: Cell::new(false) } } } impl Env for Fake { + fn preflight(&self, _: &Manifest) -> Result<()> { + if self.preflight_fail.get() { Err(Error::Invalid("Conflicting migration history".into())) } else { Ok(()) } + } fn ensure_stopped(&self) -> Result<()> { self.calls.borrow_mut().push("stop"); if self.stopped.get() { Ok(()) } else { Err(Error::Invalid("The server is running.".into())) } @@ -845,7 +1010,7 @@ mod tests { let mut m = build(&src, &pkg, &BuildOptions { kind: Kind::Update, version: "2.0.0".into(), core_commit: None, built_at: "x".into(), part_size: 1 << 20, bots_commit: None, migrations: vec![] }, &|_| {}).unwrap(); // Settings/* are merge-config by the packager; make the migration entry visible to the runner if migrations { - m.migrations.push(crate::manifest::Migration { id: "m1".into(), db: "world".into(), sha256: fsx::sha256_bytes(b"SELECT 1;"), destructive: false }); + m.migrations.push(crate::manifest::Migration { compatible_sha256: vec![], id: "m1".into(), db: "world".into(), sha256: fsx::sha256_bytes(b"SELECT 1;"), destructive: false }); fs::write(pkg.join("manifest.json"), serde_json::to_vec_pretty(&m).unwrap()).unwrap(); } let sk = SigningKey::generate(&mut rand_core::OsRng); @@ -854,6 +1019,52 @@ mod tests { World { _d: d, root, meta: meta_dir, pkg, key: base64::engine::general_purpose::STANDARD.encode(sk.verifying_key().to_bytes()) } } + const LEGACY_LAUNCHER: &str = "import sys\nROOT = Path(__file__).resolve().parents[1]\nfrom squid_playerbots import validate_bots\n(\"WorldDatabaseInfo\", \"acore_world\")\n(\"CharacterDatabaseInfo\", \"acore_characters\")\nSET name='AzerothCore',address=\nWHERE id=1;\nmysql(\"UPDATE acore_auth.realmlist SET flag=0\nif __name__ == \"__main__\":\n"; + + fn legacy_launcher_world(import_patch: bool) -> World { + let w = world(&[("Scripts/manage.py", b"new release launcher")], false); + let mut source = crate::realms::patch_launcher(LEGACY_LAUNCHER).unwrap(); + if import_patch { source = crate::driver::patch_launcher_imports(&source).unwrap(); } + write(&w.root, "Scripts/manage.py", source.as_bytes()); + let (_, mut meta) = MetaDir::open(&w.meta).unwrap(); + // Older Managers retained the signed original hash after adding realm profiles. + meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_bytes(LEGACY_LAUNCHER.as_bytes())); + fsx::atomic_write_json(&w.meta.join("install.json"), &meta).unwrap(); + w + } + + #[test] + fn legacy_manager_realm_launcher_is_replaced_during_update() { + let w = legacy_launcher_world(false); + let outcome = run(&w, &Fake::ok(), BTreeMap::new(), None).unwrap(); + assert_eq!(outcome.txn.state, State::Committed); + assert_eq!(read(&w, "Scripts/manage.py"), b"new release launcher"); + } + + #[test] + fn legacy_realm_and_import_repairs_are_replaced_during_update() { + let w = legacy_launcher_world(true); + let outcome = run(&w, &Fake::ok(), BTreeMap::new(), None).unwrap(); + assert_eq!(outcome.txn.state, State::Committed); + assert_eq!(read(&w, "Scripts/manage.py"), b"new release launcher"); + } + + #[test] + fn genuine_launcher_edits_still_require_a_decision_and_can_be_kept() { + let w = legacy_launcher_world(true); + let mut custom = read(&w, "Scripts/manage.py"); + custom.extend_from_slice(b"\n# owner's custom integration\n"); + write(&w.root, "Scripts/manage.py", &custom); + let (_, meta) = MetaDir::open(&w.meta).unwrap(); + let manifest = Manifest::parse(&fs::read(w.pkg.join("manifest.json")).unwrap()).unwrap(); + let items = plan(&w.root, &meta, &manifest, &BTreeMap::new(), None).unwrap(); + assert_eq!(items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Conflict); + let resolutions = BTreeMap::from([("Scripts/manage.py".into(), Resolution::Keep)]); + let outcome = run(&w, &Fake::ok(), resolutions, None).unwrap(); + assert_eq!(outcome.txn.state, State::Committed); + assert_eq!(read(&w, "Scripts/manage.py"), custom); + } + fn run(w: &World, env: &Fake, res: BTreeMap, fail_after: Option) -> Result { apply( &Params { root: &w.root, meta_dir: &w.meta, source: Source::Dir(w.pkg.clone()), trusted_key: &w.key, cancel: Cancel::default(), resolutions: res, env, fail_after_ops: fail_after }, @@ -865,6 +1076,22 @@ mod tests { fs::read(w.root.join(rel)).unwrap() } + #[test] + fn imported_squid_repack_requires_a_decision_even_for_pristine_worldserver() { + let w = world(&[], false); + write(&w.root, "CoA-Bots/release.json", b"{}"); + let (_, mut meta) = MetaDir::open(&w.meta).unwrap(); + meta.kind = InstallKind::Imported; + let manifest: Manifest = serde_json::from_slice(&fs::read(w.pkg.join("manifest.json")).unwrap()).unwrap(); + let action = |resolutions: BTreeMap| { + plan(&w.root, &meta, &manifest, &resolutions, None).unwrap().into_iter().find(|item| item.path == "Core/worldserver.exe").unwrap().action + }; + assert_eq!(action(BTreeMap::new()), Action::Conflict); + assert_eq!(action(BTreeMap::from([("Core/worldserver.exe".into(), Resolution::Keep)])), Action::Skip); + assert_eq!(action(BTreeMap::from([("Core/worldserver.exe".into(), Resolution::Replace)])), Action::Replace); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + } + #[test] fn happy_path_replaces_creates_merges_and_records_ownership() { let w = world(&[], false); @@ -885,6 +1112,47 @@ mod tests { assert!(!w.root.join("Core/worldserver.exe.coa-new").exists()); } + #[test] + fn an_integrated_launcher_is_current_but_user_edits_and_new_package_bytes_are_not() { + let source = b"import sys\nfrom pathlib import Path\nfrom squid_playerbots import configure\nROOT = Path(__file__).resolve().parents[1]\n"; + let w = world(&[("Scripts/manage.py", source)], false); + run(&w, &Fake::ok(), BTreeMap::new(), None).unwrap(); + let integrated = crate::driver::patch_launcher_imports(std::str::from_utf8(source).unwrap()).unwrap(); + write(&w.root, "Scripts/manage.py", integrated.as_bytes()); + let (_, mut meta) = MetaDir::open(&w.meta).unwrap(); + meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_bytes(integrated.as_bytes())); + fsx::atomic_write_json(&w.meta.join("install.json"), &meta).unwrap(); + let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap(); + assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Skip); + let manifest = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0; + let applied_plan = plan(&w.root, &meta, &manifest, &BTreeMap::new(), Some(&w._d.path().join("upd"))).unwrap(); + assert_eq!(applied_plan.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Skip); + let mut changed = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0; + changed.files.iter_mut().find(|file| file.path == "Scripts/manage.py").unwrap().sha256 = fsx::sha256_bytes(b"new launcher"); + assert!(launcher_already_integrated(&w.root, &w.meta, &meta, &changed, &Source::Dir(w.pkg.clone())).is_err()); + write(&w.root, "Scripts/manage.py", format!("{integrated}\n# user edit\n").as_bytes()); + let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap(); + assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Conflict); + meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_file(&w.root.join("Scripts/manage.py")).unwrap()); + let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap(); + assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Replace, + "Recorded user content must not be mistaken for a recognized Manager integration"); + } + + #[test] + fn older_packages_are_rejected_before_preview_or_application() { + let w = world(&[], false); + let (_, mut installed) = MetaDir::open(&w.meta).unwrap(); + installed.core.version = Some("3.0.0".into()); + fsx::atomic_write_json(&w.meta.join("install.json"), &installed).unwrap(); + let env = Fake::ok(); + assert!(run(&w, &env, BTreeMap::new(), None).unwrap_err().to_string().contains("newer than package")); + assert!(preview(&w.root, &installed, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap_err().to_string().contains("newer than package")); + assert!(env.calls.borrow().is_empty()); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + assert!(pending_checked(&w.meta).unwrap().is_none()); + } + #[test] fn a_file_modified_by_the_user_is_a_conflict_until_resolved() { let w = world(&[], false); @@ -1007,6 +1275,7 @@ mod tests { t.state = State::Applying; for op in t.ops.iter_mut() { op.started = op.path == "Core/worldserver.exe"; + op.done &= op.started; } save(&w.meta, &t).unwrap(); assert_eq!(unfinished(&w.meta).unwrap().id, id); @@ -1106,6 +1375,79 @@ mod tests { assert!(unfinished(&w.meta).is_none()); } + #[test] + fn sql_manifest_mismatch_is_rejected_before_snapshot_or_replacement() { + let w = world(&[], true); + let manifest_path = w.pkg.join("manifest.json"); + let mut manifest: Manifest = fsx::read_json(&manifest_path).unwrap(); + manifest.migrations[0].sha256 = "a".repeat(64); + let bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + // A publisher mistake: valid signed archive bytes disagree with the SQL metadata. + let key = SigningKey::generate(&mut rand_core::OsRng); + let trusted = base64::engine::general_purpose::STANDARD.encode(key.verifying_key().to_bytes()); + fs::write(&manifest_path, &bytes).unwrap(); + fs::write(w.pkg.join("manifest.json.sig"), base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).to_bytes())).unwrap(); + let env = Fake::ok(); + let error = apply(&Params { root: &w.root, meta_dir: &w.meta, source: Source::Dir(w.pkg.clone()), trusted_key: &trusted, + cancel: Default::default(), resolutions: Default::default(), env: &env, fail_after_ops: None }, &|_, _| {}).unwrap_err(); + assert!(matches!(error, Error::HashMismatch { .. }), "Expected SQL metadata mismatch, received {error}"); + assert!(env.calls.borrow().is_empty()); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + assert!(pending_checked(&w.meta).unwrap().is_none()); + } + + #[test] + fn preflight_rejection_precedes_snapshot_and_file_changes() { + let w = world(&[], true); + let env = Fake::ok(); + env.preflight_fail.set(true); + let original = fsx::sha256_file(&w.meta.join("install.json")).unwrap(); + assert!(run(&w, &env, BTreeMap::new(), None).unwrap_err().to_string().contains("Conflicting migration history")); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + assert_eq!(fsx::sha256_file(&w.meta.join("install.json")).unwrap(), original); + assert!(env.calls.borrow().is_empty()); + assert!(fs::read_dir(w.meta.join("updates")).unwrap().next().is_none()); + } + + #[test] + fn missing_and_semantically_corrupt_journals_block_operations() { + let w = world(&[], false); + let txn = run(&w, &Fake::ok(), BTreeMap::new(), None).unwrap().txn; + let path = txn_dir(&w.meta, &txn.id).unwrap().join("txn.json"); + let mut bad = txn.clone(); + bad.id = "another-update".into(); + fsx::atomic_write_json(&path, &bad).unwrap(); + assert!(pending_checked(&w.meta).is_err()); + bad = txn.clone(); + bad.ops[0].path = "../outside".into(); + fsx::atomic_write_json(&path, &bad).unwrap(); + assert!(ensure_recovered(&w.meta).is_err()); + bad = txn.clone(); + bad.ops[0].started = false; + fsx::atomic_write_json(&path, &bad).unwrap(); + assert!(ensure_recovered(&w.meta).is_err()); + fs::remove_file(&path).unwrap(); + assert!(pending_checked(&w.meta).is_err()); + let marker = cleanup_marker(&w.meta, &txn.id).unwrap(); + fsx::atomic_write_json(&marker, &CleanupMarker { id: txn.id, state: State::Committed }).unwrap(); + assert!(pending_checked(&w.meta).unwrap().is_none()); + } + + #[test] + fn damaged_saved_file_blocks_restore_before_any_environment_action() { + let w = world(&[], false); + let env = Fake::ok(); + env.healthy.set(false); + let txn = run(&w, &env, BTreeMap::new(), None).unwrap().txn; + let before = txn_dir(&w.meta, &txn.id).unwrap().join("before"); + write(&before, "Core/worldserver.exe", b"damaged original"); + env.calls.borrow_mut().clear(); + assert!(rollback(&w.root, &w.meta, &txn.id, &env).unwrap_err().to_string().contains("damaged")); + assert!(env.calls.borrow().is_empty()); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v2"); + assert_eq!(pending_checked(&w.meta).unwrap().unwrap().state, State::NeedsDecision); + } + #[test] fn unreadable_update_journal_blocks_changes_and_startup() { let w = world(&[], false); diff --git a/crates/coa-release/src/main.rs b/crates/coa-release/src/main.rs index 2956080..7551547 100644 --- a/crates/coa-release/src/main.rs +++ b/crates/coa-release/src/main.rs @@ -6,6 +6,7 @@ //! coa-release extract-schema-base --package DIR --fixture NEW-coa-schema-fixture-DIR //! coa-release schema-contract --repack FIXTURE --tree DIR --core DIR [--bots DIR] [--repairs DIR] //! coa-release clean-base --repack DIR --core DIR --tree DIR --out DIR [--bots DIR] [--data DIR] +//! coa-release export-baseline --server DIR --out DIR (the three databases of a prepared server, for a Linux package) //! coa-release sign --dir DIR (key: env COA_SIGNING_KEY, or ~/.coa-manager/signing/manifest-signing.key) //! coa-release verify --dir DIR (against the public key built into this tool) @@ -93,6 +94,12 @@ fn run() -> Result<(), String> { coa_core::cleanbase::build(&coa_core::cleanbase::Params { repack: &repack, core: &core, bots: bots.as_deref(), tree: &tree, data: data.as_deref(), out: &out }, &|s| eprintln!("{s}")).map_err(e)?; println!("clean base tree at {}", out.display()); } + "export-baseline" => { + let done = coa_core::release::export_baseline(&PathBuf::from(need(&a, "server")?), &PathBuf::from(need(&a, "out")?)).map_err(e)?; + for (kind, bytes) in done { + println!("{kind}.sql.zst: {:.1} MB", bytes as f64 / 1e6); + } + } "sign" => { let dir = PathBuf::from(need(&a, "dir")?); require_contract_entry(&dir)?; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3634aca..80a4ff9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -374,9 +374,46 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop, commands do on the host (password through `MYSQL_PWD`, never on a command line). Nothing else changes: accounts, backups, the population query, migrations and the RA service account all go through `Db`, and the host needs no MySQL client. A repack keeps using its bundled tools. -* **Not done yet**: installation and the database setup (schemas, `acore` user), updates, port conflict detection, +* **Health of the database container** is a ping over TCP, like every connection the Manager makes. On a new volume the + image first runs a temporary server that answers on its socket but takes no network connection; a socket ping reported + the container healthy about four seconds before TCP worked (measured: 10 s against 14 s). +* **Main configuration files**: a package carries only `worldserver.conf.dist` and `authserver.conf.dist` (the release + tool leaves active files out). On Windows the launcher writes them at each start; on Docker `ensure_main_configs` creates + them from the templates when they are missing, at installation and at every start, and never overwrites one. +* **Installing a new server** (`docker/install.rs`, separate from `install::install_base`, which is not touched). Same + order of events: signed manifest, space and folder checks, verified download, extraction into `.installing`, + database, then one rename. The package must hold `Core/worldserver`, `Core/authserver`, the `.dist` templates, + `_migrations/` and **`Database/baseline/{auth,characters,world}.sql.zst`**. The baseline is a dump, in the format of the + Manager's backups, of databases in which **every migration of the package is already applied**; the installer imports + it, records those migrations as applied (`baseline = 1`, a few bulk statements) and creates the `acore` account, the + realm port and the console account. It is the same arrangement as a Windows package, which ships its prepared database. + The databases are **not rebuilt from the repository's SQL**: some migrations are guards generated from the maintainers' + live database (`rev_20260903_01_live_class_baseline` refuses to run unless the table holds exactly the rows it expects), + so replaying the SQL files from the base scripts fails, in any order. `coa-release export-baseline --server DIR --out DIR` + produces the dumps from a prepared server (repack or Docker). The game data stays where the person has it + (`dataDir` in `docker.json`, mounted read-only). A failure removes the containers, the volume and the staging folder. +* **Not done yet**: wiring the installer into the screens, downloading the game data, updates, port conflict detection, the firewall and exposure checks, the Wildcard realm profiles, and the client under Wine/Proton. +### D14. Keeping Windows and Linux apart + +A fix for one platform must not be able to break the other. The rules: + +1. **Platform code lives in its own modules**: `coa_core::docker` for Linux and Docker; the repack code (`driver`, `process`, + `install`, `layout` and the bundled-tool half of `db`) for Windows. A new Linux feature is a new file there, not an edit + of a repack function. +2. **Shared modules hold platform-neutral logic only** (manifest, signing, download, extraction, migrations, backups, + configuration editing, the registry). +3. **The difference enters in as few places as possible**: the early `is_docker` return at the top of `driver::run`, + `process::observe`, `layout::scan` and `Db::from_repack`, and the platform checks of the Tauri layer. Everything past + that point belongs to one platform. +4. **No `cfg` in the middle of a shared algorithm.** Where one is unavoidable (file modes, a path comparison), it is small, + commented, and the other platform's branch is the code that was there before. +5. **Reuse by calling, not by editing**: a Linux feature that needs a helper from a Windows-side file makes it + `pub(crate)` and calls it (visibility only); it does not change what the helper does. +6. **Both platforms are tested on every pull request** (`check.yml`: Windows and Linux). Tests of one platform's behaviour + are marked for that platform, so they cannot fail on the other. + --------------------------------------------------------------------------------------------------------- ## 3. Flows diff --git a/package-lock.json b/package-lock.json index a98828c..8ed0158 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.4", + "version": "0.6.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.4", + "version": "0.6.6", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 650bb96..4465eb1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.4", + "version": "0.6.6", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d17defa..1dc0440 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -807,7 +807,7 @@ async fn check_update(state: State<'_, AppState>, id: String, source: Option, id: String) -> std::result::Result, UiError> { let root = path_of(&state, &id)?; - Ok(update::unfinished(&meta_dir(&root)?)) + Ok(update::pending_checked(&meta_dir(&root)?)?) } #[tauri::command] @@ -1359,6 +1359,7 @@ async fn play(state: State<'_, AppState>, id: String) -> std::result::Result, + lan_addresses: Vec, exposure: Vec, /// The configuration lets other computers reach the login and world servers. servers_open: bool, @@ -1375,10 +1376,9 @@ struct FriendsStatus { #[tauri::command] fn open_link(url: String) -> std::result::Result<(), UiError> { const ALLOWED: &[&str] = &["https://tailscale.com/", "https://login.tailscale.com/", "https://portforward.com/", "https://github.com/Corfirean/"]; - // A prefilled "new issue" page of this project: the address must be exactly that page, and its query may carry - // `&` between the (percent-encoded) title and text. - const NEW_ISSUE: &str = "https://github.com/Corfirean/coa-server-manager/issues/new"; - let issue = url == NEW_ISSUE || url.starts_with(&format!("{NEW_ISSUE}?")); + // A prefilled "new issue" page of one of the places a report can go (the Manager, Companions, SQUID Playerbots): the + // address must be exactly that page, and its query may carry `&` between the (percent-encoded) title and text. + let issue = coa_core::report::is_new_issue_url(&url); let chars_ok = url.chars().all(|c| c.is_ascii_alphanumeric() || "/:._-?=#%".contains(c) || (issue && c == '&')); // the GitHub page of a module that is in the bundled catalog (some are not ours) let catalog = coa_core::modules::catalog().iter().any(|e| e.repo == url); @@ -1396,6 +1396,16 @@ struct ReportContext { /// "new" for a server the Manager installed, "imported" for one that was added. install_kind: String, server_version: Option, + /// Where the form should start: the bot system that is switched on, else the Manager. + suggested_target: String, + /// The release of whichever bot system is on, for a report that goes to its repository. + bots_version: Option, +} + +/// The places a report can go, each with its GitHub repository. +#[tauri::command] +fn report_targets() -> Vec { + coa_core::report::targets() } /// "Windows 11 (build 26200)" from `ver`; empty if it cannot be read. @@ -1422,11 +1432,15 @@ async fn report_context(state: State<'_, AppState>, id: String) -> std::result:: let root = path_of(&state, &id)?; blocking(move || { let (_, meta) = install_meta(&root)?; + let bots = coa_core::report::bots(&root); + let companions = [meta.bots.version.clone(), meta.bots.commit.as_ref().map(|c| c.chars().take(8).collect())].into_iter().flatten().collect::>().join(" · "); Ok(ReportContext { manager_version: coa_core::MANAGER_VERSION.to_string(), windows: windows_version(), install_kind: if meta.kind == coa_core::registry::InstallKind::New { "new".into() } else { "imported".into() }, server_version: meta.core.version.clone(), + suggested_target: bots.suggested.to_string(), + bots_version: if bots.suggested == "squid" { bots.squid_version } else if bots.suggested == "companions" && !companions.is_empty() { Some(companions) } else { None }, }) }) .await @@ -1438,9 +1452,11 @@ async fn friends_status(state: State<'_, AppState>, id: String) -> std::result:: blocking(move || { let meta = meta_dir(&root)?; let ports = layout::read_ports(&root); + let automatic = coa_core::net::lan_ip().filter(|ip| coa_core::net::is_lan_address(*ip)); Ok(FriendsStatus { settings: coa_core::friends::load(&meta), - lan_ip: coa_core::net::lan_ip().map(|a| a.to_string()), + lan_ip: automatic.map(|a| a.to_string()), + lan_addresses: coa_core::net::lan_addresses(automatic).unwrap_or_default(), exposure: coa_core::net::exposure(&ports), servers_open: coa_core::friends::bind_is_open(&root), firewall: coa_core::firewall::status(), @@ -1493,9 +1509,10 @@ async fn friends_enable( id: String, mode: coa_core::friends::Mode, host: Option, + lan_address_override: Option, use_upnp: bool, ) -> std::result::Result { - use coa_core::friends::{self, Mode, Settings}; + use coa_core::friends::{self, Mode}; let root = path_of(&state, &id)?; let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { @@ -1506,7 +1523,7 @@ async fn friends_enable( let secondary = realms.secondary_world_port.filter(|_| realms.simultaneous); let host = match mode { Mode::Local => "127.0.0.1".to_string(), - Mode::Lan => coa_core::net::lan_ip().ok_or_else(|| Error::Invalid("This computer has no network address.".into()))?.to_string(), + Mode::Lan => coa_core::net::resolve_lan_host(lan_address_override.as_deref(), coa_core::net::lan_ip())?, Mode::Direct => host.filter(|h| !h.is_empty()).ok_or_else(|| Error::Invalid("Check your connection first to learn your public address.".into()))?, Mode::Private => coa_core::net::tailscale().ip.ok_or_else(|| Error::Invalid("Tailscale is not connected. Install it, sign in, then try again.".into()))?, }; @@ -1527,7 +1544,9 @@ async fn friends_enable( None => note = Some("Your router does not support automatic setup; forward the two game ports by hand or use the private network.".to_string()), } } - friends::save(&meta, &Settings { mode, host: Some(host.clone()) })?; + let mut settings = friends::load(&meta); + settings.select_mode(mode, host.clone(), lan_address_override); + friends::save(&meta, &settings)?; let running = coa_core::process::observe(&root, &ports).world.state == coa_core::process::ServiceState::Running; if running { friends::apply_realm_address(&root, &host)?; @@ -1580,6 +1599,8 @@ async fn export_diagnostics(state: State<'_, AppState>, id: String) -> std::resu let report = coa_core::diag::run(&root, &meta); let out = coa_core::diag::desktop_or_temp().join(format!("CoA-Diagnostics-{}.zip", coa_core::diag::stamp())); coa_core::diag::export_package(&root, &dir, &data_dir().join("logs").join("manager.log"), &meta, &report, &out)?; + // Show the file in its folder so nobody has to look for it; failing to open the folder is not a failure. + let _ = tauri_plugin_opener::reveal_item_in_dir(&out); Ok(out.to_string_lossy().into_owned()) }) .await @@ -1689,6 +1710,7 @@ pub fn run() { companions_delete_all, companions_despawn_some, open_link, + report_targets, companion_sizes, add_companions, remote_connection, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6340112..eff0631 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CoA Server Manager", - "version": "0.6.4", + "version": "0.6.6", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 4996c1c..cba1688 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -46,7 +46,7 @@ export {}; if(cmd==="preview_preset"){const a=args;const sc=a.scope==="server"?serverSchema:botsSchema;const pr=(a.scope==="server"?serverPresets:botsPresets).presets as any[];const p=pr.find(x=>x.id===a.preset)??{id:a.preset??"defaults",title:"Recommended defaults",description:"Every setting returns to its default value."};const st=(sc.settings as any[]).filter(x=>x.type==="int"||x.type==="float"||x.type==="enum").slice(0,3);return {id:p.id,title:p.title,description:p.description,changes:st.map(x=>({key:x.key,title:x.title,from:x.default,to:x.type==="enum"?(x.options?.[1]?.value??x.default):(Number(x.default)||1)*2,dangerous:false}))};} if(cmd==="companion_sizes") return {hardware:{cores:16,ram_gb:31,free_ram_gb:12},sizes:[{id:"small",title:"Small",bots:50,warning:null},{id:"medium",title:"Medium",bots:250,warning:null},{id:"large",title:"Large",bots:500,warning:"500 companions may need more CPU and memory than this computer has (8 cores, 16 GB)."}]}; if(cmd==="add_companions"){const base=(window as any).__bots??85;(window as any).__target=base+args.count;return {spawned:"queued",created:null,baseline:base};} - if(cmd==="check_update") return {from_version:(window as any).__updated?"0.261001.8":"0.260930.5",to_version:"0.261001.8",items:[],conflicts:[],migrations:5,download_bytes:6000000}; + if(cmd==="check_update") return {from_version:(window as any).__updated?"0.261001.8":"0.260930.5",to_version:"0.261001.8",items:[],conflicts:[],migrations:5,pending_migrations:(window as any).__updated?0:5,download_bytes:6000000}; if(cmd==="apply_update"){const w=window as any;for(let i=1;i<=8;i++){await new Promise((r: any)=>setTimeout(r,700));(w.__handlers?.["update-progress"]??[]).forEach((h: any)=>h({event:"update-progress",id:1,payload:{step:i<4?"Downloading the update":i<7?"Applying the update":"Finishing",percent:i*12}}));}(window as any).__updated=true;return {txn:{state:"committed",to_version:"0.261001.8"},migrations:null};} if(cmd==="get_population"){if(!running) return null;let b=(window as any).__bots??85;const t=(window as any).__target??0;if(t>b){b=Math.min(t,b+25);(window as any).__bots=b;}return {online_total:b,bots_online:Math.max(0,b-7),players_online:0,bots_total:b};} if(cmd==="get_performance") return running?{mean_ms:14,median_ms:15,p95_ms:24,p99_ms:30,max_ms:61,ticks_per_sec:71.4}:null; @@ -87,7 +87,8 @@ export {}; return (S[args.module]??[]).map(s => ({...s, value: moduleEdits[args.module]?.[s.key] ?? s.value})); } if(cmd==="module_save_settings") { moduleEdits[args.module] = {...moduleEdits[args.module], ...args.changes}; return Object.keys(args.changes); } - if(cmd==="report_context") return {manager_version:"0.3.1",windows:"Windows 11 (build 26200)",install_kind:"new",server_version:"0.261001.10"}; + if(cmd==="report_context") return {manager_version:"0.3.1",windows:"Windows 11 (build 26200)",install_kind:"new",server_version:"0.261001.10",suggested_target:"companions",bots_version:"0.2.1 · 1a2b3c4d"}; + if(cmd==="report_targets") return [{id:"manager",repo:"Corfirean/coa-server-manager",ours:true},{id:"companions",repo:"Corfirean/mod-coa-playerbots",ours:true},{id:"squid",repo:"Zyth45/mod-playerbots",ours:false}]; if(cmd==="open_link"){(window as any).__lastLink=args.url;return null;} if(cmd==="export_diagnostics") return "C:\\Users\\you\\Desktop\\CoA-Diagnostics-20261001-140000.zip"; if(cmd==="install_preflight") return {ok:true,problems:[],free_bytes:210*2**30}; diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index b9330d3..c13b43a 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -75,6 +75,7 @@ const de: Partial> = { "btn.restart": "Neustart", "btn.startWithoutUpdate": "Ohne Update starten", "btn.playWithoutUpdate": "Ohne Update spielen", + "import.squidRepackWarning": "Dieses SquidBots-Repack prüft den Worldserver-Hash. Ein Austausch kann Start_All_Bots und coa_update deaktivieren. Updates fragen vorher nach.", "btn.play": "SPIELEN", "btn.startPlay": "STARTEN & SPIELEN", "btn.startingCaps": "STARTET …", @@ -701,9 +702,9 @@ const de: Partial> = { "rep.expectedHint": "Beschreibe das erwartete Verhalten.", "rep.steps": "Schritte zum Nachstellen", "rep.attachTitle": "Logs und Screenshots", - "rep.attachText": "Erstelle eine Diagnosedatei (Einstellungen und Logs ohne Passwörter und Adressen) und ziehe sie zusammen mit Screenshots oder Videos auf die GitHub-Seite, sobald sie offen ist.", + "rep.attachText": "Ein Klick sammelt die Diagnose: Logs des Managers und des Servers, Absturzberichte, Windows-Absturzeinträge und Update-Protokolle, ohne Passwörter und Adressen. Der Ordner mit der Datei wird geöffnet; ziehe die Datei zusammen mit Screenshots oder Videos auf die GitHub-Seite.", "rep.makeFile": "Diagnosedatei erstellen", - "rep.fileSaved": "Gespeichert unter {path}. Ziehe diese Datei in das GitHub-Issue.", + "rep.fileSaved": "Gespeichert unter {path}. Der Ordner wurde geöffnet. Ziehe diese Datei in das GitHub-Issue.", "rep.privacy": "Bitte entferne Passwörter, Tokens, private IP-Angaben und andere sensible Daten, bevor du Logs veröffentlichst.", "rep.open": "Bericht auf GitHub öffnen", "rep.copy": "Text kopieren", @@ -713,6 +714,17 @@ const de: Partial> = { "rep.opened": "GitHub wurde im Browser geöffnet. Prüfe den Text, hänge Dateien an und klicke auf „Submit new issue“.", "rep.openedLong": "Der Bericht ist für einen Link zu lang und wurde deshalb in die Zwischenablage kopiert. GitHub ist geöffnet: Füge ihn (Strg+V) in die Beschreibung ein, hänge Dateien an und klicke auf „Submit new issue“.", "rep.copied": "In die Zwischenablage kopiert.", + "rep.targetTitle": "Worum geht es bei dem Problem?", + "rep.target.manager": "CoA Server Manager, der Server oder andere Module", + "rep.target.companions": "CoA Companions (Bots und das CoABotUI-Addon)", + "rep.target.squid": "SQUID Playerbots", + "rep.targetPublic": "Der Bericht wird öffentlich auf GitHub in {repo} veröffentlicht.", + "rep.botsVersion": "Bot-Version", + "rep.withDiag": "Diagnose für mich sammeln (empfohlen)", + "rep.collectOpen": "Diagnose sammeln und Bericht öffnen", + "rep.collecting": "Diagnose wird gesammelt…", + "overview.reportHint": "Ein Dienst wurde von selbst beendet?", + "overview.reportBtn": "Diagnose sammeln und melden", // Realmlist switcher and modules page "realm.profileLabel": "Realm", @@ -750,7 +762,9 @@ const de: Partial> = { "mod.noSettings": "Keine weiteren Einstellungen. Verwende den Modulschalter oben.", "err.bot_modules_conflict.title": "Zwei Bot-Module sind aktiviert", "err.bot_modules_conflict.message": "Deaktiviere CoA Companions oder SQUID Playerbots unter Module vor dem Serverstart.", - "mod.ahbot.setup": "GUID eines separaten Auktionscharakters eintragen, Verkäufer und Modul aktivieren. Der Charakter muss offline sein. Der CoA-Filter nutzt Beute-, Händler- und Berufstabellen; Stufengrenzen gelten zusätzlich. Erfordert einen Serverbuild mit CoA-Auktionsfilter.", + "mod.ahbot.setup": "Modul und Verkäufer aktivieren. Der Server legt beim ersten Start selbst einen Auktionscharakter an (Konto AHBOT), eine GUID ist nicht nötig. Ein Charakter bietet in den Auktionshäusern beider Fraktionen an. Der CoA-Filter nutzt Beute-, Händler- und Berufstabellen; Stufengrenzen gelten zusätzlich. Erfordert einen Serverbuild mit CoA-Auktionsfilter und automatischem Auktionscharakter.", + "mod.enchanter.add": "Verzauberer platzieren: Stellt euch an die gewünschte Stelle und gebt .npc add 601015 ein", + "mod.enchanter.remove": "Verzauberer entfernen: Ziel auf den NPC setzen und .npc delete eingeben", "mod.ahbot.allSettings": "Alle Auktionseinstellungen", "mod.title": "Module", "mod.intro": "Zusatzfunktionen, die im Server eingebaut sind. Schalte die gewünschten ein oder aus, öffne ihre GitHub-Seite oder ändere ihre Einstellungen.", @@ -815,6 +829,14 @@ const de: Partial> = { "upd.current": "Du bist auf dem neuesten Stand (Version {v}).", "client.job.checking": "Client wird geprüft…", "client.job.updating": "Client wird aktualisiert", + "fr.lan.address": "Netzwerkadresse", + "fr.lan.automatic": "Automatisch", + "fr.lan.default": "Standard", + "fr.lan.custom": "Eigene Adresse…", + "fr.lan.customIpv4": "Eigene IPv4-Adresse", + "fr.lan.unavailable": "Ausgewählte Adresse nicht verfügbar", + "fr.lan.unavailableText": "Die ausgewählte Adresse ist derzeit keinem erkannten lokalen Netzwerkadapter zugewiesen. Wählen Sie eine andere Adresse oder Automatisch, oder behalten Sie diese Adresse bewusst bei.", + "fr.lan.invalid": "Ungültige IPv4-Adresse", }; export default de; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f5e15bd..e67463d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -106,6 +106,7 @@ const en = { "btn.restart": "Restart", "btn.startWithoutUpdate": "Start without updating", "btn.playWithoutUpdate": "Play without updating", + "import.squidRepackWarning": "This SquidBots repack checks its worldserver hash. Replacing the binary can stop Start_All_Bots and coa_update from working. Updates will ask before replacing it.", "btn.play": "PLAY", "btn.startPlay": "START & PLAY", "btn.startingCaps": "STARTING…", @@ -734,9 +735,9 @@ const en = { "rep.expectedHint": "Describe the expected behaviour.", "rep.steps": "Steps to reproduce", "rep.attachTitle": "Logs and screenshots", - "rep.attachText": "Create a diagnostics file (settings and logs with passwords and addresses removed) and drag it, together with any screenshot or video, into the GitHub page once it is open.", + "rep.attachText": "One click collects the diagnostics: the Manager's and the server's logs, crash reports, Windows crash records and update journals, with passwords and addresses removed. The file's folder opens; drag the file, together with any screenshot or video, into the GitHub page.", "rep.makeFile": "Create the diagnostics file", - "rep.fileSaved": "Saved to {path}. Drag this file into the GitHub issue.", + "rep.fileSaved": "Saved to {path}. The folder was opened. Drag this file into the GitHub issue.", "rep.privacy": "Please remove passwords, tokens, private IP information or other sensitive data before posting logs.", "rep.open": "Open the report on GitHub", "rep.copy": "Copy the text", @@ -746,6 +747,17 @@ const en = { "rep.opened": "GitHub was opened in your browser. Check the text, attach files and press Submit new issue.", "rep.openedLong": "The report is too long for a link, so it was copied to the clipboard. GitHub is open: paste it (Ctrl+V) into the description, attach files and press Submit new issue.", "rep.copied": "Copied to the clipboard.", + "rep.targetTitle": "What is the problem about?", + "rep.target.manager": "CoA Server Manager, the server or other modules", + "rep.target.companions": "CoA Companions (bots and the CoABotUI addon)", + "rep.target.squid": "SQUID Playerbots", + "rep.targetPublic": "The report will be public on GitHub in {repo}.", + "rep.botsVersion": "Bot release", + "rep.withDiag": "Collect the diagnostics for me (recommended)", + "rep.collectOpen": "Collect diagnostics and open the report", + "rep.collecting": "Collecting diagnostics…", + "overview.reportHint": "A service stopped on its own?", + "overview.reportBtn": "Collect diagnostics and report", // Realmlist switcher and modules page "realm.label": "Realm", @@ -769,7 +781,9 @@ const en = { "mod.ahbot.weightsHint": "Relative weights, not percentages. 0 excludes a quality; larger values make it more frequent. If all defaults are zero, enabling selects common quality. Other item filters still apply.", "mod.ahbot.noTypes": "All types are disabled. The seller has no types to choose for new listings.", "mod.noSettings": "There are no additional settings. Use the module switch above.", - "mod.ahbot.setup": "Enter a dedicated auction character GUID, enable the seller and the module. Keep that character offline. The CoA filter selects items from loot tables, vendors and professions; level restrictions also apply. Requires a server build with the CoA auction filter.", + "mod.ahbot.setup": "Turn on the module and the seller. The server creates its own auction character (account AHBOT) the first time it starts, so no GUID is needed. One character lists in both factions' auction houses. The CoA filter selects items from loot tables, vendors and professions; level restrictions also apply. Requires a server build with the CoA auction filter and the automatic auction character.", + "mod.enchanter.add": "Place the enchanter: stand where you want him and enter .npc add 601015", + "mod.enchanter.remove": "Remove the enchanter: target him and enter .npc delete", "mod.ahbot.allSettings": "All settings", "err.bot_modules_conflict.title": "Two bot modules are enabled", "err.bot_modules_conflict.message": "Disable CoA Companions or SQUID Playerbots in Modules before starting the server.", @@ -962,6 +976,14 @@ const en = { "portable.diag.text": "A report for a bug report: realms, versions, sessions and recent problems. No passwords and no character data.", "portable.diag.copy": "Copy diagnostics", "portable.diag.copied": "Copied.", + "fr.lan.address": "Network address", + "fr.lan.automatic": "Automatic", + "fr.lan.default": "Default", + "fr.lan.custom": "Custom address…", + "fr.lan.customIpv4": "Custom IPv4 address", + "fr.lan.unavailable": "Selected address unavailable", + "fr.lan.unavailableText": "The selected address is not currently assigned to a detected local interface. Choose another address or switch to Automatic, or keep this address intentionally.", + "fr.lan.invalid": "Invalid IPv4 address", } as const; export default en; diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index a8ee876..3bd4e70 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -75,6 +75,7 @@ const es: Partial> = { "btn.restart": "Reiniciar", "btn.startWithoutUpdate": "Iniciar sin actualizar", "btn.playWithoutUpdate": "Jugar sin actualizar", + "import.squidRepackWarning": "Este repack SquidBots verifica el hash del servidor. Sustituirlo puede impedir el funcionamiento de Start_All_Bots y coa_update. Se solicitará confirmación.", "btn.play": "JUGAR", "btn.startPlay": "INICIAR Y JUGAR", "btn.startingCaps": "INICIANDO…", @@ -701,9 +702,9 @@ const es: Partial> = { "rep.expectedHint": "Describe el comportamiento esperado.", "rep.steps": "Pasos para reproducirlo", "rep.attachTitle": "Registros y capturas", - "rep.attachText": "Crea un archivo de diagnóstico (ajustes y registros sin contraseñas ni direcciones) y arrástralo, junto con capturas o vídeos, a la página de GitHub cuando se abra.", + "rep.attachText": "Un clic reúne el diagnóstico: registros del Manager y del servidor, informes de fallos, registros de fallos de Windows y diarios de actualización, sin contraseñas ni direcciones. Se abre la carpeta del archivo; arrástralo, junto con capturas o vídeos, a la página de GitHub.", "rep.makeFile": "Crear el archivo de diagnóstico", - "rep.fileSaved": "Guardado en {path}. Arrastra este archivo al issue de GitHub.", + "rep.fileSaved": "Guardado en {path}. Se abrió la carpeta. Arrastra este archivo al issue de GitHub.", "rep.privacy": "Elimina contraseñas, tokens, información de IP privada y otros datos sensibles antes de publicar registros.", "rep.open": "Abrir el informe en GitHub", "rep.copy": "Copiar el texto", @@ -713,6 +714,17 @@ const es: Partial> = { "rep.opened": "GitHub se abrió en tu navegador. Revisa el texto, adjunta archivos y pulsa «Submit new issue».", "rep.openedLong": "El informe es demasiado largo para un enlace, así que se copió al portapapeles. GitHub está abierto: pégalo (Ctrl+V) en la descripción, adjunta archivos y pulsa «Submit new issue».", "rep.copied": "Copiado al portapapeles.", + "rep.targetTitle": "¿Sobre qué es el problema?", + "rep.target.manager": "CoA Server Manager, el servidor u otros módulos", + "rep.target.companions": "CoA Companions (bots y el addon CoABotUI)", + "rep.target.squid": "SQUID Playerbots", + "rep.targetPublic": "El informe será público en GitHub, en {repo}.", + "rep.botsVersion": "Versión de los bots", + "rep.withDiag": "Reunir el diagnóstico por mí (recomendado)", + "rep.collectOpen": "Reunir el diagnóstico y abrir el informe", + "rep.collecting": "Reuniendo el diagnóstico…", + "overview.reportHint": "¿Un servicio se detuvo solo?", + "overview.reportBtn": "Reunir el diagnóstico y avisar", // Realmlist switcher and modules page "realm.profileLabel": "Reino", @@ -750,7 +762,9 @@ const es: Partial> = { "mod.noSettings": "No hay ajustes adicionales. Usa el interruptor del módulo de arriba.", "err.bot_modules_conflict.title": "Hay dos módulos de bots activos", "err.bot_modules_conflict.message": "Desactiva CoA Companions o SQUID Playerbots en Módulos antes de iniciar el servidor.", - "mod.ahbot.setup": "Indica el GUID de un personaje dedicado a subastas y activa vendedor y módulo. El personaje debe estar desconectado. El filtro CoA usa botín, vendedores y profesiones; los límites de nivel se aplican además. Requiere un servidor con filtro de subastas CoA.", + "mod.ahbot.setup": "Activa el módulo y el vendedor. El servidor crea por sí mismo un personaje de subastas (cuenta AHBOT) en el primer arranque, no hace falta GUID. Un solo personaje publica en las casas de subastas de ambas facciones. El filtro CoA usa botín, vendedores y profesiones; los límites de nivel se aplican además. Requiere un servidor con filtro de subastas CoA y personaje de subastas automático.", + "mod.enchanter.add": "Colocar al encantador: ponte donde quieras y escribe .npc add 601015", + "mod.enchanter.remove": "Quitar al encantador: selecciona al PNJ y escribe .npc delete", "mod.ahbot.allSettings": "Todos los ajustes de subastas", "mod.title": "Módulos", "mod.intro": "Funciones adicionales integradas en el servidor. Activa o desactiva las que quieras, abre su página de GitHub o cambia sus ajustes.", @@ -815,6 +829,14 @@ const es: Partial> = { "upd.current": "Estás al día (versión {v}).", "client.job.checking": "Comprobando el cliente…", "client.job.updating": "Actualizando el cliente", + "fr.lan.address": "Dirección de red", + "fr.lan.automatic": "Automática", + "fr.lan.default": "Predeterminada", + "fr.lan.custom": "Dirección personalizada…", + "fr.lan.customIpv4": "Dirección IPv4 personalizada", + "fr.lan.unavailable": "Dirección seleccionada no disponible", + "fr.lan.unavailableText": "La dirección seleccionada no está asignada actualmente a ninguna interfaz local detectada. Elige otra dirección o Automática, o conserva esta dirección si así lo deseas.", + "fr.lan.invalid": "Dirección IPv4 no válida", }; export default es; diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 1b7e1c5..beb10a4 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -75,6 +75,7 @@ const fr: Partial> = { "btn.restart": "Redémarrer", "btn.startWithoutUpdate": "Démarrer sans mise à jour", "btn.playWithoutUpdate": "Jouer sans mise à jour", + "import.squidRepackWarning": "Ce repack SquidBots vérifie le hash du serveur. Le remplacer peut empêcher Start_All_Bots et coa_update de fonctionner. Une confirmation sera demandée.", "btn.play": "JOUER", "btn.startPlay": "DÉMARRER ET JOUER", "btn.startingCaps": "DÉMARRAGE…", @@ -701,9 +702,9 @@ const fr: Partial> = { "rep.expectedHint": "Décrivez le comportement attendu.", "rep.steps": "Étapes pour reproduire", "rep.attachTitle": "Journaux et captures d’écran", - "rep.attachText": "Créez un fichier de diagnostic (réglages et journaux sans mots de passe ni adresses) et glissez-le, avec vos captures ou vidéos, sur la page GitHub une fois ouverte.", + "rep.attachText": "Un clic rassemble le diagnostic : journaux du Manager et du serveur, rapports de plantage, enregistrements de plantage de Windows et journaux de mise à jour, sans mots de passe ni adresses. Le dossier du fichier s’ouvre ; glissez le fichier, avec vos captures ou vidéos, sur la page GitHub.", "rep.makeFile": "Créer le fichier de diagnostic", - "rep.fileSaved": "Enregistré dans {path}. Glissez ce fichier dans l’issue GitHub.", + "rep.fileSaved": "Enregistré dans {path}. Le dossier a été ouvert. Glissez ce fichier dans l’issue GitHub.", "rep.privacy": "Veuillez retirer les mots de passe, jetons, adresses IP privées et autres données sensibles avant de publier des journaux.", "rep.open": "Ouvrir le rapport sur GitHub", "rep.copy": "Copier le texte", @@ -713,6 +714,17 @@ const fr: Partial> = { "rep.opened": "GitHub s’est ouvert dans votre navigateur. Vérifiez le texte, joignez les fichiers et appuyez sur « Submit new issue ».", "rep.openedLong": "Le rapport est trop long pour un lien : il a été copié dans le presse-papiers. GitHub est ouvert : collez-le (Ctrl+V) dans la description, joignez les fichiers et appuyez sur « Submit new issue ».", "rep.copied": "Copié dans le presse-papiers.", + "rep.targetTitle": "De quoi parle le problème ?", + "rep.target.manager": "CoA Server Manager, le serveur ou d’autres modules", + "rep.target.companions": "CoA Companions (bots et addon CoABotUI)", + "rep.target.squid": "SQUID Playerbots", + "rep.targetPublic": "Le rapport sera public sur GitHub dans {repo}.", + "rep.botsVersion": "Version des bots", + "rep.withDiag": "Rassembler le diagnostic pour moi (recommandé)", + "rep.collectOpen": "Rassembler le diagnostic et ouvrir le rapport", + "rep.collecting": "Collecte du diagnostic…", + "overview.reportHint": "Un service s’est arrêté tout seul ?", + "overview.reportBtn": "Rassembler le diagnostic et signaler", // Realmlist switcher and modules page "realm.profileLabel": "Royaume", @@ -750,7 +762,9 @@ const fr: Partial> = { "mod.noSettings": "Aucun réglage supplémentaire. Utilisez l’interrupteur du module ci-dessus.", "err.bot_modules_conflict.title": "Deux modules de bots sont activés", "err.bot_modules_conflict.message": "Désactivez CoA Companions ou SQUID Playerbots dans Modules avant de démarrer le serveur.", - "mod.ahbot.setup": "Indiquez le GUID d’un personnage dédié aux enchères, activez le vendeur et le module. Le personnage doit être hors ligne. Le filtre CoA utilise le butin, les vendeurs et les métiers ; les limites de niveau s’ajoutent. Nécessite un serveur avec le filtre d’enchères CoA.", + "mod.ahbot.setup": "Activez le module et le vendeur. Le serveur crée lui-même un personnage d’enchères (compte AHBOT) au premier démarrage, aucun GUID n’est nécessaire. Un seul personnage met en vente dans les hôtels des ventes des deux factions. Le filtre CoA utilise le butin, les vendeurs et les métiers ; les limites de niveau s’ajoutent. Nécessite un serveur avec le filtre d’enchères CoA et le personnage d’enchères automatique.", + "mod.enchanter.add": "Placer l’enchanteur : placez-vous à l’endroit voulu et saisissez .npc add 601015", + "mod.enchanter.remove": "Retirer l’enchanteur : ciblez le PNJ et saisissez .npc delete", "mod.ahbot.allSettings": "Tous les réglages d’enchères", "mod.title": "Modules", "mod.intro": "Fonctions supplémentaires intégrées au serveur. Activez ou désactivez celles que vous voulez, ouvrez leur page GitHub ou modifiez leurs réglages.", @@ -815,6 +829,14 @@ const fr: Partial> = { "upd.current": "Vous êtes à jour (version {v}).", "client.job.checking": "Vérification du client…", "client.job.updating": "Mise à jour du client", + "fr.lan.address": "Adresse réseau", + "fr.lan.automatic": "Automatique", + "fr.lan.default": "Par défaut", + "fr.lan.custom": "Adresse personnalisée…", + "fr.lan.customIpv4": "Adresse IPv4 personnalisée", + "fr.lan.unavailable": "Adresse sélectionnée indisponible", + "fr.lan.unavailableText": "Cette adresse n’est actuellement attribuée à aucune interface locale détectée. Choisissez une autre adresse ou le mode Automatique, ou conservez volontairement cette adresse.", + "fr.lan.invalid": "Adresse IPv4 non valide", }; export default fr; diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index e784be2..7807c3a 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -106,6 +106,7 @@ const ru: Partial> = { "btn.restart": "Перезапустить", "btn.startWithoutUpdate": "Запустить без обновления", "btn.playWithoutUpdate": "Играть без обновления", + "import.squidRepackWarning": "Этот репак SquidBots проверяет хеш worldserver. Замена файла может нарушить работу Start_All_Bots и coa_update. Перед заменой менеджер запросит ваше решение.", "btn.play": "ИГРАТЬ", "btn.startPlay": "ЗАПУСТИТЬ И ИГРАТЬ", "btn.startingCaps": "ЗАПУСК…", @@ -732,9 +733,9 @@ const ru: Partial> = { "rep.expectedHint": "Опишите ожидаемое поведение.", "rep.steps": "Как воспроизвести", "rep.attachTitle": "Логи и скриншоты", - "rep.attachText": "Создайте файл диагностики (настройки и логи без паролей и адресов) и перетащите его вместе со скриншотами или видео на страницу GitHub, когда она откроется.", + "rep.attachText": "Диагностика собирается одним нажатием: логи менеджера и сервера, отчёты о падениях, записи Windows о сбоях и журналы обновлений, без паролей и адресов. Откроется папка с файлом; перетащите его вместе со скриншотами или видео на страницу GitHub.", "rep.makeFile": "Создать файл диагностики", - "rep.fileSaved": "Сохранено: {path}. Перетащите этот файл в issue на GitHub.", + "rep.fileSaved": "Сохранено: {path}. Папка открыта. Перетащите этот файл в issue на GitHub.", "rep.privacy": "Перед публикацией логов удалите пароли, токены, частные IP-адреса и другие личные данные.", "rep.open": "Открыть отчёт на GitHub", "rep.copy": "Копировать текст", @@ -744,6 +745,17 @@ const ru: Partial> = { "rep.opened": "GitHub открыт в браузере. Проверьте текст, прикрепите файлы и нажмите «Submit new issue».", "rep.openedLong": "Отчёт слишком длинный для ссылки, поэтому он скопирован в буфер обмена. GitHub открыт: вставьте текст (Ctrl+V) в описание, прикрепите файлы и нажмите «Submit new issue».", "rep.copied": "Скопировано в буфер обмена.", + "rep.targetTitle": "О чём проблема?", + "rep.target.manager": "CoA Server Manager, сервер или другие модули", + "rep.target.companions": "CoA Companions (боты и аддон CoABotUI)", + "rep.target.squid": "SQUID Playerbots", + "rep.targetPublic": "Репорт будет публичным на GitHub в {repo}.", + "rep.botsVersion": "Версия ботов", + "rep.withDiag": "Собрать диагностику за меня (рекомендуется)", + "rep.collectOpen": "Собрать диагностику и открыть репорт", + "rep.collecting": "Собираю диагностику…", + "overview.reportHint": "Служба остановилась сама?", + "overview.reportBtn": "Собрать диагностику и сообщить", // Realmlist switcher and modules page "realm.label": "Реалм", @@ -767,7 +779,9 @@ const ru: Partial> = { "mod.ahbot.weightsHint": "Это относительные веса, а не проценты. 0 исключает качество; большее значение повышает частоту. Если все значения по умолчанию равны нулю, включается обычное качество. Остальные фильтры продолжают действовать.", "mod.ahbot.noTypes": "Все типы отключены. Продавцу не из чего выбирать новые лоты.", "mod.noSettings": "Дополнительных настроек нет. Используйте переключатель модуля выше.", - "mod.ahbot.setup": "Укажите GUID отдельного персонажа аукциониста, включите продавца и модуль. Персонаж должен быть офлайн. Фильтр CoA оставляет предметы из таблиц добычи, торговцев и профессий; ограничения уровней применяются дополнительно. Нужна сборка сервера с фильтром аукциона CoA.", + "mod.ahbot.setup": "Включите модуль и продавца. Сервер сам создаст персонажа аукциона (аккаунт AHBOT) при первом запуске, GUID вводить не нужно. Один персонаж выставляет лоты в аукционах обеих фракций. Фильтр CoA оставляет предметы из таблиц добычи, торговцев и профессий; ограничения уровней действуют дополнительно. Нужна сборка сервера с фильтром аукциона CoA и автоматическим персонажем аукциона.", + "mod.enchanter.add": "Разместить НПС зачарователя: встаньте в нужное место и введите команду .npc add 601015", + "mod.enchanter.remove": "Удалить НПС зачарователя: возьмите НПС в цель и введите команду .npc delete", "mod.ahbot.allSettings": "Все настройки", "err.bot_modules_conflict.title": "Включены две системы ботов", "err.bot_modules_conflict.message": "Выключите CoA Companions или SQUID Playerbots в разделе «Модули» перед запуском сервера.", @@ -960,6 +974,14 @@ const ru: Partial> = { "portable.diag.text": "Отчёт для сообщения об ошибке: реалмы, версии, сессии и недавние проблемы. Без паролей и без данных персонажей.", "portable.diag.copy": "Скопировать диагностику", "portable.diag.copied": "Скопировано.", + "fr.lan.address": "Сетевой адрес", + "fr.lan.automatic": "Автоматически", + "fr.lan.default": "По умолчанию", + "fr.lan.custom": "Другой адрес…", + "fr.lan.customIpv4": "Свой IPv4-адрес", + "fr.lan.unavailable": "Выбранный адрес недоступен", + "fr.lan.unavailableText": "Выбранный адрес сейчас не назначен обнаруженному сетевому интерфейсу. Выберите другой адрес, переключитесь на автоматический выбор или намеренно сохраните этот адрес.", + "fr.lan.invalid": "Некорректный IPv4-адрес", }; export default ru; diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 9f063f8..fe83254 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -87,6 +87,7 @@ const zh = { "btn.restart": "重启", "btn.startWithoutUpdate": "不更新启动服务器", "btn.playWithoutUpdate": "不更新开始游戏", + "import.squidRepackWarning": "此 SquidBots 整合包会检查 worldserver 哈希。替换程序可能导致 Start_All_Bots 和 coa_update 无法运行。更新前会要求您选择。", "btn.play": "开始游戏", "btn.startPlay": "启动并开始游戏", "btn.startingCaps": "正在启动…", @@ -713,9 +714,9 @@ const zh = { "rep.expectedHint": "描述你期望的正常表现。", "rep.steps": "复现步骤", "rep.attachTitle": "日志与截图附件", - "rep.attachText": "生成一份诊断包文件(已自动脱敏密码与私有 IP 等敏感信息),并在浏览器打开 GitHub 页面后,将其连同截图或录屏一起拖拽上传。", + "rep.attachText": "一键收集诊断信息:管理器与服务器日志、崩溃报告、Windows 崩溃记录和更新日志(已去除密码与地址)。文件所在文件夹会自动打开;请将该文件连同截图或录屏一起拖入 GitHub 页面。", "rep.makeFile": "生成诊断包文件", - "rep.fileSaved": "已保存至 {path}。请将该文件拖入 GitHub Issue 中。", + "rep.fileSaved": "已保存至 {path}。文件夹已打开。请将该文件拖入 GitHub Issue 中。", "rep.privacy": "上传前请注意检查并移除日志中可能包含的私有密码、Token 或其他敏感隐私信息。", "rep.open": "在 GitHub 上打开报告", "rep.copy": "复制报告文本", @@ -725,6 +726,17 @@ const zh = { "rep.opened": "已在浏览器中打开 GitHub。请检查内容、附上日志文件并点击“Submit new issue”。", "rep.openedLong": "报告内容较长超出 URL 长度限制,已自动复制到剪贴板。请在已打开的 GitHub 页面中按 Ctrl+V 粘贴描述,附上文件后提交。", "rep.copied": "已复制到剪贴板。", + "rep.targetTitle": "问题与什么有关?", + "rep.target.manager": "CoA Server Manager、服务器或其他模块", + "rep.target.companions": "CoA Companions(机器人与 CoABotUI 插件)", + "rep.target.squid": "SQUID Playerbots", + "rep.targetPublic": "该报告将公开发布在 GitHub 的 {repo}。", + "rep.botsVersion": "机器人版本", + "rep.withDiag": "替我收集诊断信息(推荐)", + "rep.collectOpen": "收集诊断信息并打开报告", + "rep.collecting": "正在收集诊断信息…", + "overview.reportHint": "某个服务自己停止了?", + "overview.reportBtn": "收集诊断信息并反馈", // Realmlist switcher and modules page "realm.label": "服务器列表", @@ -750,7 +762,9 @@ const zh = { "mod.noSettings": "没有其他设置,请使用上方模块开关。", "err.bot_modules_conflict.title": "已启用两个机器人模块", "err.bot_modules_conflict.message": "启动服务器前,请在模块中关闭CoA Companions或SQUID Playerbots。", - "mod.ahbot.setup": "填写独立拍卖角色的GUID,并启用卖家和模块。角色必须离线。CoA筛选使用掉落、商人和专业物品表,等级限制另行生效。需要包含CoA拍卖筛选的服务器版本。", + "mod.ahbot.setup": "启用模块和卖家即可。服务器首次启动时会自动创建拍卖角色(账号AHBOT),无需填写GUID。一个角色即可在双方阵营的拍卖行上架。CoA筛选使用掉落、商人和专业物品表,等级限制另行生效。需要包含CoA拍卖筛选和自动拍卖角色的服务器版本。", + "mod.enchanter.add": "放置附魔师NPC:站在想要的位置,输入 .npc add 601015", + "mod.enchanter.remove": "删除附魔师NPC:选中该NPC,输入 .npc delete", "mod.ahbot.allSettings": "所有拍卖设置", "mod.title": "模块管理", "mod.intro": "服务器内置的可选扩展特性。可按需开关、访问其 GitHub 页面或调整独立参数。", @@ -817,6 +831,14 @@ const zh = { "client.job.updating": "正在更新游戏客户端", "err.docker_unavailable.title": "Docker 不可用", "err.docker_unavailable.message": "此服务器通过 Docker 运行。请安装并启动 Docker,确保当前用户有权使用它,然后重试。", + "fr.lan.address": "网络地址", + "fr.lan.automatic": "自动", + "fr.lan.default": "默认", + "fr.lan.custom": "自定义地址…", + "fr.lan.customIpv4": "自定义 IPv4 地址", + "fr.lan.unavailable": "所选地址不可用", + "fr.lan.unavailableText": "所选地址目前未分配给检测到的本地网络接口。请选择其他地址、切换到自动,或有意保留此地址。", + "fr.lan.invalid": "无效的 IPv4 地址", } as const; export default zh; diff --git a/src/lib/api.ts b/src/lib/api.ts index afa8b38..19f0539 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -193,6 +193,7 @@ export interface RealmProfile { export interface ModuleView { id: string; name: string; + version?: string | null; description: Record; repo: string; /** the module is part of this server build (its configuration exists) */ @@ -216,6 +217,7 @@ export interface ModuleSetting { value: string; default: string | null; doc: string; + field?: { key: string; type: string; title: string; description: string; group: string; group_title?: string | null; min?: number | null; max?: number | null } | null; } export interface AllSetting { @@ -232,6 +234,19 @@ export interface ReportContext { windows: string; install_kind: "new" | "imported"; server_version: string | null; + /** where the form starts: the bot system that is switched on, else the Manager */ + suggested_target: ReportTargetId; + /** the release of the bot system that is on */ + bots_version: string | null; +} + +export type ReportTargetId = "manager" | "companions" | "squid"; + +/** A place a problem report can go: its GitHub repository as `owner/name`. */ +export interface ReportTarget { + id: ReportTargetId; + repo: string; + ours: boolean; } export interface InstallRequirements { @@ -268,6 +283,7 @@ export interface UpdatePreview { items: UpdatePlanItem[]; conflicts: string[]; migrations: number; + pending_migrations?: number; download_bytes: number; } @@ -345,9 +361,16 @@ export interface ClientDownloadCheck { export type FriendsMode = "local" | "lan" | "direct" | "private"; +export interface LanAddress { + interface: string; + address: string; + is_default: boolean; +} + export interface FriendsStatus { - settings: { mode: FriendsMode; host: string | null }; + settings: { mode: FriendsMode; host: string | null; lan_address_override: string | null }; lan_ip: string | null; + lan_addresses: LanAddress[]; exposure: { port: number; what: string; reachable_from_network: boolean; listening: boolean }[]; servers_open: boolean; firewall: { auth: boolean; world: boolean }; @@ -416,6 +439,7 @@ export const api = { allSettings: (id: string) => invoke("all_settings", { id }), allSettingsSave: (id: string, changes: Record) => invoke("all_settings_save", { id, changes }), reportContext: (id: string) => invoke("report_context", { id }), + reportTargets: () => invoke("report_targets"), listAccounts: (id: string) => invoke("list_accounts", { id }), accountSetPassword: (id: string, name: string, password: string) => invoke("account_set_password", { id, name, password }), accountSetAccess: (id: string, name: string, level: number) => invoke("account_set_access", { id, name, level }), @@ -453,8 +477,8 @@ export const api = { play: (id: string) => invoke("play", { id }), friendsStatus: (id: string) => invoke("friends_status", { id }), friendsCheckInternet: () => invoke("friends_check_internet"), - friendsEnable: (id: string, mode: FriendsMode, host?: string, useUpnp = false) => - invoke<{ host: string; restart_required: boolean; note: string | null }>("friends_enable", { id, mode, host: host ?? null, useUpnp }), + friendsEnable: (id: string, mode: FriendsMode, host?: string, useUpnp = false, lanAddressOverride: string | null = null) => + invoke<{ host: string; restart_required: boolean; note: string | null }>("friends_enable", { id, mode, host: host ?? null, useUpnp, lanAddressOverride }), friendsPackage: (id: string) => invoke("friends_package", { id }), runDiagnostics: (id: string) => invoke<{ checks: DiagCheck[]; problems: number }>("run_diagnostics", { id }), verifyFiles: (id: string) => invoke<{ path: string; kind: "missing" | "changed" }[]>("verify_files", { id }), diff --git a/src/lib/moduleSettings.ts b/src/lib/moduleSettings.ts index c43362e..b82b6b2 100644 --- a/src/lib/moduleSettings.ts +++ b/src/lib/moduleSettings.ts @@ -86,8 +86,8 @@ for (const [key, title, desc] of [ for (let n = 1; n <= 3; n++) add(`EC.P${n}`, "string", [`NPC phrase ${n}`, `Реплика НПС ${n}`, `NPC-Satz ${n}`, `Phrase du PNJ ${n}`, `Frase del PNJ ${n}`, `NPC台词 ${n}`], ["Text spoken by the NPC. Keep the enclosing quotes.", "Текст реплики НПС. Сохраните кавычки вокруг текста.", "Gesprochener Text. Anführungszeichen beibehalten.", "Texte prononcé. Conservez les guillemets.", "Texto hablado. Conserva las comillas.", "NPC发言内容,请保留外层引号。"], { advanced: true }); const ah = "AuctionHouseBot."; -add(ah + "GUIDs", "string", ["Auction characters", "Персонажи аукциона", "Auktionscharaktere", "Personnages d’enchères", "Personajes de subasta", "拍卖角色"], ["Character GUIDs separated by commas, without spaces. Use dedicated offline characters, not companion bots. 0 selects nobody.", "GUID персонажей через запятую без пробелов. Используйте отдельных офлайн-персонажей, не ботов-компаньонов. 0 — никто.", "Charakter-GUIDs durch Kommas, ohne Leerzeichen. Separate Offline-Charaktere verwenden, keine Begleiter-Bots. 0 wählt niemanden.", "GUID séparés par des virgules sans espaces. Utilisez des personnages dédiés hors ligne, pas des bots compagnons. 0 ne sélectionne personne.", "GUID separados por comas sin espacios. Usa personajes dedicados desconectados, no bots acompañantes. 0 no selecciona a nadie.", "角色GUID以逗号分隔,不加空格。使用专用离线角色,勿用伙伴机器人。0不选择任何角色。"]); -add(ah + "EnableSeller", "bool", ["Post auctions", "Выставлять лоты", "Auktionen einstellen", "Publier des enchères", "Publicar subastas", "发布拍卖"], ["Let the auction characters sell items. The module switch and valid character GUIDs are also required.", "Разрешает персонажам продавать предметы. Нужны включённый модуль и действительные GUID персонажей.", "Erlaubt Verkäufe. Modulschalter und gültige Charakter-GUIDs sind ebenfalls erforderlich.", "Autorise les ventes. Le module doit être activé et les GUID valides.", "Permite vender. También requiere el módulo activado y GUID válidos.", "允许拍卖角色出售物品,还需启用模块并填写有效角色GUID。"]); +add(ah + "GUIDs", "string", ["Auction characters", "Персонажи аукциона", "Auktionscharaktere", "Personnages d’enchères", "Personajes de subasta", "拍卖角色"], ["Leave 0 and the server creates and uses its own auction character. To use your own, enter GUIDs separated by commas, without spaces; use dedicated offline characters, not companion bots.", "Оставьте 0, и сервер сам создаст и будет использовать своего персонажа аукциона. Чтобы использовать своих, укажите GUID через запятую без пробелов; нужны отдельные офлайн-персонажи, не боты-компаньоны.", "Bei 0 legt der Server selbst einen Auktionscharakter an und nutzt ihn. Eigene Charaktere: GUIDs durch Kommas, ohne Leerzeichen; separate Offline-Charaktere verwenden, keine Begleiter-Bots.", "Laissez 0 : le serveur crée et utilise son propre personnage d’enchères. Pour les vôtres, indiquez des GUID séparés par des virgules sans espaces ; personnages dédiés hors ligne, pas des bots compagnons.", "Deja 0 y el servidor crea y usa su propio personaje de subastas. Para usar los tuyos, indica GUID separados por comas sin espacios; personajes dedicados desconectados, no bots acompañantes.", "保持0时由服务器自动创建并使用拍卖角色。要使用自己的角色,请以逗号分隔填写GUID,不加空格;使用专用离线角色,勿用伙伴机器人。"], { advanced: true }); +add(ah + "EnableSeller", "bool", ["Post auctions", "Выставлять лоты", "Auktionen einstellen", "Publier des enchères", "Publicar subastas", "发布拍卖"], ["Let the auction characters sell items. The module switch is also required.", "Разрешает персонажам продавать предметы. Нужен включённый модуль.", "Erlaubt Verkäufe. Der Modulschalter muss ebenfalls aktiv sein.", "Autorise les ventes. Le module doit aussi être activé.", "Permite vender. También requiere el módulo activado.", "允许拍卖角色出售物品,还需启用模块。"]); add(ah + "CoA.ObtainableItemsOnly", "bool", ["Only obtainable items", "Только доступные в игре предметы", "Nur erhältliche Gegenstände", "Objets accessibles uniquement", "Solo objetos obtenibles", "仅可获取物品"], ["List items from world loot, vendors and professions. Other item filters still apply.", "Выставляет предметы из добычи, торговцев и профессий. Остальные фильтры продолжают действовать.", "Listet Gegenstände aus Beute, Händlern und Berufen. Weitere Filter gelten weiterhin.", "Publie les objets de butin, vendeurs et métiers. Les autres filtres s’appliquent.", "Publica objetos de botín, vendedores y profesiones. Los demás filtros siguen activos.", "发布来自掉落、商人和专业的物品,其他筛选仍生效。"]); add(ah + "ItemsPerCycle", "int", ["Listings per cycle", "Лотов за цикл", "Angebote pro Durchlauf", "Annonces par cycle", "Anuncios por ciclo", "每轮上架数量"], ["Maximum items posted during one auction update.", "Максимум предметов за одно обновление аукциона.", "Maximale Angebote pro Auktionsaktualisierung.", "Nombre maximal d’objets par mise à jour.", "Máximo de objetos por actualización.", "每轮拍卖更新最多上架的物品数量。"], { min: 0 }); for (const [kind, titles] of [ @@ -104,7 +104,7 @@ for (const [faction, names] of [ `${names[0]}: ${maximum ? "maximum" : "minimum"} listings`, `${names[1]}: ${maximum ? "максимум" : "минимум"} лотов`, `${names[2]}: ${maximum ? "maximale" : "minimale"} Angebote`, `${names[3]} : annonces ${maximum ? "maximales" : "minimales"}`, `${names[4]}: anuncios ${maximum ? "máximos" : "mínimos"}`, `${names[5]}:${maximum ? "最多" : "最少"}上架数量`, ], ["Target listing count for this auction house. Keep the minimum no higher than the maximum.", "Целевое число лотов этого аукциона. Минимум не должен превышать максимум.", "Zielanzahl für dieses Auktionshaus. Minimum nicht höher als Maximum einstellen.", "Nombre cible pour cet hôtel des ventes. Le minimum ne doit pas dépasser le maximum.", "Cantidad objetivo para esta casa de subastas. El mínimo no debe superar el máximo.", "此拍卖行的目标上架数量,最小值不得超过最大值。"], { min: 0 }); } -add(ah + "Buyer.Enabled", "bool", ["Buy player auctions", "Покупать лоты игроков", "Spielerauktionen kaufen", "Acheter les enchères des joueurs", "Comprar subastas de jugadores", "购买玩家拍卖"], ["Enable the auction buyer. It spends the auction characters’ money.", "Включает покупателя. Он тратит деньги персонажей аукциона.", "Aktiviert Käufe mit dem Geld der Auktionscharaktere.", "Active les achats avec l’argent des personnages d’enchères.", "Activa las compras con el dinero de los personajes de subasta.", "启用拍卖买家,会花费拍卖角色的金币。"]); +add(ah + "Buyer.Enabled", "bool", ["Buy player auctions", "Покупать лоты игроков", "Spielerauktionen kaufen", "Acheter les enchères des joueurs", "Comprar subastas de jugadores", "购买玩家拍卖"], ["The auctioneer bot buys listings.", "Бот-аукционист покупает лоты.", "Der Auktionator-Bot kauft Angebote.", "Le bot commissaire-priseur achète des annonces.", "El bot subastador compra anuncios.", "拍卖机器人会购买拍卖品。"]); add(ah + "Buyer.BuyCandidatesPerBuyCycle", "int", ["Purchase candidates per cycle", "Кандидатов на покупку за цикл", "Kaufkandidaten pro Durchlauf", "Achats potentiels par cycle", "Candidatos de compra por ciclo", "每轮购买候选数量"], ["How many auctions the buyer considers per cycle.", "Сколько лотов покупатель рассматривает за цикл.", "Anzahl geprüfter Auktionen pro Durchlauf.", "Nombre d’enchères examinées par cycle.", "Subastas examinadas por ciclo.", "买家每轮考虑的拍卖数量。"], { min: 0 }); add(ah + "Buyer.AcceptablePriceModifier", "float", ["Buyer price multiplier", "Множитель цены покупателя", "Kaufpreisfaktor", "Multiplicateur du prix d’achat", "Multiplicador del precio de compra", "买家价格倍率"], ["1 uses the calculated price. Higher values allow more expensive purchases.", "1 — расчётная цена. Большие значения допускают более дорогие покупки.", "1 nutzt den berechneten Preis. Höhere Werte erlauben teurere Käufe.", "1 utilise le prix calculé. Une valeur supérieure autorise des achats plus chers.", "1 usa el precio calculado. Valores mayores permiten compras más caras.", "1使用计算价格,增大后允许更高价购买。"], { min: 0 }); add(ah + "MaxBuyoutPriceInCopper", "int", ["Maximum buyout (copper)", "Максимальная цена выкупа (медь)", "Maximaler Sofortkauf (Kupfer)", "Rachat maximal (cuivre)", "Compra máxima (cobre)", "最高一口价(铜币)"], ["Upper price limit. 10,000 copper = 1 gold.", "Верхний предел цены. 10 000 меди = 1 золото.", "Preisobergrenze. 10.000 Kupfer = 1 Gold.", "Prix maximal. 10 000 cuivre = 1 or.", "Precio máximo. 10 000 cobre = 1 oro.", "价格上限,10000铜币=1金币。"], { min: 0 }); @@ -173,7 +173,9 @@ for (const [category, categoryNames] of Object.entries(auctionCategories)) { { min: 0, max: 1000, advanced: true }, ); } -export function moduleField(key: string, locale: Locale): ModuleField | undefined { +export function moduleField(key: string, locale: Locale, metadata?: ModuleSetting["field"]): ModuleField | undefined { + const translated = locale !== "en" ? fields.find(f => f.key === key) : undefined; + if (metadata) return { title: translated ? text(translated.title, locale) : metadata.title, description: translated ? text(translated.description, locale) : metadata.description, type: metadata.type, category: metadata.group, advanced: false, min: metadata.min ?? undefined, max: metadata.max ?? undefined }; const field = fields.find(f => f.key === key); if (field) return { ...field, title: text(field.title, locale), description: text(field.description, locale), category: "general", advanced: field.advanced ?? false, step: field.type === "range" ? 0.05 : undefined, options: field.options?.map(o => ({ value: o.value, label: text(o.label, locale) })) }; const existing = [...bots.settings, ...server.settings].find(s => s.key === key); diff --git a/src/lib/report.ts b/src/lib/report.ts index aca699f..b3669b3 100644 --- a/src/lib/report.ts +++ b/src/lib/report.ts @@ -1,9 +1,14 @@ +import type { ReportTarget, ReportTargetId } from "./api"; + /** The text of a problem report, always in English so whoever reads it on GitHub can follow it; the person's own words go in as typed. */ export interface ReportInput { managerVersion: string; windows: string; installKind: "new" | "imported"; serverVersion: string | null; + /** where the report goes; a bot repository is told which Manager and bot release it came from */ + target: ReportTargetId; + botsVersion: string | null; what: string; expected: string; steps: string; @@ -11,7 +16,10 @@ export interface ReportInput { diagnostics: boolean; } -export const NEW_ISSUE_URL = "https://github.com/Corfirean/coa-server-manager/issues/new"; +/** The "new issue" page of a repository given as `owner/name`. */ +export function newIssueUrl(repo: string): string { + return `https://github.com/${repo}/issues/new`; +} const PRIVACY = "> Please remove passwords, tokens, private IP information or other sensitive data before posting logs."; export function reportBody(r: ReportInput): string { @@ -23,6 +31,7 @@ export function reportBody(r: ReportInput): string { `**Windows version:** ${r.windows || "unknown"} `, `**Fresh install or imported server:** ${r.installKind === "new" ? "Fresh install" : "Imported server"} `, ...(r.serverVersion ? [`**Server version:** ${r.serverVersion} `] : []), + ...(r.target !== "manager" ? [`**Bot system:** ${r.target === "squid" ? "SQUID Playerbots" : "CoA Companions"}${r.botsVersion ? ` ${r.botsVersion}` : ""} `] : []), "", "**What happened?** ", text(r.what, "Describe the problem."), @@ -54,9 +63,9 @@ export function encodeQuery(s: string): string { .join(""); } -/** A link that opens GitHub's "new issue" page with the title and text filled in. */ -export function issueUrl(title: string, body: string): string { - return `${NEW_ISSUE_URL}?labels=bug&title=${encodeQuery(title)}&body=${encodeQuery(body)}`; +/** A link that opens GitHub's "new issue" page of `target` with the title and text filled in. The bug label is only asked for in our own repositories. */ +export function issueUrl(target: Pick, title: string, body: string): string { + return `${newIssueUrl(target.repo)}?${target.ours ? "labels=bug&" : ""}title=${encodeQuery(title)}&body=${encodeQuery(body)}`; } /** Links longer than this are not reliable in every browser; the full text is then copied to the clipboard instead. */ diff --git a/src/lib/serverUpdate.ts b/src/lib/serverUpdate.ts index 49727ff..cc74040 100644 --- a/src/lib/serverUpdate.ts +++ b/src/lib/serverUpdate.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore } from "react"; import { listen } from "@tauri-apps/api/event"; import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from "@/lib/api"; +import { isUpdateCurrent } from "./serverUpdateStatus"; /** * Server update status for the main screen and the navigation: checked when the app starts and every five minutes, @@ -16,11 +17,13 @@ export interface ServerUpdateState { error: string | null; /** The full error of the last update attempt, for the Settings card. */ uiError: UiError | null; + stateError: UiError | null; + stateRevision: number; /** How the last update ended; kept here so the Settings card can show it after the user left and came back. */ result: { committed: string } | { pending: UpdateTxn } | null; } -const EMPTY: ServerUpdateState = { preview: null, available: false, checking: false, applying: false, step: null, percent: 0, error: null, uiError: null, result: null }; +const EMPTY: ServerUpdateState = { preview: null, available: false, checking: false, applying: false, step: null, percent: 0, error: null, uiError: null, stateError: null, stateRevision: 0, result: null }; const states = new Map(); const listeners = new Set<() => void>(); export const CHECK_EVERY_MS = 5 * 60 * 1000; @@ -37,10 +40,10 @@ export async function checkServerUpdate(id: string): Promise { set(id, { checking: true }); try { const p = await api.checkUpdate(id); - set(id, { preview: p, available: p.to_version !== (p.from_version ?? ""), error: null }); + set(id, { preview: p, available: !isUpdateCurrent(p), error: null }); } catch (e) { - // offline or no package published: keep what we knew and stay quiet - set(id, { error: asUiError(e).human.code }); + // A cached offer is no longer safe after an update or failed recheck. + set(id, { preview: null, available: false, error: asUiError(e).human.code }); } finally { set(id, { checking: false }); } @@ -57,6 +60,20 @@ export function clearServerUpdateResult(id: string) { set(id, { result: null, uiError: null }); } +export async function refreshServerUpdateState(id: string): Promise { + try { + const pending = await api.pendingUpdate(id); + const current = get(id).result; + set(id, { stateError: null, stateRevision: get(id).stateRevision + 1, + ...(pending ? { available: false, preview: null } : {}), + result: pending ? { pending } : current && "committed" in current ? current : null }); + return pending; + } catch (e) { + set(id, { stateError: asUiError(e), stateRevision: get(id).stateRevision + 1, available: false, preview: null }); + throw e; + } +} + /** * Apply an update. Without options this is the main-screen button: the pending update, only when no file needs a * decision. With options (the Settings card) the owner has already chosen what to do with each changed file and may @@ -65,7 +82,8 @@ export function clearServerUpdateResult(id: string) { */ export async function applyServerUpdate(id: string, opts?: { choices: Record; source?: string }): Promise { const p = get(id).preview; - if (get(id).applying) return false; + const current = get(id); + if (current.applying || current.stateError || (current.result && "pending" in current.result)) return false; if (!opts && (!p || p.conflicts.length > 0)) return false; set(id, { applying: true, step: null, percent: 0, error: null, uiError: null, result: null }); let un: (() => void) | undefined; @@ -84,6 +102,9 @@ export async function applyServerUpdate(id: string, opts?: { choices: Record item.action === "skip") + && (preview.pending_migrations ?? (preview.migrations === 0 ? 0 : undefined)) === 0; +} diff --git a/src/lib/squidSettings.ts b/src/lib/squidSettings.ts index b7a2fd7..1f0d1b7 100644 --- a/src/lib/squidSettings.ts +++ b/src/lib/squidSettings.ts @@ -24,7 +24,7 @@ function group(key: string, curated: boolean) { /** Use the same settings screen as Companions, backed only by shipped SQUID options. */ export function squidSettingsView(items: ModuleSetting[], locale: Locale): SettingsView { const settings: SettingView[] = items.filter(s => s.key !== "AiPlayerbot.Enabled" && !s.key.startsWith("PlayerbotsDatabase") && !s.key.startsWith("Playerbots.Updates.")).map(s => { - const field = moduleField(s.key, locale); + const field = moduleField(s.key, locale, s.field); const type = field?.type === "bool" || field?.type === "int" || field?.type === "float" || field?.type === "enum" ? field.type : "string"; const decode = (raw: string): JsonValue => { const value = raw.trim().replace(/^"|"$/g, ""); @@ -35,11 +35,13 @@ export function squidSettingsView(items: ModuleSetting[], locale: Locale): Setti const value = decode(s.value), defaultValue = decode(s.default ?? s.value); return { key: s.key, type, title: field?.title ?? s.key, description: field?.description ?? s.doc, - category: group(s.key, !!field), advanced: !field || !!field.advanced, + category: s.field?.group ? "squid." + s.field.group : group(s.key, !!field), advanced: !field || !!field.advanced, min: field?.min, max: field?.max, options: field?.options ?? [], value, default: defaultValue, is_default: value === defaultValue, present: true, problem: null, drift: false, restartRequired: "world", dangerous: false, }; }); - return { scope: "bots", categories: groups.map(g => ({ id: g.id, title: g.titles[languages.indexOf(locale)] })), settings, unknown_keys: 0, drift_keys: [], files: ["Core/configs/modules/playerbots.conf"] }; + const categories = new Map(groups.map(g => [g.id, g.titles[languages.indexOf(locale)]])); + for (const item of items) if (item.field?.group) categories.set("squid." + item.field.group, categories.get("squid." + item.field.group) ?? item.field.group_title ?? item.field.group); + return { scope: "bots", categories: [...categories].map(([id, title]) => ({ id, title })), settings, unknown_keys: 0, drift_keys: [], files: ["Core/configs/modules/playerbots.conf"] }; } diff --git a/src/screens/FriendsPage.tsx b/src/screens/FriendsPage.tsx index e180a6e..a7d3059 100644 --- a/src/screens/FriendsPage.tsx +++ b/src/screens/FriendsPage.tsx @@ -5,6 +5,15 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { useHuman, useT, type Key } from "@/i18n"; +/** Convenience only; Rust validates again before applying or saving. */ +function validLanIpv4(value: string): boolean { + if (!/^(0|[1-9]\d{0,2})(\.(0|[1-9]\d{0,2})){3}$/.test(value)) return false; + const octets = value.split(".").map(Number); + const [a, b] = octets; + return octets.every((n) => n <= 255) && a > 0 && a < 224 && a !== 127 + && !(a === 169 && b === 254) && !(a === 100 && b >= 64 && b <= 127); +} + function Line({ ok, children, warn }: { ok: boolean; warn?: boolean; children: React.ReactNode }) { const t = useT(); return ( @@ -81,11 +90,28 @@ export function FriendsPage({ serverId }: { serverId: string }) { const [note, setNote] = useState(null); const [copied, setCopied] = useState(false); const [needsRestart, setNeedsRestart] = useState(false); + const [lanChoice, setLanChoice] = useState("automatic"); + const [customLan, setCustomLan] = useState(""); - const refresh = useCallback(() => api.friendsStatus(serverId).then(setSt).catch((e) => setError(asUiError(e))), [serverId]); + const refresh = useCallback(() => api.friendsStatus(serverId).then((status) => { + setSt(status); + }).catch((e) => setError(asUiError(e))), [serverId]); useEffect(() => { - void refresh(); - }, [refresh]); + let cancelled = false; + setSt(null); + setNet(null); + void api.friendsStatus(serverId).then((status) => { + if (cancelled) return; + const saved = status.settings.lan_address_override; + setCustomLan(saved ?? ""); + setLanChoice(saved === null ? "automatic" : status.lan_addresses.some((a) => a.address === saved) ? saved : "custom"); + setSt(status); + }).catch((e) => { if (!cancelled) setError(asUiError(e)); }); + return () => { cancelled = true; }; + }, [serverId]); + + const lanOverride = lanChoice === "automatic" ? null : lanChoice === "custom" ? customLan.trim() : lanChoice; + const lanValid = lanOverride === null || validLanIpv4(lanOverride); async function run(label: string, fn: () => Promise) { setBusy(label); @@ -109,7 +135,12 @@ export function FriendsPage({ serverId }: { serverId: string }) { const enable = (mode: FriendsMode) => run(`enable-${mode}`, async () => { - const r = await api.friendsEnable(serverId, mode, net?.public_ip ?? undefined, mode === "direct" && !!net?.router_found); + const r = await api.friendsEnable( + serverId, mode, + mode === "direct" ? net?.public_ip ?? undefined : undefined, + mode === "direct" && !!net?.router_found, + mode === "lan" ? lanOverride : null, + ); setNeedsRestart(r.restart_required); return [r.restart_required ? t("fr.restartToApply") : null, r.note ? (r.note.startsWith("Your router was asked") ? t("fr.note.forwarded") : r.note.startsWith("Your router does not support") ? t("fr.note.manual") : r.note) : null].filter(Boolean).join(" ") || t("fr.ready"); }); @@ -119,6 +150,8 @@ export function FriendsPage({ serverId }: { serverId: string }) { const cur = st.settings; const exposed = st.exposure.filter((e) => (e.what === "database" || e.what === "server console") && e.reachable_from_network); const connection = cur.mode === "local" ? null : `set realmlist ${cur.host}`; + const selectedLan = lanOverride ?? st.lan_ip; + const lanUnavailable = lanOverride !== null && lanValid && !st.lan_addresses.some((a) => a.address === lanOverride); return (
@@ -157,8 +190,28 @@ export function FriendsPage({ serverId }: { serverId: string }) { {m.id === "lan" && ( <> -

{t("fr.yourLan")} {st.lan_ip ?? t("fr.unknown")}

- {st.lan_ip && cur.mode !== m.id && } + + + {lanChoice === "custom" && <> + + setCustomLan(e.target.value)} /> + } + {!lanValid && } + {lanUnavailable &&

{t("fr.lan.unavailable")} {t("fr.lan.unavailableText")}

} + {selectedLan && lanValid && } )} @@ -221,7 +274,7 @@ export function FriendsPage({ serverId }: { serverId: string }) { +

+ )}
diff --git a/src/screens/ReportPage.tsx b/src/screens/ReportPage.tsx index 65da14e..07fe1ab 100644 --- a/src/screens/ReportPage.tsx +++ b/src/screens/ReportPage.tsx @@ -1,22 +1,36 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { Loader2 } from "lucide-react"; -import { api, asUiError, type ReportContext, type UiError } from "@/lib/api"; +import { api, asUiError, type ReportContext, type ReportTarget, type ReportTargetId, type UiError } from "@/lib/api"; import { MAX_LINK, issueUrl, reportBody } from "@/lib/report"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { useHuman, useT } from "@/i18n"; +import { useHuman, useT, type Key } from "@/i18n"; const field = "mt-1 block w-full rounded-md border border-line bg-bg px-3 py-2.5 text-[15px] outline-none focus:border-gold"; -/** A problem report that becomes a prefilled GitHub issue. Nothing is sent from here: the person reviews and submits it on GitHub. */ +const TARGET_LABEL: Record = { + manager: "rep.target.manager", + companions: "rep.target.companions", + squid: "rep.target.squid", +}; + +const FALLBACK: ReportTarget = { id: "manager", repo: "Corfirean/coa-server-manager", ours: true }; + +/** + * A problem report that becomes a prefilled GitHub issue in the repository of whatever it is about. One button collects + * the diagnostics (and shows the file in its folder) and opens the issue; the person reviews it and submits it on GitHub. + */ export function ReportPage({ serverId }: { serverId: string }) { const t = useT(); const human = useHuman(); const [ctx, setCtx] = useState(null); + const [targets, setTargets] = useState([]); + const [targetId, setTargetId] = useState(null); const [title, setTitle] = useState(""); const [what, setWhat] = useState(""); const [expected, setExpected] = useState(""); const [steps, setSteps] = useState(""); + const [withDiag, setWithDiag] = useState(true); const [file, setFile] = useState(null); const [busy, setBusy] = useState(false); const [note, setNote] = useState(null); @@ -25,30 +39,44 @@ export function ReportPage({ serverId }: { serverId: string }) { useEffect(() => { void api.reportContext(serverId).then(setCtx).catch(() => setCtx(null)); + void api.reportTargets().then(setTargets).catch(() => setTargets([])); }, [serverId]); - const body = useMemo( - () => - reportBody({ - managerVersion: ctx?.manager_version ?? "", - windows: ctx?.windows ?? "", - installKind: ctx?.install_kind ?? "new", - serverVersion: ctx?.server_version ?? null, - what, - expected, - steps, - diagnostics: file !== null, - }), - [ctx, what, expected, steps, file], - ); - const issueTitle = `[Bug] ${title.trim()}`; + // start on the bot system that is switched on, until the person picks one + const chosen: ReportTargetId = targetId ?? ctx?.suggested_target ?? "manager"; + const shown = targets.length ? targets : [FALLBACK]; + const target = shown.find((x) => x.id === chosen) ?? shown[0]; + + function bodyFor(hasFile: boolean): string { + return reportBody({ + managerVersion: ctx?.manager_version ?? "", + windows: ctx?.windows ?? "", + installKind: ctx?.install_kind ?? "new", + serverVersion: ctx?.server_version ?? null, + target: target.id, + botsVersion: ctx?.bots_version ?? null, + what, + expected, + steps, + diagnostics: hasFile, + }); + } + const issueTitle = target.ours ? `[Bug] ${title.trim()}` : title.trim(); const ready = title.trim().length > 0 && what.trim().length > 0; - async function makeFile() { + /** Collects the diagnostics once; the Manager shows the file in its folder. */ + async function collect(): Promise { + if (file) return file; + const path = await api.exportDiagnostics(serverId); + setFile(path); + return path; + } + + async function collectOnly() { setBusy(true); setError(null); try { - setFile(await api.exportDiagnostics(serverId)); + await collect(); } catch (e) { setError(asUiError(e)); } finally { @@ -56,26 +84,32 @@ export function ReportPage({ serverId }: { serverId: string }) { } } + /** One button: collect the diagnostics (if asked), then open the issue with everything filled in. */ async function openIssue() { + setBusy(true); setError(null); setNote(null); try { - const full = issueUrl(issueTitle, body); + const path = withDiag ? await collect() : null; + const body = bodyFor(path !== null); + const full = issueUrl(target, issueTitle, body); if (full.length <= MAX_LINK) { await api.openLink(full); setNote(t("rep.opened")); } else { await navigator.clipboard.writeText(body); - await api.openLink(issueUrl(issueTitle, "Paste the report text here (it was copied to the clipboard by CoA Server Manager).")); + await api.openLink(issueUrl(target, issueTitle, "Paste the report text here (it was copied to the clipboard by CoA Server Manager).")); setNote(t("rep.openedLong")); } } catch (e) { setError(asUiError(e)); + } finally { + setBusy(false); } } async function copy() { - await navigator.clipboard.writeText(`${issueTitle}\n\n${body}`); + await navigator.clipboard.writeText(`${issueTitle}\n\n${bodyFor(file !== null)}`); setNote(t("rep.copied")); } @@ -85,6 +119,22 @@ export function ReportPage({ serverId }: { serverId: string }) {

{t("q.report")}

{t("rep.intro")}

+ +

{t("rep.targetTitle")}

+
+ {shown.map((x) => ( + + ))} +
+

{t("rep.targetPublic", { repo: target.repo })}

+
+

{t("rep.autoTitle")}

@@ -100,6 +150,12 @@ export function ReportPage({ serverId }: { serverId: string }) {
{ctx.server_version}
)} + {target.id !== "manager" && ctx?.bots_version && ( + <> +
{t("rep.botsVersion")}
+
{ctx.bots_version}
+ + )}
@@ -125,16 +181,20 @@ export function ReportPage({ serverId }: { serverId: string }) {

{t("rep.attachTitle")}

{t("rep.attachText")}

- + {file &&

{t("rep.fileSaved", { path: file })}

}

{t("rep.privacy")}

- + +
); } diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx index 9d09611..1f5574f 100644 --- a/src/screens/SettingsHome.tsx +++ b/src/screens/SettingsHome.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { ChevronDown, ChevronRight, Loader2 } from "lucide-react"; -import { applyServerUpdate, clearServerUpdateResult, useServerUpdate } from "@/lib/serverUpdate"; +import { applyServerUpdate, clearServerUpdateResult, refreshServerUpdateState, useServerUpdate } from "@/lib/serverUpdate"; +import { isUpdateCurrent } from "@/lib/serverUpdateStatus"; import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -23,12 +24,14 @@ export function SettingsHome({ serverId }: { serverId: string }) { const human = useHuman(); const [preview, setPreview] = useState(null); const [pending, setPending] = useState(null); + const [pendingChecked, setPendingChecked] = useState(false); + const [pendingStateError, setPendingStateError] = useState(null); const [localBusy, setBusy] = useState<"check" | "rollback" | "retry" | null>(null); // an update keeps running in the background when this tab is left, so its state is the shared one const busy = upd.applying ? "update" : localBusy; const progress = upd.applying ? { step: upd.step ?? "Starting", percent: upd.percent } : null; const [localError, setError] = useState(null); - const error = localError ?? upd.uiError; + const error = pendingStateError ?? localError ?? upd.uiError; const [localDone, setDone] = useState(null); const done = upd.result && "committed" in upd.result ? t("upd.updatedTo", { v: upd.result.committed }) : localDone; const [choices, setChoices] = useState>({}); @@ -36,16 +39,36 @@ export function SettingsHome({ serverId }: { serverId: string }) { const [source, setSource] = useState(""); useEffect(() => { - void api.pendingUpdate(serverId).then(setPending).catch(() => {}); + let active = true; + setPendingChecked(false); + setPending(null); + setPreview(null); + setPendingStateError(null); + void api.pendingUpdate(serverId).then((txn) => { + if (active) { setPending(txn); setPendingChecked(true); } + }).catch((e) => { if (active) setPendingStateError(asUiError(e)); }); + return () => { active = false; }; }, [serverId]); - // the update finished (possibly while another tab was open): drop the stale preview, show what is left to decide + // Every finished attempt refreshes the disk state, including failures in the background. useEffect(() => { - if (!upd.result) return; + if (!upd.stateRevision) return; setPreview(null); - if ("pending" in upd.result) setPending(upd.result.pending); - else void api.pendingUpdate(serverId).then(setPending).catch(() => {}); - }, [upd.result, serverId]); + setPendingChecked(!upd.stateError); + setPendingStateError(upd.stateError); + if (!upd.stateError) setPending(upd.result && "pending" in upd.result ? upd.result.pending : null); + }, [upd.stateRevision, serverId]); + + async function refreshPending() { + setPendingChecked(false); + try { + const txn = await refreshServerUpdateState(serverId); + setPending(txn); + setPendingStateError(null); + setPendingChecked(true); + return txn; + } catch (e) { setPendingStateError(asUiError(e)); throw e; } + } async function check() { setBusy("check"); @@ -54,6 +77,8 @@ export function SettingsHome({ serverId }: { serverId: string }) { clearServerUpdateResult(serverId); setPreview(null); try { + const txn = await refreshPending(); + if (txn) return; const p = await api.checkUpdate(serverId, source.trim() || undefined); setPreview(p); setChoices(Object.fromEntries(p.conflicts.map((c) => [c, "keep" as const]))); @@ -65,6 +90,7 @@ export function SettingsHome({ serverId }: { serverId: string }) { } async function update() { + if (!pendingChecked || pendingStateError || pending) return; setError(null); setDone(null); await applyServerUpdate(serverId, { choices, source: source.trim() || undefined }); @@ -81,6 +107,7 @@ export function SettingsHome({ serverId }: { serverId: string }) { } catch (e) { setError(asUiError(e)); } finally { + try { await refreshPending(); } catch { /* Keep the disk-state error visible. */ } setBusy(null); } } @@ -97,7 +124,10 @@ export function SettingsHome({ serverId }: { serverId: string }) { clearServerUpdateResult(serverId); } else setPending(result); } catch (e) { setError(asUiError(e)); } - finally { setBusy(null); } + finally { + try { await refreshPending(); } catch { /* Keep the disk-state error visible. */ } + setBusy(null); + } } async function browse() { @@ -165,28 +195,27 @@ export function SettingsHome({ serverId }: { serverId: string }) {
)} - {done &&

{done}

} + {done && pendingChecked && !pendingStateError &&

{done}

} {error && (

{error.human.code === "unknown" ? error.technical : human(error.human).message}

)} - {preview && preview.from_version === preview.to_version && preview.items.every((i) => i.action === "skip") && ( - // The same version with no file to change: the database scripts of this package are all in the ledger already. + {preview && isUpdateCurrent(preview) && (

{t("upd.current", { v: preview.to_version })}

)} - {preview && !(preview.from_version === preview.to_version && preview.items.every((i) => i.action === "skip")) && ( + {preview && !isUpdateCurrent(preview) && (

{preview.from_version ? t("upd.availableFrom", { to: preview.to_version, from: preview.from_version }) : t("upd.available", { to: preview.to_version })}

- {preview.migrations > 0 - ? t("upd.summaryDb", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length, db: preview.migrations }) + {(preview.pending_migrations ?? preview.migrations) > 0 + ? t("upd.summaryDb", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length, db: preview.pending_migrations ?? preview.migrations }) : t("upd.summary", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length })}

    @@ -211,7 +240,7 @@ export function SettingsHome({ serverId }: { serverId: string }) { ))}
- diff --git a/src/screens/SettingsPage.tsx b/src/screens/SettingsPage.tsx index 29bce36..6d03c6e 100644 --- a/src/screens/SettingsPage.tsx +++ b/src/screens/SettingsPage.tsx @@ -178,12 +178,15 @@ export function SettingsPage(props: { serverId: string; scope: Scope; botModule? const [raw, setRaw] = useState(null); const [rawEdits, setRawEdits] = useState>({}); const [onlyChanged, setOnlyChanged] = useState(false); + const [squidVersion, setSquidVersion] = useState(null); const [cols, setCols] = useState([]); const [colEdits, setColEdits] = useState>({}); const load = useCallback(async () => { try { - const v = squid ? squidSettingsView(await api.moduleSettings(serverId, "playerbots"), locale) : await api.settings(serverId, scope); + const data = squid ? await Promise.all([api.moduleSettings(serverId, "playerbots"), api.modulesList(serverId)]) : null; + if (data) setSquidVersion(data[1].find(module => module.id === "playerbots")?.version ?? null); + const v = data ? squidSettingsView(data[0], locale) : await api.settings(serverId, scope); setView(v); setDraft({}); setErrors({}); @@ -363,7 +366,7 @@ export function SettingsPage(props: { serverId: string; scope: Scope; botModule? return (

{props.title}

-

{squid ? "SQUID’s Playerbots" : props.question}

+

{squid ? `SQUID’s Playerbots${squidVersion ? " · " + squidVersion : ""}` : props.question}

{scope === "bots" && !squid && } diff --git a/src/screens/Shell.tsx b/src/screens/Shell.tsx index 374cfd0..8d2bcff 100644 --- a/src/screens/Shell.tsx +++ b/src/screens/Shell.tsx @@ -129,7 +129,7 @@ export function Shell(props: {
{page === "overview" ? ( - props.onForget(server.id)} onOpenUpdates={() => setPage("settings")} onRealmChanged={reloadModules} /> + props.onForget(server.id)} onOpenUpdates={() => setPage("settings")} onRealmChanged={reloadModules} onReport={() => setPage("report")} /> ) : page === "bots" || page === "server" ? ( ) : page === "console" ? ( diff --git a/tools/check-module-settings.mjs b/tools/check-module-settings.mjs index ea6d6d9..0194eb4 100644 --- a/tools/check-module-settings.mjs +++ b/tools/check-module-settings.mjs @@ -67,3 +67,15 @@ for (const locale of ["en", "ru", "de", "fr", "es", "zh"]) { if (view.categories.some(c => !c.title)) throw new Error("SQUID categories must be translated"); } console.log("SQUID Bots view: typed values, upstream defaults, categories and hidden managed settings verified"); + +const upstream = squidSettingsView([{ key: "AiPlayerbot.FutureOption", value: "2.5", default: "1", doc: "old description", field: {key:"AiPlayerbot.FutureOption",type:"float",title:"Future option",description:"From the release JSON",group:"new-group",group_title:"New activity",min:0,max:3} }], "ru"); +const future = upstream.settings[0]; +if (future.title !== "Future option" || future.description !== "From the release JSON" || future.type !== "float" || future.min !== 0 || future.max !== 3 || future.advanced || future.category !== "squid.new-group" || upstream.categories.find(c=>c.id===future.category)?.title !== "New activity") throw new Error("New upstream JSON options must appear without handwritten definitions"); +console.log("Upstream metadata: new fields, titles, ranges and arbitrary groups verified"); + +const localized = moduleField("AiPlayerbot.MaxRandomBots", "ru", { key:"AiPlayerbot.MaxRandomBots", type:"int", title:"Upstream English title", description:"Upstream English description", group:"population", min:10, max:1234 }); +const original = moduleField("AiPlayerbot.MaxRandomBots", "ru"); +if (localized.title !== original.title || localized.description !== original.description || localized.max !== 1234) throw new Error("Existing translations must survive upstream metadata while ranges stay current"); +const localizedGroup = squidSettingsView([{key:"AiPlayerbot.MaxRandomBots",value:"100",default:"500",doc:"",field:{key:"AiPlayerbot.MaxRandomBots",type:"int",title:"English",description:"English",group:"population",group_title:"English population"}}],"ru"); +if (localizedGroup.categories.find(c=>c.id==="squid.population")?.title !== "Население мира") throw new Error("Known groups must retain translated captions"); +console.log("Existing setting/group translations retained with upstream English fallback"); diff --git a/tools/server-update-refresh.test.mjs b/tools/server-update-refresh.test.mjs new file mode 100644 index 0000000..dde2c9d --- /dev/null +++ b/tools/server-update-refresh.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { registerHooks } from "node:module"; + +let applyFailure = false; +let stateFailure = false; +let pending = null; +const calls = []; +globalThis.__hotfixApi = { + applyUpdate: async () => { calls.push("apply"); if (applyFailure) throw new Error("SQL failure"); return { txn: { state: "committed", to_version: "2" } }; }, + pendingUpdate: async () => { calls.push("disk"); if (stateFailure) throw new Error("damaged journal"); return pending; }, + checkUpdate: async () => ({ from_version: "2", to_version: "2", items: [], conflicts: [], migrations: 1, pending_migrations: 0 }), +}; +registerHooks({ resolve(specifier, context, next) { + if (specifier === "react") return { shortCircuit: true, url: "data:text/javascript,export const useSyncExternalStore=(subscribe,getSnapshot)=>getSnapshot();" }; + if (specifier === "@/lib/api") return { shortCircuit: true, url: "data:text/javascript,export const api=globalThis.__hotfixApi;export const asUiError=e=>({human:{code:'unknown'},technical:e.message});" }; + if (specifier === "@tauri-apps/api/event") return { shortCircuit: true, url: "data:text/javascript,export const listen=async()=>()=>{};" }; + if (specifier === "./serverUpdateStatus" && context.parentURL?.endsWith("/serverUpdate.ts")) return { shortCircuit: true, url: new URL("../src/lib/serverUpdateStatus.ts", import.meta.url).href }; + return next(specifier, context); +} }); +const { applyServerUpdate, refreshServerUpdateState, checkServerUpdate, useServerUpdate } = await import("../src/lib/serverUpdate.ts"); + +test("a failed update rereads its unfinished transaction from disk", async () => { + calls.length = 0; + applyFailure = true; + pending = { id: "interrupted", state: "failed", message: "Recovery required" }; + assert.equal(await applyServerUpdate("failed-case", { choices: {} }), false); + assert.deepEqual(calls, ["apply", "disk"]); + assert.equal(await refreshServerUpdateState("failed-case"), pending); +}); +test("unreadable state blocks another apply until a successful reread", async () => { + calls.length = 0; + applyFailure = false; + stateFailure = true; + await applyServerUpdate("corrupt-case", { choices: {} }); + assert.equal(await applyServerUpdate("corrupt-case", { choices: {} }), false); + assert.deepEqual(calls, ["apply", "disk"]); + stateFailure = false; + pending = null; + assert.equal(await refreshServerUpdateState("corrupt-case"), null); + assert.equal(await applyServerUpdate("corrupt-case", { choices: {} }), true); + assert.deepEqual(calls, ["apply", "disk", "disk", "apply", "disk"]); +}); +test("refresh after recovery returns the latest disk state, then its absence", async () => { + pending = { id: "recovery", state: "failed", message: "Restore failed at database verification" }; + assert.equal(await refreshServerUpdateState("recovery-case"), pending); + pending = null; + assert.equal(await refreshServerUpdateState("recovery-case"), null); +}); + +test("a rejected recheck removes an earlier cached update offer", async () => { + const original = globalThis.__hotfixApi.checkUpdate; + try { + globalThis.__hotfixApi.checkUpdate = async () => ({ from_version: "0.261005.22", to_version: "0.261005.24", items: [], migrations: 1, pending_migrations: 1 }); + await checkServerUpdate("stale-offer"); + assert.equal(useServerUpdate("stale-offer").available, true); + globalThis.__hotfixApi.checkUpdate = async () => { throw new Error("Installed version is newer than package"); }; + await checkServerUpdate("stale-offer"); + assert.equal(useServerUpdate("stale-offer").available, false); + assert.equal(useServerUpdate("stale-offer").preview, null); + } finally { globalThis.__hotfixApi.checkUpdate = original; } +}); diff --git a/tools/server-update-status.test.mjs b/tools/server-update-status.test.mjs new file mode 100644 index 0000000..8db2ce2 --- /dev/null +++ b/tools/server-update-status.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isUpdateCurrent } from "../src/lib/serverUpdateStatus.ts"; + +const same = { from_version: "1", to_version: "1", items: [{ action: "skip" }], migrations: 10 }; + +test("same files and version cannot hide pending database repairs", () => { + assert.equal(isUpdateCurrent({ ...same, pending_migrations: 1 }), false); + assert.equal(isUpdateCurrent({ ...same, pending_migrations: 0 }), true); +}); +test("unknown history is not presented as up to date", () => { + assert.equal(isUpdateCurrent(same), false); + assert.equal(isUpdateCurrent({ ...same, migrations: 0 }), true); +}); +test("file changes and new releases remain available with no pending SQL", () => { + assert.equal(isUpdateCurrent({ ...same, pending_migrations: 0, items: [{ action: "replace" }] }), false); + assert.equal(isUpdateCurrent({ ...same, pending_migrations: 0, to_version: "2" }), false); +});