From e17c757d1158b08e6596ff860fc4381d9e1b4fa2 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, 30 Sep 2026 08:26:11 +0500 Subject: [PATCH] Phase 5: clean install (signed packages, split tar.zst, resumable verified downloads, staging + atomic commit, DB credential rotation), RA account creation, Install and Players UI Verified: fixture packaged 2.2 GB -> 327 MB, installed in 18 s with rotated DB credentials via the native UI; hostile archives, bad signatures and interrupted downloads are rejected/resumed in tests. Co-Authored-By: Claude Sonnet 5.5 --- crates/coa-core/Cargo.toml | 2 +- crates/coa-core/examples/install_e2e.rs | 59 +++ crates/coa-core/src/install.rs | 408 +++++++++++++++++++++ crates/coa-core/src/lib.rs | 3 + crates/coa-core/src/manifest.rs | 30 +- crates/coa-core/src/package.rs | 464 ++++++++++++++++++++++++ crates/coa-core/src/ra.rs | 171 +++++++++ src-tauri/src/lib.rs | 79 +++- src/App.tsx | 31 +- src/lib/api.ts | 17 + src/screens/InstallServer.tsx | 168 +++++++++ src/screens/PlayersPage.tsx | 81 +++++ src/screens/Shell.tsx | 3 + src/screens/Welcome.tsx | 16 +- 14 files changed, 1518 insertions(+), 14 deletions(-) create mode 100644 crates/coa-core/examples/install_e2e.rs create mode 100644 crates/coa-core/src/install.rs create mode 100644 crates/coa-core/src/package.rs create mode 100644 crates/coa-core/src/ra.rs create mode 100644 src/screens/InstallServer.tsx create mode 100644 src/screens/PlayersPage.tsx diff --git a/crates/coa-core/Cargo.toml b/crates/coa-core/Cargo.toml index 7b7aec9..7bc709b 100644 --- a/crates/coa-core/Cargo.toml +++ b/crates/coa-core/Cargo.toml @@ -16,7 +16,7 @@ fs4 = "0.13" tracing = "0.1" ed25519-dalek = "2" base64 = "0.22" -zstd = "0.13" +zstd = { version = "0.13", features = ["zstdmt"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "native-tls"] } tar = "0.4" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] } diff --git a/crates/coa-core/examples/install_e2e.rs b/crates/coa-core/examples/install_e2e.rs new file mode 100644 index 0000000..ebd90ef --- /dev/null +++ b/crates/coa-core/examples/install_e2e.rs @@ -0,0 +1,59 @@ +//! Full clean-install scenario: package a DISPOSABLE fixture, sign it, install it into a new folder. +//! usage: install_e2e +use base64::{engine::general_purpose::STANDARD, Engine}; +use coa_core::db::{Account, Db}; +use coa_core::download::Cancel; +use coa_core::install::{install_base, Params, Source}; +use coa_core::package::{build, BuildOptions}; +use coa_core::registry::Registry; +use ed25519_dalek::{Signer, SigningKey}; +use std::path::PathBuf; + +fn main() { + let a: Vec = std::env::args().skip(1).collect(); + let (fixture, out, dest) = (PathBuf::from(&a[0]), PathBuf::from(&a[1]), PathBuf::from(&a[2])); + assert!(a[0].to_lowercase().contains("fixture"), "refusing: source is not a fixture"); + assert!(std::fs::symlink_metadata(fixture.join("Data")).unwrap().file_type().is_symlink(), "Data junction must be skipped by the packager"); + + let t = std::time::Instant::now(); + let m = build(&fixture, &out, &BuildOptions { version: "0.1.0".into(), core_commit: None, built_at: chrono::Utc::now().to_rfc3339(), part_size: 256 * 1024 * 1024 }, &|s| eprintln!(" pack: {s}")).expect("build"); + let arch = m.archive.as_ref().unwrap(); + println!("packaged {} files, {:.2} GB -> {} parts, {:.2} GB in {:?}", m.files.len(), arch.unpacked_size as f64 / 1e9, arch.parts.len(), arch.parts.iter().map(|p| p.size).sum::() as f64 / 1e9, t.elapsed()); + + let key_path = format!("{}/.coa-manager/signing/manifest-signing.key", std::env::var("USERPROFILE").unwrap()); + let seed: [u8; 32] = STANDARD.decode(std::fs::read_to_string(key_path).unwrap().trim()).unwrap().try_into().unwrap(); + let sig = SigningKey::from_bytes(&seed).sign(&std::fs::read(out.join("manifest.json")).unwrap()); + std::fs::write(out.join("manifest.json.sig"), STANDARD.encode(sig.to_bytes())).unwrap(); + + let reg_dir = std::env::temp_dir().join("coa-install-e2e"); + let _ = std::fs::remove_dir_all(®_dir); + let registry = Registry::at(reg_dir.join("installs.json")); + let t = std::time::Instant::now(); + let installed = install_base( + &Params { source: Source::Dir(out.clone()), dest: dest.clone(), trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, registry: ®istry, cancel: Cancel::default() }, + &|s| eprintln!(" install: {:>3}% {} {}", s.percent, s.step, s.detail.unwrap_or_default()), + ) + .expect("install"); + println!("installed {} at {} in {:?}", installed.version, installed.path, t.elapsed()); + + assert!(!dest.with_file_name("coa-installtest.installing").exists()); + assert!(!dest.join("Settings/database.bootstrap.json").exists(), "bootstrap credentials are removed"); + assert!(dest.join("Core/worldserver.exe").is_file()); + assert!(dest.with_file_name("coa-installtest.manager/install.json").is_file()); + assert_eq!(registry.list().unwrap().len(), 1); + + // The rotated credentials must differ from the packaged ones and work. + let packaged: serde_json::Value = serde_json::from_slice(&std::fs::read(fixture.join("Settings/database.json")).unwrap()).unwrap(); + let now: serde_json::Value = serde_json::from_slice(&std::fs::read(dest.join("Settings/database.json")).unwrap()).unwrap(); + assert_ne!(packaged["rootPassword"], now["rootPassword"]); + assert_ne!(packaged["appPassword"], now["appPassword"]); + coa_core::backup::with_database(&dest, |db: &Db| { + assert!(db.ping()); + assert_eq!(db.tables("acore_auth")?.len() > 10, true); + let app = Db::from_repack(&dest, Account::App)?; + assert!(app.query("SELECT 1;")? == "1", "the game account works with the new password"); + Ok(()) + }) + .expect("database after install"); + println!("OK: install verified (rotated credentials work, no leftovers)"); +} diff --git a/crates/coa-core/src/install.rs b/crates/coa-core/src/install.rs new file mode 100644 index 0000000..7409ade --- /dev/null +++ b/crates/coa-core/src/install.rs @@ -0,0 +1,408 @@ +//! Clean installation of a base package. +//! +//! Order matters: nothing appears at the destination until the package is signed-off, downloaded, verified, +//! extracted and its database credentials rotated. Everything before the final rename happens in a +//! sibling `.installing` folder that this module created and marks, so a failure or crash can only ever +//! leave that folder (safe to discard) - never a half-installed server at the destination. + +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::db::{Account, Db}; +use crate::download::{self, Cancel, Job, Progress}; +use crate::driver::{self, Verb}; +use crate::error::{Error, Result}; +use crate::fsx; +use crate::layout::{self, Classification}; +use crate::manifest::{self, Manifest}; +use crate::package::{self, BOOTSTRAP_CREDENTIALS}; +use crate::registry::{metadata_dir_for, InstallKind, InstallMeta, MetaDir, Registry}; +use crate::signing; + +const MARKER: &str = ".coa-installing"; + +#[derive(Debug, Clone, Serialize)] +pub struct Problem { + pub code: &'static str, + pub message: String, +} + +#[derive(Debug, Serialize)] +pub struct Preflight { + pub ok: bool, + pub problems: Vec, + pub free_bytes: u64, +} + +fn problem(code: &'static str, message: &str) -> Problem { + Problem { code, message: message.into() } +} + +/// Checks whether `dest` is a sensible place for a new server (spec section 32). +pub fn preflight(dest: &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 C:\\Games\\CoA Server.")); + } + if !s.is_ascii() { + problems.push(problem("non_ascii", "Choose a folder whose path uses only English letters and digits, such as C:\\Games\\CoA Server.")); + } + if s.len() > 100 { + problems.push(problem("too_long", "That folder path is too long. Choose a shorter one.")); + } + let lower = s.to_lowercase().replace('/', "\\"); + let trimmed = lower.trim_end_matches('\\'); + let protected_roots = ["c:\\windows", "c:\\program files", "c:\\program files (x86)", "c:\\programdata"]; + if trimmed.len() <= 3 || protected_roots.iter().any(|p| trimmed == *p || trimmed.starts_with(&format!("{p}\\"))) { + problems.push(problem("system_folder", "Please choose a normal folder for your games, not a system location or a drive root.")); + } + if let Some(profile) = std::env::var_os("USERPROFILE") { + let profile = profile.to_string_lossy().to_lowercase(); + let special = ["", "\\documents", "\\desktop", "\\downloads", "\\pictures", "\\music", "\\videos"]; + if special.iter().any(|sfx| trimmed == format!("{profile}{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 layout::scan(dest).map(|r| r.classification) { + Ok(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 { + let (a, b) = (fsx::canonicalize_lenient(dest), fsx::canonicalize_lenient(&existing)); + if let (Ok(a), Ok(b)) = (a, b) { + 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; + } + } + } + } + + 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 } +} + +/// Where a package comes from. +#[derive(Debug, Clone)] +pub enum Source { + /// `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` (used for offline / local installs). + Dir(PathBuf), +} + +#[derive(Debug, Clone, Serialize)] +pub struct Step { + pub step: &'static str, + pub percent: u8, + pub detail: Option, +} + +pub struct Params<'a> { + pub source: Source, + pub dest: 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, +} + +#[derive(Debug, Serialize)] +pub struct Installed { + pub id: String, + pub path: String, + pub version: String, +} + +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()); + } + s.truncate(n); + s +} + +fn fetch_small(source: &Source, name: &str) -> Result> { + match source { + 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('/')); + download::check_url(&url)?; + let t = download::HttpTransport::new()?; + let reply = download::Transport::get(&t, &url, 0).map_err(|e| Error::Invalid(format!("Could not reach the download server: {e}")))?; + if reply.status != 200 { + return Err(Error::Invalid(format!("The download server answered {} for {name}.", reply.status))); + } + let mut buf = Vec::new(); + reply.body.take(64 * 1024 * 1024).read_to_end(&mut buf)?; + Ok(buf) + } + } +} + +/// Rotate the database users' passwords away from the packaged bootstrap values and write the launcher's +/// `Settings/database.json`. MySQL is started from `root` and stopped again before returning. +fn bootstrap_database(root: &Path) -> Result<()> { + let boot = root.join(BOOTSTRAP_CREDENTIALS); + let target = root.join("Settings/database.json"); + if !boot.is_file() { + return Err(Error::Invalid("The package has no database bootstrap information.".into())); + } + fs::copy(&boot, &target)?; // launcher needs database.json to render its files at first start + + let started = driver::run(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 result = (|| -> Result<()> { + let db = Db::from_repack(root, Account::Admin)?; + let (root_pw, app_pw) = (random_hex(48), random_hex(48)); + let accounts = db.query("SELECT CONCAT(user,'@',host) FROM mysql.user WHERE user IN ('root','acore');")?; + let mut sql = String::new(); + for line in accounts.lines() { + let (user, host) = line.split_once('@').ok_or_else(|| Error::Invalid("unexpected user list".into()))?; + let pw = if user == "root" { &root_pw } else { &app_pw }; + if !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | ':' | '-')) { + return Err(Error::Invalid("unexpected database host name".into())); + } + sql.push_str(&format!("ALTER USER '{user}'@'{host}' IDENTIFIED BY '{pw}';\n")); + } + sql.push_str("FLUSH PRIVILEGES;\n"); + db.query(&sql)?; + fsx::atomic_write_json(&target, &serde_json::json!({ "rootPassword": root_pw, "appPassword": app_pw }))?; + // The new root password must actually work before we throw the old one away. + if !Db::from_repack(root, Account::Admin)?.ping() { + return Err(Error::Invalid("The new database password did not work.".into())); + } + Ok(()) + })(); + let _ = driver::run(root, Verb::StopAll); + result?; + fs::remove_file(&boot)?; + Ok(()) +} + +pub fn install_base(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); + // 1. Manifest first: it is signed, and tells us how much space we need. + let manifest_bytes = fetch_small(&p.source, "manifest.json")?; + let sig = String::from_utf8_lossy(&fetch_small(&p.source, "manifest.json.sig")?).into_owned(); + signing::verify(&manifest_bytes, &sig, p.trusted_key)?; + let m = Manifest::parse(&manifest_bytes)?; + if m.kind != manifest::Kind::Base || !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, archive.unpacked_size + download_size, p.registry); + if !pre.ok { + return Err(Error::Invalid(pre.problems.iter().map(|x| x.message.clone()).collect::>().join(" "))); + } + + // 2. Download (or use the local folder), verifying every part. + let parts_dir = match &p.source { + Source::Dir(d) => d.clone(), + Source::Url(base) => { + let dl = meta_dir.join("staging").join("download"); + let total = archive.parts.len(); + for (i, part) in archive.parts.iter().enumerate() { + let job = Job { url: format!("{}/{}", base.trim_end_matches('/'), part.name), dest: dl.join(&part.name), sha256: part.sha256.clone(), size: part.size }; + download::fetch(&job, &p.cancel, &|pr: Progress| { + let frac = (i as f64 + pr.downloaded as f64 / pr.total.max(1) as f64) / total as f64; + say("Downloading server", 5 + (frac * 45.0) as u8, Some(format!("{:.1} MB/s", pr.bytes_per_sec as f64 / 1e6))); + })?; + } + dl + } + }; + + // 3. Extract into our own staging folder. + if staging_root.exists() { + if staging_root.join(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(MARKER), b"")?; + + let result = (|| -> Result { + say("Unpacking", 50, None); + package::extract(&parts_dir, &m, &staging_root, &|done, total| say("Unpacking", 50 + (done * 30 / total.max(1)) as u8, None))?; + say("Preparing database", 82, None); + bootstrap_database(&staging_root)?; + fs::remove_file(staging_root.join(MARKER))?; + + // 4. Commit: the only moment the destination changes. + say("Finishing", 95, None); + if dest.exists() { + fs::remove_dir(&dest)?; // only succeeds for an empty folder (preflight verified) + } + fs::rename(&staging_root, &dest)?; + + let mut meta = InstallMeta::new(InstallKind::New, &dest); + meta.core.commit = m.core.commit.clone(); + meta.core.version = Some(m.version.clone()); + meta.database.schemas = vec!["acore_auth".into(), "acore_characters".into(), "acore_world".into()]; + 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(MARKER).exists() { + // Unfinished work of ours; the destination was never touched. + let _ = driver::run(&staging_root, Verb::StopAll); + let _ = fs::remove_dir_all(&staging_root); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + 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() + } + + #[test] + fn accepts_a_new_or_empty_folder() { + let d = tempfile::tempdir().unwrap(); + // temp dirs may contain non-ASCII user names; only test the rules that don't depend on that + let fresh = d.path().join("CoA Server"); + let p = preflight(&fresh, 1000, ®(d.path())); + assert!(!codes(&p).contains(&"not_empty") && !codes(&p).contains(&"already_server")); + fs::create_dir_all(&fresh).unwrap(); + let p = preflight(&fresh, 1000, ®(d.path())); + assert!(!codes(&p).contains(&"not_empty"), "an empty folder is fine"); + } + + #[test] + fn refuses_unrelated_client_and_existing_server_folders() { + let d = tempfile::tempdir().unwrap(); + let docs = d.path().join("docs"); + fs::create_dir_all(&docs).unwrap(); + fs::write(docs.join("cv.docx"), b"x").unwrap(); + assert!(codes(&preflight(&docs, 1, ®(d.path()))).contains(&"not_empty")); + + let client = d.path().join("wow"); + fs::create_dir_all(client.join("Data")).unwrap(); + fs::write(client.join("Wow.exe"), b"x").unwrap(); + assert!(codes(&preflight(&client, 1, ®(d.path()))).contains(&"client_folder")); + + let server = d.path().join("server"); + layout::testkit::fake_repack(&server); + assert!(codes(&preflight(&server, 1, ®(d.path()))).contains(&"already_server")); + } + + #[test] + fn refuses_system_folders_relative_paths_and_registered_overlaps() { + let d = tempfile::tempdir().unwrap(); + for bad in ["C:\\", "C:\\Windows\\coa", "C:\\Program Files\\coa"] { + assert!(codes(&preflight(Path::new(bad), 1, ®(d.path()))).contains(&"system_folder"), "{bad}"); + } + assert!(codes(&preflight(Path::new("relative\\dir"), 1, ®(d.path()))).contains(&"relative")); + let r = reg(d.path()); + let existing = d.path().join("existing"); + fs::create_dir_all(&existing).unwrap(); + r.register("1", &existing).unwrap(); + assert!(codes(&preflight(&existing.join("inner"), 1, &r)).contains(&"registered")); + } + + #[test] + fn refuses_when_there_is_not_enough_space() { + let d = tempfile::tempdir().unwrap(); + assert!(codes(&preflight(&d.path().join("x"), u64::MAX / 4, ®(d.path()))).contains(&"space")); + } + + #[test] + fn non_ascii_paths_are_rejected_because_the_launcher_cannot_run_from_them() { + let d = tempfile::tempdir().unwrap(); + assert!(codes(&preflight(Path::new("C:\\Игры\\CoA"), 1, ®(d.path()))).contains(&"non_ascii")); + } + + #[test] + fn random_credentials_have_the_launcher_required_shape() { + let a = random_hex(48); + assert_eq!(a.len(), 48); + assert!(a.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + assert_ne!(a, random_hex(48)); + } + + #[test] + fn an_unsigned_or_wrongly_signed_package_is_refused_before_any_download() { + use base64::Engine; + use ed25519_dalek::{Signer, SigningKey}; + let d = tempfile::tempdir().unwrap(); + let pkg = d.path().join("pkg"); + let src = d.path().join("src"); + layout::testkit::fake_repack(&src); + fs::create_dir_all(src.join("Settings")).unwrap(); + fs::write(src.join("Settings/database.json"), br#"{"rootPassword":"a","appPassword":"b"}"#).unwrap(); + let opts = package::BuildOptions { version: "0.1.0".into(), core_commit: None, built_at: "x".into(), part_size: 1 << 20 }; + package::build(&src, &pkg, &opts, &|_| {}).unwrap(); + let good = SigningKey::generate(&mut rand_core::OsRng); + let evil = SigningKey::generate(&mut rand_core::OsRng); + let mbytes = fs::read(pkg.join("manifest.json")).unwrap(); + let enc = |k: &SigningKey| base64::engine::general_purpose::STANDARD.encode(k.sign(&mbytes).to_bytes()); + let trusted = base64::engine::general_purpose::STANDARD.encode(good.verifying_key().to_bytes()); + let r = reg(d.path()); + let dest = d.path().join("dest"); + let run = |sig: &str| { + fs::write(pkg.join("manifest.json.sig"), sig).unwrap(); + install_base(&Params { source: Source::Dir(pkg.clone()), dest: dest.clone(), trusted_key: &trusted, registry: &r, cancel: Cancel::default() }, &|_| {}) + }; + assert!(run(&enc(&evil)).is_err(), "signed by the wrong key"); + assert!(run("").is_err(), "empty signature"); + assert!(!dest.exists() && !dest.with_file_name("dest.installing").exists(), "nothing created when the signature is bad"); + } +} diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 451c5f7..b1b01a2 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -6,10 +6,13 @@ pub mod driver; pub mod error; pub mod fsx; pub mod health; +pub mod install; pub mod layout; pub mod logging; pub mod process; pub mod manifest; +pub mod package; +pub mod ra; pub mod registry; pub mod signing; diff --git a/crates/coa-core/src/manifest.rs b/crates/coa-core/src/manifest.rs index 711e2bf..9f5fac4 100644 --- a/crates/coa-core/src/manifest.rs +++ b/crates/coa-core/src/manifest.rs @@ -55,6 +55,22 @@ pub struct Migration { pub destructive: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArchivePart { + pub name: String, + pub size: u64, + pub sha256: String, +} + +/// How a base/update payload is shipped: one zstd-compressed tar stream cut into parts. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveInfo { + pub format: String, + pub parts: Vec, + pub unpacked_size: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Revision { pub commit: Option, @@ -75,6 +91,8 @@ pub struct Manifest { pub files: Vec, #[serde(default)] pub migrations: Vec, + #[serde(default)] + pub archive: Option, } fn is_sha256(s: &str) -> bool { @@ -134,6 +152,16 @@ impl Manifest { return bad(format!("{}: duplicate path", f.path)); } } + if let Some(a) = &self.archive { + if a.format != "tar.zst" || a.parts.is_empty() { + return bad(format!("unsupported archive format {:?}", a.format)); + } + for p in &a.parts { + if !is_sha256(&p.sha256) || p.name.contains(['/', '\\']) || p.name.is_empty() || p.name.starts_with('.') { + return bad(format!("archive part {:?} is invalid", p.name)); + } + } + } let mut ids = HashSet::new(); for m in &self.migrations { if m.id.is_empty() || !is_sha256(&m.sha256) || !ids.insert((m.db.clone(), m.id.clone())) { @@ -180,7 +208,7 @@ mod tests { fn sample(files: &str) -> String { format!( r#"{{"schema":1,"kind":"update","version":"0.4.1","core":{{"commit":"{c}"}},"bots":{{"commit":null}}, - "builtAt":"2026-09-30T00:00:00Z","minManagerVersion":"0.1.0","files":[{files}],"migrations":[]}}"#, + "builtAt":"2026-09-30T00:00:00Z","minManagerVersion":"0.1.0","files":[{files}],"migrations":[],"archive":null}}"#, c = "a".repeat(40) ) } diff --git a/crates/coa-core/src/package.rs b/crates/coa-core/src/package.rs new file mode 100644 index 0000000..e4c2bf2 --- /dev/null +++ b/crates/coa-core/src/package.rs @@ -0,0 +1,464 @@ +//! Base/update packages: a zstd-compressed tar stream cut into parts below the hosting size limit. +//! +//! `build` (used by the release pipeline) produces parts + `manifest.json`; `extract` (used by the Manager) +//! unpacks verified parts into a staging folder, accepting only regular files and directories that the signed +//! manifest lists, with matching size and SHA-256. + +use std::collections::HashMap; +use std::fs::{self, File}; +use std::io::{self, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::error::{Error, Result}; +use crate::fsx; +use crate::manifest::{ArchiveInfo, ArchivePart, FileEntry, Kind, Manifest, Owner, ReplacePolicy, Revision}; + +/// GitHub release assets are limited to 2 GiB; stay clearly below. +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"; + +/// Files that never belong in a shipped package: per-install state, logs, secrets, old binaries. +pub fn excluded(rel: &str) -> bool { + let l = rel.to_lowercase(); + let name = l.rsplit('/').next().unwrap_or(""); + l.starts_with(".state/") + || l.starts_with("core/logs/") + || l.starts_with("core/crashes/") + || l.starts_with("bugreport/logs/") + || l.starts_with("bugreport/reports/") + || l.starts_with("mysql/logs/") + || l.starts_with("source/") + || l.starts_with("testing/") + || l.starts_with("core_backup") + || l.starts_with("scripts/__pycache__/") + || l == "settings/database.json" + || l == "mysql/admin-client.ini" + || 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 + || name.ends_with(".pdb") + || name.ends_with(".bak") + || name.ends_with(".log") + || name.contains(".pre-") + || name.contains(".bak-") + || name.ends_with(".orig") + || name.contains(".prev_") + || name.contains(".manager") +} + +fn policy_for(rel: &str) -> (Owner, ReplacePolicy) { + let l = rel.to_lowercase(); + if l.starts_with("mysql/data/") { + (Owner::User, ReplacePolicy::NeverTouch) + } else if l.starts_with("settings/") { + (Owner::Core, ReplacePolicy::MergeConfig) + } else if l.contains("/configs/") { + (Owner::Core, ReplacePolicy::CreateIfMissing) + } else { + (Owner::Core, ReplacePolicy::Replace) + } +} + +struct SplitWriter { + dir: PathBuf, + max: u64, + parts: Vec, + cur: Option<(BufWriter, Sha256, u64, String)>, +} + +impl SplitWriter { + fn new(dir: &Path, max: u64) -> Self { + SplitWriter { dir: dir.to_path_buf(), max, parts: Vec::new(), cur: None } + } + + fn open_next(&mut self) -> io::Result<()> { + let name = format!("base.tar.zst.{:03}", self.parts.len() + 1); + let f = File::create(self.dir.join(&name))?; + self.cur = Some((BufWriter::new(f), Sha256::new(), 0, name)); + Ok(()) + } + + fn close_current(&mut self) -> io::Result<()> { + if let Some((mut w, h, n, name)) = self.cur.take() { + w.flush()?; + self.parts.push(ArchivePart { name, size: n, sha256: hex::encode(h.finalize()) }); + } + Ok(()) + } +} + +impl Write for SplitWriter { + fn write(&mut self, mut buf: &[u8]) -> io::Result { + let total = buf.len(); + while !buf.is_empty() { + if self.cur.is_none() { + self.open_next()?; + } + let (w, h, n, _) = self.cur.as_mut().unwrap(); + let room = (self.max - *n) as usize; + let take = room.min(buf.len()); + w.write_all(&buf[..take])?; + h.update(&buf[..take]); + *n += take as u64; + buf = &buf[take..]; + if *n >= self.max { + self.close_current()?; + } + } + Ok(total) + } + + fn flush(&mut self) -> io::Result<()> { + match self.cur.as_mut() { + Some((w, ..)) => w.flush(), + None => Ok(()), + } + } +} + +fn walk(root: &Path, dir: &Path, out: &mut Vec) -> io::Result<()> { + for e in fs::read_dir(dir)? { + let e = e?; + let ft = e.file_type()?; + let rel = e.path().strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"); + if ft.is_symlink() { + continue; // junctions / symlinks (e.g. a fixture's Data link) are never packaged + } + if ft.is_dir() { + walk(root, &e.path(), out)?; + } else if ft.is_file() && !excluded(&rel) { + out.push(rel); + } + } + Ok(()) +} + +pub struct BuildOptions { + pub version: String, + pub core_commit: Option, + pub built_at: String, + pub part_size: u64, +} + +/// Package `src` into `out` (parts + manifest.json). Returns the manifest. +pub fn build(src: &Path, out: &Path, opts: &BuildOptions, progress: &dyn Fn(&str)) -> Result { + fs::create_dir_all(out)?; + let mut rels = Vec::new(); + walk(src, src, &mut rels)?; + // (name inside the archive, file on disk) + let mut files: Vec<(String, PathBuf)> = rels.into_iter().map(|r| (r.clone(), src.join(&r))).collect(); + // The database users of the packaged data directory: needed once at install to rotate the passwords. + let creds = src.join("Settings/database.json"); + if creds.is_file() { + files.push((BOOTSTRAP_CREDENTIALS.to_string(), creds)); + } + files.sort(); + + let split = SplitWriter::new(out, opts.part_size); + let mut enc = zstd::Encoder::new(split, 6)?; + enc.multithread(std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(2))?; + let mut tar = tar::Builder::new(enc); + tar.mode(tar::HeaderMode::Deterministic); + + let mut entries = Vec::new(); + let mut unpacked = 0u64; + for (i, (rel, path)) in files.iter().enumerate() { + if i % 500 == 0 { + progress(&format!("Packing {} / {}", i, files.len())); + } + fsx::safe_join(src, rel)?; + let mut f = File::open(path)?; + let size = f.metadata()?.len(); + let mut header = tar::Header::new_gnu(); + header.set_size(size); + header.set_mode(0o755); + header.set_mtime(0); + header.set_entry_type(tar::EntryType::Regular); + // hash while streaming into the archive + struct Tee<'a, R: Read> { + inner: R, + hasher: &'a mut Sha256, + } + impl Read for Tee<'_, R> { + fn read(&mut self, b: &mut [u8]) -> io::Result { + let n = self.inner.read(b)?; + self.hasher.update(&b[..n]); + Ok(n) + } + } + let mut hasher = Sha256::new(); + tar.append_data(&mut header, rel, Tee { inner: &mut f, hasher: &mut hasher })?; + let (owner, policy) = policy_for(rel); + entries.push(FileEntry { path: rel.clone(), sha256: hex::encode(hasher.finalize()), size, owner, policy }); + unpacked += size; + } + progress("Finishing archive"); + let enc = tar.into_inner()?; + let mut split = enc.finish()?; + split.close_current()?; + + let manifest = Manifest { + schema: crate::manifest::SCHEMA, + kind: Kind::Base, + version: opts.version.clone(), + core: Revision { commit: opts.core_commit.clone() }, + bots: None, + built_at: opts.built_at.clone(), + min_manager_version: "0.1.0".into(), + files: entries, + migrations: Vec::new(), + archive: Some(ArchiveInfo { format: "tar.zst".into(), parts: split.parts.clone(), unpacked_size: unpacked }), + }; + manifest.validate()?; + fsx::atomic_write(&out.join("manifest.json"), &serde_json::to_vec_pretty(&manifest)?)?; + Ok(manifest) +} + +struct PartsReader { + paths: Vec, + idx: usize, + cur: Option, +} + +impl Read for PartsReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + loop { + if self.cur.is_none() { + match self.paths.get(self.idx) { + Some(p) => { + self.cur = Some(File::open(p)?); + self.idx += 1; + } + None => return Ok(0), + } + } + let n = self.cur.as_mut().unwrap().read(buf)?; + if n > 0 { + return Ok(n); + } + self.cur = None; + } + } +} + +/// Unpack verified `parts_dir` into `dest` (an empty or new staging folder). Every file must be listed in the +/// manifest with the recorded size and hash; anything else aborts the extraction. +pub fn extract(parts_dir: &Path, manifest: &Manifest, dest: &Path, progress: &dyn Fn(u64, u64)) -> Result<()> { + let archive = manifest.archive.as_ref().ok_or_else(|| Error::InvalidManifest("manifest has no archive".into()))?; + let mut paths = Vec::new(); + for part in &archive.parts { + let p = parts_dir.join(&part.name); + let len = fs::metadata(&p).map_err(|_| Error::Invalid(format!("download part {} is missing", part.name)))?.len(); + if len != part.size { + return Err(Error::HashMismatch { path: part.name.clone(), expected: format!("{} bytes", part.size), actual: format!("{len} bytes") }); + } + let actual = fsx::sha256_file(&p)?; + if !actual.eq_ignore_ascii_case(&part.sha256) { + return Err(Error::HashMismatch { path: part.name.clone(), expected: part.sha256.clone(), actual }); + } + paths.push(p); + } + fsx::require_space(dest, archive.unpacked_size)?; + fs::create_dir_all(dest)?; + + let expected: HashMap = manifest.files.iter().map(|f| (f.path.replace('\\', "/").to_lowercase(), f)).collect(); + let decoder = zstd::Decoder::new(PartsReader { paths, idx: 0, cur: None })?; + let mut tar = tar::Archive::new(decoder); + let mut seen = std::collections::HashSet::new(); + let mut done = 0u64; + for entry in tar.entries()? { + let mut entry = entry?; + let kind = entry.header().entry_type(); + let rel = entry.path()?.to_string_lossy().replace('\\', "/"); + match kind { + tar::EntryType::Directory => continue, + tar::EntryType::Regular | tar::EntryType::GNULongName | tar::EntryType::Continuous => {} + other => return Err(Error::PathRejected(format!("{rel}: unsupported archive entry type {other:?}"))), + } + let key = rel.to_lowercase(); + let want = expected.get(&key).ok_or_else(|| Error::PathRejected(format!("{rel}: not listed in the manifest")))?; + if !seen.insert(key) { + return Err(Error::PathRejected(format!("{rel}: appears twice in the archive"))); + } + let target = fsx::safe_join(dest, &want.path)?; + fs::create_dir_all(target.parent().unwrap())?; + let mut out = BufWriter::new(File::create(&target)?); + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 1 << 20]; + let mut size = 0u64; + loop { + let n = entry.read(&mut buf)?; + if n == 0 { + break; + } + size += n as u64; + if size > want.size { + return Err(Error::HashMismatch { path: rel, expected: format!("{} bytes", want.size), actual: "more".into() }); + } + hasher.update(&buf[..n]); + out.write_all(&buf[..n])?; + } + out.flush()?; + let actual = hex::encode(hasher.finalize()); + if size != want.size || !actual.eq_ignore_ascii_case(&want.sha256) { + return Err(Error::HashMismatch { path: rel, expected: want.sha256.clone(), actual }); + } + done += size; + progress(done, archive.unpacked_size); + } + if seen.len() != expected.len() { + return Err(Error::InvalidManifest(format!("archive holds {} files, manifest lists {}", seen.len(), expected.len()))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(part: u64) -> BuildOptions { + BuildOptions { version: "0.1.0".into(), core_commit: Some("a".repeat(40)), built_at: "2026-09-30T00:00:00Z".into(), part_size: part } + } + + fn tree(root: &Path) -> Vec<(String, Vec)> { + let mut v = Vec::new(); + fn walk(dir: &Path, root: &Path, v: &mut Vec<(String, Vec)>) { + for e in fs::read_dir(dir).unwrap() { + let e = e.unwrap(); + if e.file_type().unwrap().is_dir() { + walk(&e.path(), root, v); + } else { + v.push((e.path().strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"), fs::read(e.path()).unwrap())); + } + } + } + walk(root, root, &mut v); + v.sort(); + v + } + + fn source() -> (tempfile::TempDir, PathBuf) { + let d = tempfile::tempdir().unwrap(); + let src = d.path().join("src"); + crate::layout::testkit::fake_repack(&src); + // noise that must be left out + for (p, c) in [("Core/Logs/Server.log", "log"), (".state/world.json", "{}"), ("Settings/database.json", "{\"rootPassword\":\"x\"}"), ("Core/worldserver.exe.pre-fix", "old"), ("Core/worldserver.pdb", "pdb")] { + let f = src.join(p); + fs::create_dir_all(f.parent().unwrap()).unwrap(); + fs::write(f, c).unwrap(); + } + // pseudo-random payload so compression yields several parts + let mut x = 0x2545F4914F6CDD1Du64; + let big: Vec = (0..300_000) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x >> 24) as u8 + }) + .collect(); + fs::write(src.join("Data/dbc/big.dbc"), big).unwrap(); + (d, src) + } + + #[test] + fn build_split_extract_roundtrip_excludes_noise_and_secrets() { + let (d, src) = source(); + let out = d.path().join("out"); + let m = build(&src, &out, &opts(64 * 1024), &|_| {}).unwrap(); + let parts = &m.archive.as_ref().unwrap().parts; + assert!(parts.len() >= 3, "small part size forces a split, got {}", parts.len()); + assert!(parts.iter().all(|p| p.size <= 64 * 1024)); + let listed: Vec<&str> = m.files.iter().map(|f| f.path.as_str()).collect(); + for bad in ["Core/Logs/Server.log", ".state/world.json", "Settings/database.json", "Core/worldserver.pdb"] { + assert!(!listed.contains(&bad), "{bad} must not be packaged"); + } + assert!(listed.contains(&"Core/worldserver.exe")); + assert!(listed.contains(&BOOTSTRAP_CREDENTIALS) || !src.join("Settings/database.json").exists()); + + let dest = d.path().join("dest"); + extract(&out, &m, &dest, &|_, _| {}).unwrap(); + let mut expected = tree(&src); + expected.retain(|(p, _)| listed.contains(&p.as_str())); + // the source's database.json travels under its bootstrap name + expected.push((BOOTSTRAP_CREDENTIALS.to_string(), fs::read(src.join("Settings/database.json")).unwrap())); + expected.sort(); + assert_eq!(tree(&dest), expected); + // user data policy + assert!(m.files.iter().filter(|f| f.path.starts_with("mysql/data/")).all(|f| f.policy == ReplacePolicy::NeverTouch)); + } + + #[test] + fn a_damaged_part_is_rejected_before_anything_is_written() { + let (d, src) = source(); + let out = d.path().join("out"); + let m = build(&src, &out, &opts(64 * 1024), &|_| {}).unwrap(); + let p = out.join(&m.archive.as_ref().unwrap().parts[1].name); + let mut b = fs::read(&p).unwrap(); + b[100] ^= 0x55; + fs::write(&p, b).unwrap(); + let dest = d.path().join("dest"); + assert!(matches!(extract(&out, &m, &dest, &|_, _| {}), Err(Error::HashMismatch { .. }))); + assert!(!dest.exists(), "nothing extracted"); + } + + /// Build a hostile archive by hand and run it through `extract` with a manifest that "allows" it. + fn hostile(build_tar: impl FnOnce(&mut tar::Builder>>)) -> (tempfile::TempDir, Manifest) { + let d = tempfile::tempdir().unwrap(); + let mut tb = tar::Builder::new(zstd::Encoder::new(Vec::new(), 1).unwrap()); + build_tar(&mut tb); + let bytes = tb.into_inner().unwrap().finish().unwrap(); + let name = "base.tar.zst.001"; + fs::write(d.path().join(name), &bytes).unwrap(); + let ok_sha = fsx::sha256_bytes(b"data"); + let m = Manifest { + schema: 1, + kind: Kind::Base, + version: "0.1.0".into(), + core: Revision { commit: None }, + bots: None, + built_at: "x".into(), + min_manager_version: "0.1.0".into(), + files: vec![FileEntry { path: "ok.txt".into(), sha256: ok_sha, size: 4, owner: Owner::Core, policy: ReplacePolicy::Replace }], + migrations: vec![], + archive: Some(ArchiveInfo { format: "tar.zst".into(), parts: vec![ArchivePart { name: name.into(), size: bytes.len() as u64, sha256: fsx::sha256_bytes(&bytes) }], unpacked_size: 4 }), + }; + (d, m) + } + + fn add(tb: &mut tar::Builder>>, path: &str, data: &[u8], ty: tar::EntryType) { + let mut h = tar::Header::new_gnu(); + h.set_size(data.len() as u64); + h.set_entry_type(ty); + h.set_mode(0o644); + // write the path bytes directly so `..` survives (set_path would refuse it) + let name = &mut h.as_old_mut().name; + name[..path.len()].copy_from_slice(path.as_bytes()); + h.set_cksum(); + tb.append(&h, data).unwrap(); + } + + #[test] + fn traversal_unlisted_and_symlink_entries_are_refused() { + for (path, ty) in [("../evil.txt", tar::EntryType::Regular), ("not-listed.txt", tar::EntryType::Regular), ("ok.txt", tar::EntryType::Symlink)] { + let (d, m) = hostile(|tb| add(tb, path, b"data", ty)); + let dest = d.path().join("dest"); + assert!(extract(d.path(), &m, &dest, &|_, _| {}).is_err(), "{path} {ty:?}"); + assert!(!d.path().join("evil.txt").exists()); + } + } + + #[test] + fn content_that_does_not_match_the_manifest_is_refused() { + let (d, m) = hostile(|tb| add(tb, "ok.txt", b"DATA", tar::EntryType::Regular)); + assert!(matches!(extract(d.path(), &m, &d.path().join("dest"), &|_, _| {}), Err(Error::HashMismatch { .. }))); + let (d2, m2) = hostile(|tb| add(tb, "ok.txt", b"data", tar::EntryType::Regular)); + extract(d2.path(), &m2, &d2.path().join("dest"), &|_, _| {}).unwrap(); + assert_eq!(fs::read(d2.path().join("dest/ok.txt")).unwrap(), b"data"); + } +} diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs new file mode 100644 index 0000000..b20bbaf --- /dev/null +++ b/crates/coa-core/src/ra.rs @@ -0,0 +1,171 @@ +//! Minimal Remote Access (RA) console client for the worldserver, local only. +//! Only a fixed set of typed operations is exposed; free-form commands are not accepted here. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::path::Path; +use std::time::Duration; + +use serde::Deserialize; + +use crate::error::{Error, Result}; +use crate::fsx; +use crate::layout::read_ports; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RaSettings { + ra_username: String, + ra_password: String, +} + +#[derive(Debug)] +pub struct Ra { + stream: TcpStream, +} + +fn read_until(stream: &mut TcpStream, marker: &[u8]) -> Result { + let mut data = Vec::new(); + let mut byte = [0u8; 1]; + while !data.ends_with(marker) { + match stream.read(&mut byte) { + Ok(0) => return Err(Error::Invalid("The world console closed the connection.".into())), + Ok(_) => data.push(byte[0]), + Err(e) => return Err(Error::Invalid(format!("The world console did not answer: {e}"))), + } + if data.len() > 256 * 1024 { + return Err(Error::Invalid("The world console sent too much data.".into())); + } + } + Ok(String::from_utf8_lossy(&data).into_owned()) +} + +impl Ra { + /// Connect to the loopback RA port of the server in `root`, using the credentials from its `repack.json`. + pub fn connect(root: &Path) -> Result { + let cfg: RaSettings = fsx::read_json(&root.join("Settings/repack.json")).map_err(|_| Error::Invalid("The server console settings could not be read.".into()))?; + Self::connect_to(read_ports(root).ra, &cfg.ra_username, &cfg.ra_password) + } + + pub fn connect_to(port: u16, user: &str, password: &str) -> Result { + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)) + .map_err(|_| Error::Invalid("The world server is not running (its console is not reachable).".into()))?; + stream.set_read_timeout(Some(Duration::from_secs(15)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + read_until(&mut stream, b"Username: ")?; + stream.write_all(format!("{user}\r\n").as_bytes())?; + read_until(&mut stream, b"Password: ")?; + stream.write_all(format!("{password}\r\n").as_bytes())?; + let banner = read_until(&mut stream, b"AC>").map_err(|_| Error::Invalid("The world console rejected the login.".into()))?; + if banner.to_lowercase().contains("authentication failed") { + return Err(Error::Invalid("The world console rejected the login.".into())); + } + Ok(Ra { stream }) + } + + fn command(&mut self, cmd: &str) -> Result { + debug_assert!(!cmd.contains(['\r', '\n'])); + self.stream.write_all(format!("{cmd}\r\n").as_bytes())?; + let out = read_until(&mut self.stream, b"AC>")?; + Ok(out.trim_end_matches("AC>").trim().to_string()) + } + + pub fn create_account(&mut self, name: &str, password: &str) -> Result<()> { + validate_account(name, password)?; + let out = self.command(&format!("account create {name} {password}"))?; + let l = out.to_lowercase(); + if l.contains("already exist") { + return Err(Error::Invalid("That account name is already taken.".into())); + } + if l.contains("created") { + Ok(()) + } else { + Err(Error::Invalid(format!("The server did not create the account: {}", out.lines().last().unwrap_or("")))) + } + } + + /// Give `name` administrator rights on all realms (GM level 3). + pub fn make_administrator(&mut self, name: &str) -> Result<()> { + validate_account(name, "placeholder")?; + let out = self.command(&format!("account set gmlevel {name} 3 -1"))?; + if out.to_lowercase().contains("gm level") || out.to_lowercase().contains("security") || out.is_empty() { + Ok(()) + } else { + Err(Error::Invalid(format!("Could not set administrator rights: {out}"))) + } + } +} + +/// Account names are letters/digits (3-17); passwords are 6-16 printable characters without spaces or quotes. +pub fn validate_account(name: &str, password: &str) -> Result<()> { + let bad = |m: &str| Err(Error::Invalid(m.into())); + if !(3..=17).contains(&name.len()) || !name.chars().all(|c| c.is_ascii_alphanumeric()) { + return bad("The username must be 3–17 letters or digits."); + } + if !(6..=16).contains(&password.len()) || !password.chars().all(|c| c.is_ascii_graphic() && c != '"' && c != '\'') { + return bad("The password must be 6–16 characters (no spaces or quotes)."); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + use std::net::TcpListener; + + fn fake_ra(reply: &'static str) -> (u16, std::thread::JoinHandle>) { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let h = std::thread::spawn(move || { + let (mut s, _) = l.accept().unwrap(); + let mut r = BufReader::new(s.try_clone().unwrap()); + let mut got = Vec::new(); + s.write_all(b"Username: ").unwrap(); + let mut line = String::new(); + r.read_line(&mut line).unwrap(); + got.push(line.trim().to_string()); + s.write_all(b"Password: ").unwrap(); + line.clear(); + r.read_line(&mut line).unwrap(); + got.push(line.trim().to_string()); + s.write_all(b"Authentication Succeeded\r\nAC>").unwrap(); + line.clear(); + if r.read_line(&mut line).unwrap_or(0) > 0 { + got.push(line.trim().to_string()); + s.write_all(format!("{reply}\r\nAC>").as_bytes()).unwrap(); + } + got + }); + (port, h) + } + + #[test] + fn creates_an_account_over_ra_with_the_expected_commands() { + let (port, h) = fake_ra("Account created: PLAYER1"); + let mut ra = Ra::connect_to(port, "local", "secretra").unwrap(); + ra.create_account("Player1", "hunter22").unwrap(); + drop(ra); + assert_eq!(h.join().unwrap(), ["local", "secretra", "account create Player1 hunter22"]); + } + + #[test] + fn duplicate_account_and_input_validation() { + let (port, _h) = fake_ra("Account already exist."); + let mut ra = Ra::connect_to(port, "u", "p").unwrap(); + assert!(ra.create_account("Player1", "hunter22").unwrap_err().to_string().contains("already taken")); + for (n, p) in [("ab", "hunter22"), ("bad name", "hunter22"), ("Player1", "short"), ("Player1", "has space1"), ("Player1", "quote\"pw1"), ("Pl\r\nayer", "hunter22"), ("x".repeat(18).as_str(), "hunter22")] { + assert!(validate_account(n, p).is_err(), "{n:?} {p:?}"); + } + validate_account("Player1", "hunter22").unwrap(); + } + + #[test] + fn unreachable_console_gives_a_friendly_error() { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + drop(l); + assert!(Ra::connect_to(port, "u", "p").unwrap_err().to_string().contains("not running")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fdc1146..13dfef4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,7 +7,10 @@ use std::sync::Mutex; use coa_core::backup::{self, Kind, RecoveryPoint, Trigger, VerifyReport}; use coa_core::config::{self, Scope, SettingsView}; +use coa_core::download::Cancel; use coa_core::driver::{self, DriverOutcome, Verb}; +use coa_core::install::{self, Preflight, Source}; +use coa_core::ra::Ra; use coa_core::error::UiError; use coa_core::layout::{self, Classification, ScanReport}; use coa_core::process::{self, Observed}; @@ -22,6 +25,20 @@ struct AppState { registry: Registry, /// Installation ids with a start/stop currently running (one action at a time per server). busy: Mutex>, + /// Cancel handle of the installation currently running, if any. + install_cancel: Mutex>, +} + +/// Where official server packages are published (created by the release pipeline, Phase 6). +const DEFAULT_PACKAGE_URL: &str = "https://github.com/Corfirean/coa-server-build/releases/latest/download"; + +fn package_source(custom: Option) -> Source { + let pick = custom.filter(|s| !s.trim().is_empty()).or_else(|| std::env::var("COA_PACKAGE_SOURCE").ok()); + match pick { + Some(p) if p.to_ascii_lowercase().starts_with("http") => Source::Url(p), + Some(p) => Source::Dir(PathBuf::from(p)), + None => Source::Url(DEFAULT_PACKAGE_URL.into()), + } } #[derive(Serialize)] @@ -312,12 +329,66 @@ async fn restore_backup_database(state: State<'_, AppState>, id: String, backup_ blocking(move || backup::restore_database(&root, &meta_dir(&root)?, &backup_id, &database)).await } +#[tauri::command] +fn install_preflight(state: State<'_, AppState>, dest: String) -> Preflight { + // The real size is checked again once the signed manifest is known; assume a typical install here. + install::preflight(std::path::Path::new(&dest), 6 * 1024 * 1024 * 1024, &state.registry) +} + +#[tauri::command] +async fn install_new(app: AppHandle, state: State<'_, AppState>, dest: String, package: Option) -> std::result::Result { + 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 || { + install::install_base( + &install::Params { source, dest: dest_path, trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, registry: ®istry, cancel }, + &|step| { + let _ = app.emit("install-progress", step); + }, + ) + }) + .await; + if let Ok(mut c) = state.install_cancel.lock() { + *c = None; + } + let done = result.map_err(|e| Error::Invalid(e.to_string()))??; + Ok(summary(done.id, PathBuf::from(done.path))) +} + +#[tauri::command] +fn cancel_install(state: State<'_, AppState>) { + if let Ok(c) = state.install_cancel.lock() { + if let Some(c) = c.as_ref() { + c.cancel(); + } + } +} + +#[tauri::command] +async fn create_account(state: State<'_, AppState>, id: String, username: String, password: String, administrator: bool) -> std::result::Result<(), UiError> { + let root = path_of(&state, &id)?; + blocking(move || { + coa_core::ra::validate_account(&username, &password)?; + let mut ra = Ra::connect(&root)?; + ra.create_account(&username, &password)?; + if administrator { + ra.make_administrator(&username)?; + } + tracing::info!(%username, administrator, "account created"); + Ok(()) + }) + .await +} + pub fn run() { let dir = data_dir(); let _ = coa_core::logging::init(&dir.join("logs").join("manager.log")); tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) - .manage(AppState { registry: Registry::at(dir.join("installs.json")), busy: Mutex::new(HashSet::new()) }) + .manage(AppState { registry: Registry::at(dir.join("installs.json")), busy: Mutex::new(HashSet::new()), install_cancel: Mutex::new(None) }) .invoke_handler(tauri::generate_handler![ default_install_dir, scan_server, @@ -338,7 +409,11 @@ pub fn run() { verify_backup, delete_backup, restore_backup_configs, - restore_backup_database + restore_backup_database, + install_preflight, + install_new, + cancel_install, + create_account ]) .run(tauri::generate_context!()) .expect("error while running CoA Server Manager"); diff --git a/src/App.tsx b/src/App.tsx index 8293ef3..5029161 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,8 +3,9 @@ import { api, type ServerSummary } from "@/lib/api"; import { Welcome } from "@/screens/Welcome"; import { ImportServer } from "@/screens/ImportServer"; import { Shell } from "@/screens/Shell"; +import { InstallServer } from "@/screens/InstallServer"; -type View = "welcome" | "import"; +type View = "welcome" | "add" | "import" | "install"; export default function App() { const [servers, setServers] = useState(null); @@ -29,7 +30,7 @@ export default function App() { servers={servers} activeId={activeId} onSelect={setActiveId} - onAddAnother={() => setView("import")} + onAddAnother={() => setView("add")} onForget={async (id) => { await api.forget(id); await refresh(); @@ -38,11 +39,25 @@ export default function App() { ); } + if (view === "install") { + return ( + setView(servers.length > 0 ? "add" : "welcome")} + onDone={async (s) => { + await refresh(); + setActiveId(s.id); + setView("welcome"); + }} + /> + ); + } + if (view === "import") { return ( 0} - onCancel={() => setView("welcome")} + canCancel + onCancel={() => setView(servers.length > 0 ? "add" : "welcome")} onAdded={async (s) => { await refresh(); setActiveId(s.id); @@ -52,5 +67,11 @@ export default function App() { ); } - return setView("import")} />; + return ( + setView("import")} + onInstall={() => setView("install")} + onBack={servers.length > 0 ? () => setView("welcome") : undefined} + /> + ); } diff --git a/src/lib/api.ts b/src/lib/api.ts index 80c29e3..c99bd9f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -161,6 +161,18 @@ export interface DbRestore { tables_restored: number; } +export interface Preflight { + ok: boolean; + problems: { code: string; message: string }[]; + free_bytes: number; +} + +export interface InstallStep { + step: string; + percent: number; + detail: string | null; +} + export const api = { defaultInstallDir: () => invoke("default_install_dir"), scan: (path: string) => invoke("scan_server", { path }), @@ -179,6 +191,11 @@ export const api = { deleteBackup: (id: string, backupId: string) => invoke("delete_backup", { id, backupId }), restoreConfigs: (id: string, backupId: string) => invoke("restore_backup_configs", { id, backupId }), restoreDatabase: (id: string, backupId: string, database: string) => invoke("restore_backup_database", { id, backupId, database }), + installPreflight: (dest: string) => invoke("install_preflight", { dest }), + installNew: (dest: string, pkg?: string) => invoke("install_new", { dest, package: pkg ?? null }), + cancelInstall: () => invoke("cancel_install"), + createAccount: (id: string, username: string, password: string, administrator: boolean) => + invoke("create_account", { id, username, password, administrator }), previewPreset: (id: string, scope: Scope, preset: string) => invoke("preview_preset", { id, scope, preset }), }; diff --git a/src/screens/InstallServer.tsx b/src/screens/InstallServer.tsx new file mode 100644 index 0000000..44efdf9 --- /dev/null +++ b/src/screens/InstallServer.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { open } from "@tauri-apps/plugin-dialog"; +import { Check, ChevronDown, ChevronRight, Loader2 } from "lucide-react"; +import { api, asUiError, type InstallStep, type Preflight, type ServerSummary, type UiError } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +type Phase = "choose" | "installing" | "done"; + +export function InstallServer(props: { canCancel: boolean; onCancel: () => void; onDone: (s: ServerSummary) => void | Promise }) { + const [dest, setDest] = useState("C:\\Games\\CoA Server"); + const [pre, setPre] = useState(null); + const [phase, setPhase] = useState("choose"); + const [step, setStep] = useState({ step: "Preparing your server", percent: 0, detail: null }); + const [error, setError] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); + const [pkg, setPkg] = useState(""); + const [installed, setInstalled] = useState(null); + const [showDetails, setShowDetails] = useState(false); + + useEffect(() => { + const t = setTimeout(() => void api.installPreflight(dest).then(setPre).catch(() => setPre(null)), 250); + return () => clearTimeout(t); + }, [dest]); + + useEffect(() => { + const un = listen("install-progress", (e) => setStep(e.payload)); + return () => { + void un.then((f) => f()); + }; + }, []); + + async function browse() { + const picked = await open({ directory: true, multiple: false, title: "Choose where to install your server" }); + if (typeof picked === "string") setDest(picked.replace(/[\\/]+$/, "") + (picked.toLowerCase().includes("coa") ? "" : "\\CoA Server")); + } + + async function browsePackage() { + const picked = await open({ directory: true, multiple: false, title: "Choose a server package folder" }); + if (typeof picked === "string") setPkg(picked); + } + + async function install() { + setError(null); + setPhase("installing"); + setStep({ step: "Preparing your server", percent: 0, detail: null }); + try { + const s = await api.installNew(dest, pkg.trim() || undefined); + setInstalled(s); + setPhase("done"); + } catch (e) { + setError(asUiError(e)); + setPhase("choose"); + } + } + + if (phase === "installing") { + return ( +
+

