From 97c024d95c6d87fb58bb77cb1f60273afcdc21bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 14 Oct 2026 19:22:45 +0500 Subject: [PATCH] Install current stable full images and qualify clean installation --- changelog.d/20261014-current-clean-install.md | 7 ++ crates/coa-core/src/cleanbase.rs | 2 +- crates/coa-core/src/install.rs | 4 + crates/coa-core/src/pkgsource.rs | 43 ++++++++ crates/coa-core/src/release_schema.rs | 99 ++++++++++++++++++- crates/coa-release/src/main.rs | 18 ++++ docs/STABILIZATION-20261014.md | 2 + src-tauri/src/lib.rs | 11 ++- src/dev/mock.ts | 2 +- 9 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 changelog.d/20261014-current-clean-install.md diff --git a/changelog.d/20261014-current-clean-install.md b/changelog.d/20261014-current-clean-install.md new file mode 100644 index 0000000..83f0d6e --- /dev/null +++ b/changelog.d/20261014-current-clean-install.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: New servers install the current stable build directly +--- +A clean installation downloads the complete package of the selected stable release, including its prepared database. It no longer installs an old base and asks for a separate update. Missing or mismatched full packages stop installation before changing the destination. diff --git a/crates/coa-core/src/cleanbase.rs b/crates/coa-core/src/cleanbase.rs index 44cef0b..37470fb 100644 --- a/crates/coa-core/src/cleanbase.rs +++ b/crates/coa-core/src/cleanbase.rs @@ -24,7 +24,7 @@ pub struct Params<'a> { } /// Auth tables that hold player data. Everything else (permissions, build info, realmlist) is configuration. -const AUTH_PLAYER_TABLES: [&str; 11] = +pub(crate) const AUTH_PLAYER_TABLES: [&str; 11] = ["account", "account_access", "account_banned", "account_muted", "account_password_history", "ip_banned", "logs", "logs_ip_actions", "realmcharacters", "uptime", "rbac_account_permissions"]; fn copy_dir(src: &Path, dst: &Path, skip: &dyn Fn(&str) -> bool, base: &Path) -> Result { diff --git a/crates/coa-core/src/install.rs b/crates/coa-core/src/install.rs index 2d88ee2..e9e8915 100644 --- a/crates/coa-core/src/install.rs +++ b/crates/coa-core/src/install.rs @@ -201,6 +201,10 @@ fn bootstrap_database_inner(root: &Path) -> Result<()> { sql.push_str("FLUSH PRIVILEGES;\n"); db.query(&sql)?; fsx::atomic_write_json(&target, &serde_json::json!({ "rootPassword": root_pw, "appPassword": app_pw }))?; + fsx::atomic_write(&root.join("mysql/admin-client.ini"), format!( + "[client]\nuser=root\npassword={root_pw}\nhost=127.0.0.1\nport={}\nprotocol=tcp\ndefault-character-set=utf8mb4\n", + crate::layout::read_ports(root).mysql, + ).as_bytes())?; // The server console gets its own account with a random password instead of a shared default. let console_pw = Db::from_repack(root, Account::Admin)?.provision_service_account()?; // new root password db::write_console_credentials(root, &console_pw)?; diff --git a/crates/coa-core/src/pkgsource.rs b/crates/coa-core/src/pkgsource.rs index 85fac7a..8d3f669 100644 --- a/crates/coa-core/src/pkgsource.rs +++ b/crates/coa-core/src/pkgsource.rs @@ -14,6 +14,7 @@ use crate::signing; /// Where a package comes from. #[derive(Debug, Clone)] pub enum Source { + Installation(Box), /// `https://.../` base URL holding manifest.json, manifest.json.sig and the parts. Url(String), /// A folder produced by `package::build` plus its `manifest.json.sig` (offline / local installs). @@ -23,6 +24,7 @@ pub enum Source { /// Read a small file (manifest, signature) from the source. pub fn fetch_small(source: &Source, name: &str) -> Result> { match source { + Source::Installation(source) => fetch_small(source, &if matches!(name, "manifest.json" | "manifest.json.sig") { format!("install-{name}") } else { name.into() }), Source::Dir(d) => Ok(fs::read(fsx::safe_join(d, name)?).map_err(|_| Error::Invalid(format!("{name} was not found in the package folder.")))?), Source::Url(base) => { let url = format!("{}/{name}", base.trim_end_matches('/')); @@ -48,6 +50,14 @@ pub fn fetch_manifest(source: &Source, trusted_key: &str) -> Result<(Manifest, V let sig = String::from_utf8_lossy(&fetch_small(source, "manifest.json.sig")?).into_owned(); signing::verify(&bytes, &sig, trusted_key)?; let manifest = Manifest::parse(&bytes)?; + if let Source::Installation(release) = source { + let (update, _) = fetch_manifest(release, trusted_key)?; + if manifest.kind != crate::manifest::Kind::Base || update.kind != crate::manifest::Kind::Update || manifest.version != update.version + || manifest.core.commit != update.core.commit + || manifest.bots.as_ref().and_then(|value| value.commit.as_ref()) != update.bots.as_ref().and_then(|value| value.commit.as_ref()) { + return Err(Error::InvalidManifest("The full installation package does not match the selected server release.".into())); + } + } Ok((manifest, bytes)) } @@ -68,6 +78,7 @@ pub fn human_bytes(n: u64) -> String { pub fn fetch_parts(source: &Source, manifest: &Manifest, download_dir: &Path, cancel: &Cancel, report: &dyn Fn(f64, Option)) -> Result { let archive = manifest.archive.as_ref().ok_or_else(|| Error::InvalidManifest("manifest has no archive".into()))?; match source { + Source::Installation(source) => fetch_parts(source, manifest, download_dir, cancel, report), Source::Dir(d) => Ok(d.clone()), Source::Url(base) => { let all: u64 = archive.parts.iter().map(|p| p.size).sum::().max(1); @@ -90,6 +101,38 @@ pub fn fetch_parts(source: &Source, manifest: &Manifest, download_dir: &Path, ca mod tests { use super::*; + #[test] + fn clean_installation_requires_the_signed_full_image_of_the_selected_release() { + use base64::{engine::general_purpose::STANDARD, Engine}; + use ed25519_dalek::{Signer, SigningKey}; + let folder = tempfile::tempdir().unwrap(); + let signing_key = SigningKey::from_bytes(&[23; 32]); + let key = STANDARD.encode(signing_key.verifying_key().to_bytes()); + let source = Source::Installation(Box::new(Source::Dir(folder.path().into()))); + let mut manifest = serde_json::json!({"schema": 1, "kind": "update", "version": "0.261014.1", + "core": {"commit": "a".repeat(40)}, "builtAt": "test", "minManagerVersion": "0.6.16", + "files": [], "migrations": [], "archive": null}); + let write = |name: &str, value: &serde_json::Value| { + let bytes = serde_json::to_vec(value).unwrap(); + fs::write(folder.path().join(name), &bytes).unwrap(); + fs::write(folder.path().join(format!("{name}.sig")), STANDARD.encode(signing_key.sign(&bytes).to_bytes())).unwrap(); + }; + write("manifest.json", &manifest); + assert!(fetch_manifest(&source, &key).is_err()); + manifest["kind"] = "base".into(); + write("install-manifest.json", &manifest); + assert_eq!(fetch_manifest(&source, &key).unwrap().0.version, "0.261014.1"); + manifest["version"] = "0.2.0".into(); + write("install-manifest.json", &manifest); + assert!(fetch_manifest(&source, &key).is_err()); + manifest["version"] = "0.261014.1".into(); + manifest["core"]["commit"] = "b".repeat(40).into(); + write("install-manifest.json", &manifest); + assert!(fetch_manifest(&source, &key).is_err()); + fs::write(folder.path().join("install-manifest.json"), b"tampered").unwrap(); + assert!(fetch_manifest(&source, &key).unwrap_err().to_string().contains("signature")); + } + #[test] fn sizes_are_shown_in_binary_units() { assert_eq!(human_bytes(0), "0 B"); diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index a9bde6d..16696ce 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -1,5 +1,6 @@ //! Database acceptance for release packages, using a freshly extracted signed base fixture. use std::collections::HashSet; +use std::fs; use std::path::Path; use crate::{backup, fsx, install, manifest::{Kind, Migration}, migrations, package, pkgsource, release::{self, SqlFile}, schema_check, Error, Result}; @@ -90,6 +91,66 @@ pub fn qualify_upgrade(base: &Path, source: &Path, candidate: &Path, fixture: &P })) } +pub fn compose_install(base: &Path, candidate: &Path, fixture: &Path, trusted_key: &str) -> Result<()> { + qualify_upgrade(base, base, candidate, fixture, trusted_key)?; + let fixture = fsx::canonicalize_lenient(fixture)?; + let (_, bytes) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(candidate.into()), trusted_key)?; + let manifest = crate::manifest::Manifest::parse(&bytes)?; + install::with_scratch_ports(&fixture, || backup::with_database(&fixture, |db| { + let players = db.query(&format!("SELECT COUNT(*) FROM acore_characters.characters WHERE account<>{};", crate::companions::TEMPLATE_ACCOUNT))?; + if players.trim() != "0" || crate::companions::template_count(db)? != 21 { + return Err(Error::Invalid("The installation image contains unexpected player characters.".into())); + } + for table in crate::cleanbase::AUTH_PLAYER_TABLES { + if db.tables("acore_auth")?.iter().any(|name| name == table) { + db.query(&format!("DELETE FROM acore_auth.`{table}`;"))?; + } + } + if let Some(problem) = schema_check::check(db, &fixture)?.first() { + return Err(Error::Invalid(format!("Installation image schema mismatch: {}.{}", problem.table, problem.column))); + } + Ok(()) + }))?; + fs::remove_file(fixture.join("Core/configs/coa-qualification-user.conf"))?; + let path = fixture.join("Settings/repack.json"); + let mut settings: serde_json::Value = fsx::read_json(&path)?; + settings["raPassword"] = "".into(); + fsx::atomic_write_json(&path, &settings)?; + fsx::atomic_write_json(&fixture.join("Settings/install-source.json"), &serde_json::json!({ + "version": manifest.version, "core": manifest.core, "bots": manifest.bots, + "updateManifestSha256": fsx::sha256_bytes(&bytes), + }))?; + fs::remove_file(fixture.join(MARKER))?; + Ok(()) +} + +pub fn qualify_install(package: &Path, fixture: &Path, trusted_key: &str) -> Result { + use crate::update::Env; + if !cfg!(windows) || fixture.exists() + || !fixture.file_name().is_some_and(|name| name.to_string_lossy().starts_with("coa-schema-fixture-")) { + return Err(Error::Invalid("Clean installation qualification requires a new disposable Windows fixture.".into())); + } + let fixture = std::path::absolute(fixture)?; + let package = fsx::canonicalize_lenient(package)?; + let (manifest, bytes) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(package.clone()), trusted_key)?; + let registry_dir = tempfile::tempdir()?; + let registry = crate::registry::Registry::at(registry_dir.path().join("registry.json")); + let installed = install::install_base(&install::Params { + source: pkgsource::Source::Dir(package), dest: fixture.clone(), trusted_key, + registry: ®istry, cancel: crate::download::Cancel::default(), + }, &|_| {})?; + let metadata = crate::registry::metadata_dir_for(&fixture)?; + let env = QualificationEnv(crate::update::RepackEnv { root: &fixture, meta_dir: &metadata }); + env.validate()?; + env.ensure_stopped()?; + if installed.version != manifest.version || crate::update::pending_checked(&metadata)?.is_some() { + return Err(Error::Invalid("Clean installation did not finish at the packaged release.".into())); + } + Ok(serde_json::json!({ "schema": 1, "result": "passed", "version": installed.version, + "manifestSha256": fsx::sha256_bytes(&bytes), + "checks": ["clean-install", "signed-full-package", "credential-rotation", "schema-contract", "private-auth-world-startup"] })) +} + pub fn collect(core: &Path, bots: Option<&Path>, repairs: Option<&Path>) -> Result> { let mut files = release::collect_core_sql(core)?; if let Some(bots) = bots { files.extend(release::collect_bots_sql(bots)?); } @@ -221,7 +282,15 @@ pub fn validate_startup(fixture: &Path, tree: &Path, base: &Path, trusted_key: & } pub fn verify_package(dir: &Path, trusted_key: &str) -> Result<()> { - let (manifest, _) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(dir.into()), trusted_key)?; + verify_package_source(dir, &pkgsource::Source::Dir(dir.into()), trusted_key) +} + +pub fn verify_install_package(dir: &Path, trusted_key: &str) -> Result<()> { + verify_package_source(dir, &pkgsource::Source::Installation(Box::new(pkgsource::Source::Dir(dir.into()))), trusted_key) +} + +fn verify_package_source(dir: &Path, source: &pkgsource::Source, trusted_key: &str) -> Result<()> { + let (manifest, _) = pkgsource::fetch_manifest(source, trusted_key)?; if !manifest.files.iter().any(|f| f.path == schema_check::CONTRACT && f.size > 0) { return Err(Error::Invalid("The release is missing its database schema contract.".into())); } @@ -250,6 +319,34 @@ mod tests { use base64::{engine::general_purpose::STANDARD, Engine}; use ed25519_dalek::{Signer, SigningKey}; + #[test] + #[ignore = "Requires a composed disposable installation image"] + fn real_full_package_installs_the_current_version_without_an_update_transaction() { + let image = std::path::PathBuf::from(std::env::var_os("COA_INSTALL_IMAGE_DIR").expect("COA_INSTALL_IMAGE_DIR")); + let provenance: serde_json::Value = fsx::read_json(&image.join("Settings/install-source.json")).unwrap(); + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + let options = package::BuildOptions { + kind: Kind::Base, version: provenance["version"].as_str().unwrap().into(), + core_commit: provenance["core"]["commit"].as_str().map(String::from), + bots_commit: provenance["bots"]["commit"].as_str().map(String::from), + built_at: "qualification".into(), part_size: package::DEFAULT_PART_SIZE, migrations: vec![], + }; + package::build(&image, &package, &options, &|_| {}).unwrap(); + let signing_key = SigningKey::generate(&mut rand_core::OsRng); + let bytes = fs::read(package.join("manifest.json")).unwrap(); + fs::write(package.join("manifest.json.sig"), STANDARD.encode(signing_key.sign(&bytes).to_bytes())).unwrap(); + let trusted_key = STANDARD.encode(signing_key.verifying_key().to_bytes()); + let fixture = root.path().join("coa-schema-fixture-clean-install"); + let report = qualify_install(&package, &fixture, &trusted_key).unwrap(); + assert_eq!(report["version"], provenance["version"]); + assert_eq!(report["result"], "passed"); + let metadata = crate::registry::metadata_dir_for(&fixture).unwrap(); + let (_, installed) = crate::registry::MetaDir::open(&metadata).unwrap(); + assert_eq!(installed.core.version.as_deref(), provenance["version"].as_str()); + assert!(!metadata.join("updates").read_dir().is_ok_and(|mut entries| entries.next().is_some())); + } + #[test] fn relative_startup_fixture_targets_replace_the_original_location() { let directory = tempfile::Builder::new().prefix("coa-schema-fixture-path-test-").tempdir_in(".").unwrap(); diff --git a/crates/coa-release/src/main.rs b/crates/coa-release/src/main.rs index 9dddaf2..0b08488 100644 --- a/crates/coa-release/src/main.rs +++ b/crates/coa-release/src/main.rs @@ -47,6 +47,24 @@ fn run() -> Result<(), String> { let e = |x: coa_core::Error| x.to_string(); let part = a.get("part-size").and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_PART_SIZE); match cmd.as_str() { + "verify-install" => { + coa_core::release_schema::verify_install_package( + &PathBuf::from(need(&a, "dir")?), coa_core::signing::EMBEDDED_PUBLIC_KEY, + ).map_err(e)?; + } + "compose-install" => { + coa_core::release_schema::compose_install( + &PathBuf::from(need(&a, "base")?), &PathBuf::from(need(&a, "candidate")?), + &PathBuf::from(need(&a, "fixture")?), coa_core::signing::EMBEDDED_PUBLIC_KEY, + ).map_err(e)?; + } + "qualify-install" => { + let report = coa_core::release_schema::qualify_install( + &PathBuf::from(need(&a, "package")?), &PathBuf::from(need(&a, "fixture")?), + coa_core::signing::EMBEDDED_PUBLIC_KEY, + ).map_err(e)?; + coa_core::fsx::atomic_write_json(&PathBuf::from(need(&a, "report")?), &report).map_err(e)?; + } "qualify-upgrade" => { let report = coa_core::release_schema::qualify_upgrade( &PathBuf::from(need(&a, "base")?), &PathBuf::from(need(&a, "source")?), diff --git a/docs/STABILIZATION-20261014.md b/docs/STABILIZATION-20261014.md index 903c739..3c07e44 100644 --- a/docs/STABILIZATION-20261014.md +++ b/docs/STABILIZATION-20261014.md @@ -107,3 +107,5 @@ reproduced the missing alerts before the fix; all 12 frontend tests pass afterwa This batch does not publish a Manager installer, promote a server package, close user reports or claim the complete fresh-install/update/gameplay matrix. + +Clean Windows installs now select the full image attached to the same signed stable release used by updates. They never fall back to the legacy base when the current full image is absent. Explicit offline/custom full packages remain supported, while Repair and release upgrade fixtures retain the legacy base source. The release worker composes and validates the complete current database image before publication; users do not run an intermediate base server or an update transaction. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa9337a..6656434 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,6 +63,13 @@ fn package_source(custom: Option) -> Source { } } +fn installation_source(custom: Option) -> Result { + let custom = custom.filter(|value| !value.trim().is_empty()) + .or_else(|| std::env::var("COA_PACKAGE_SOURCE").ok().filter(|value| !value.trim().is_empty())); + if custom.is_some() { return Ok(package_source(custom)); } + Ok(Source::Installation(Box::new(update_source(None)?))) +} + #[derive(Serialize)] struct ServerSummary { id: String, @@ -519,7 +526,7 @@ async fn install_requirements(package: Option) -> std::result::Result, dest: String, p } let cancel = Cancel::default(); *state.install_cancel.lock().map_err(|_| Error::Invalid("state poisoned".into()))? = Some(cancel.clone()); - let source = package_source(package); let registry = Registry::at(data_dir().join("installs.json")); let dest_path = PathBuf::from(dest); let result = tauri::async_runtime::spawn_blocking(move || { + let source = installation_source(package)?; install::install_base( &install::Params { source, dest: dest_path, trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, registry: ®istry, cancel }, &|step| { diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 89b6b31..1b1159b 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -96,7 +96,7 @@ export {}; if(cmd==="set_game_data_folder") return args.path; if(cmd==="install_environment"){const docker=new URLSearchParams(location.search).get("flavor")==="docker";return docker?{flavor:"docker",default_dir:"/home/you/CoaServer",docker_problem:new URLSearchParams(location.search).has("nodocker")?"permission denied while trying to connect to the Docker daemon socket":null}:{flavor:"repack",default_dir:"C:\\Games\\CoA Server",docker_problem:null};} if(cmd==="install_preflight") return {ok:true,problems:[],free_bytes:210*2**30}; - if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.2.0"}; + if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.261014.1"}; if(cmd==="list_accounts"){const w=window as any;w.__acc=w.__acc??[{id:2,name:"ALICE",access:3,online:true,last_login:"2026-10-01 12:04:11",characters:4},{id:5,name:"BOB",access:0,online:false,last_login:null,characters:0},{id:6,name:"CAROL",access:2,online:false,last_login:"2026-09-30 21:40:02",characters:2}];return w.__acc;} if(cmd==="account_set_access"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.access=args.level;return null;} if(cmd==="account_set_password") return null;