Preparing your server

+

This can take a few minutes. You can leave this window open.

+ +
+ + {step.step} + + {step.percent}% +
+
+
+
+ {step.detail &&

{step.detail}

} + + {step.step === "Downloading server" && ( + + )} +
+ ); + } + + if (phase === "done" && installed) { + return ( +
+
+ +
+

Your server is ready

+

Everything is installed. Start it from the next screen.

+ +
+ ); + } + + return ( +
+
+

Install a new server

+

Choose a folder, or keep the suggested one. Everything else is set up for you.

+
+ +
+ +
+ setDest(e.target.value)} + className="selectable flex-1 rounded-md border border-line bg-card px-3 py-2.5 text-[15px] outline-none focus:border-gold" + /> + +
+
+ + {pre && pre.problems.length > 0 && ( + + {pre.problems.map((p) => ( +

{p.message}

+ ))} +
+ )} + {pre?.ok &&

This folder is ready. {Math.round(pre.free_bytes / (1 << 30))} GB free.

} + + {error && ( + +

{error.human.code === "unknown" ? "The installation could not finish" : error.human.title}

+

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

+

Nothing was installed. You can try again; downloaded files are kept.

+ + {showDetails &&
{error.technical}
} +
+ )} + +
+ + {showAdvanced && ( +
+ +
+ setPkg(e.target.value)} placeholder="(leave empty to download)" className="selectable flex-1 rounded-md border border-line bg-card px-3 py-2 text-sm outline-none focus:border-gold" /> + +
+
+ )} +
+ +
+ + {props.canCancel && ( + + )} +
+
+ ); +} diff --git a/src/screens/PlayersPage.tsx b/src/screens/PlayersPage.tsx new file mode 100644 index 0000000..ec9970e --- /dev/null +++ b/src/screens/PlayersPage.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from "react"; +import { api, asUiError, type UiError } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +export function PlayersPage({ serverId }: { serverId: string }) { + const [online, setOnline] = useState(null); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [admin, setAdmin] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(null); + + useEffect(() => { + let alive = true; + const poll = () => api.status(serverId).then((s) => alive && setOnline(s.observed.world.state === "running")).catch(() => {}); + void poll(); + const t = setInterval(poll, 3000); + return () => { + alive = false; + clearInterval(t); + }; + }, [serverId]); + + async function create() { + setBusy(true); + setError(null); + setDone(null); + try { + await api.createAccount(serverId, username, password, admin); + setDone(username); + setUsername(""); + setPassword(""); + } catch (e) { + setError(asUiError(e)); + } finally { + setBusy(false); + } + } + + const input = "w-72 rounded-md border border-line bg-bg px-3 py-2.5 text-[15px] outline-none focus:border-gold"; + return ( +
+

Players

+

Who is playing right now?

+ + +

Create your account

+

This is the name and password you use to log in to the game.

+ {online === false &&

Start the server first — accounts are created while it is running.

} +
+
+ + setUsername(e.target.value)} autoComplete="off" /> +
+
+ + setPassword(e.target.value)} autoComplete="new-password" /> +
+ +
+ + {done &&

Account “{done}” created. You can log in to the game with it now.

} + {error && ( +

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

+ )} +
+
+ ); +} diff --git a/src/screens/Shell.tsx b/src/screens/Shell.tsx index f4369c4..970bf1c 100644 --- a/src/screens/Shell.tsx +++ b/src/screens/Shell.tsx @@ -6,6 +6,7 @@ import { Overview } from "@/screens/Overview"; import { Placeholder } from "@/screens/Placeholder"; import { SettingsPage } from "@/screens/SettingsPage"; import { BackupsPage } from "@/screens/BackupsPage"; +import { PlayersPage } from "@/screens/PlayersPage"; import { Button } from "@/components/ui/button"; type Page = "overview" | "bots" | "server" | "players" | "friends" | "backups" | "console" | "settings"; @@ -80,6 +81,8 @@ export function Shell(props: { props.onForget(server.id)} /> ) : page === "bots" || page === "server" ? ( + ) : page === "players" ? ( + ) : page === "backups" ? ( ) : ( diff --git a/src/screens/Welcome.tsx b/src/screens/Welcome.tsx index 3b3ff4d..2acd4c8 100644 --- a/src/screens/Welcome.tsx +++ b/src/screens/Welcome.tsx @@ -1,7 +1,6 @@ import { FolderSearch, Download } from "lucide-react"; -import { Card } from "@/components/ui/card"; -export function Welcome({ onImport }: { onImport: () => void }) { +export function Welcome({ onImport, onInstall, onBack }: { onImport: () => void; onInstall: () => void; onBack?: () => void }) { return (
@@ -11,12 +10,14 @@ export function Welcome({ onImport }: { onImport: () => void }) {
- +
+ {onBack && ( + + )}
); }