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 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-09-30 08:26:11 +05:00
1 parent 0bc6059019
commit e17c757d11
14 files changed
+1518 -14

No files matched your search

+1 -1
View File
@@ -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"] }
+59
View File
@@ -0,0 +1,59 @@
//! Full clean-install scenario: package a DISPOSABLE fixture, sign it, install it into a new folder.
//! usage: install_e2e <fixture folder> <package output> <install destination>
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<String> = 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::<u64>() 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(&reg_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: &registry, 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)");
}
+408
View File
@@ -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 `<dest>.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<Problem>,
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<String>,
}
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<Vec<u8>> {
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<Installed> {
let say = |step: &'static str, percent: u8, detail: Option<String>| 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::<Vec<_>>().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<Installed> {
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, &reg(d.path()));
assert!(!codes(&p).contains(&"not_empty") && !codes(&p).contains(&"already_server"));
fs::create_dir_all(&fresh).unwrap();
let p = preflight(&fresh, 1000, &reg(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, &reg(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, &reg(d.path()))).contains(&"client_folder"));
let server = d.path().join("server");
layout::testkit::fake_repack(&server);
assert!(codes(&preflight(&server, 1, &reg(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, &reg(d.path()))).contains(&"system_folder"), "{bad}");
}
assert!(codes(&preflight(Path::new("relative\\dir"), 1, &reg(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, &reg(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, &reg(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");
}
}
+3
View File
@@ -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;
+29 -1
View File
@@ -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<ArchivePart>,
pub unpacked_size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Revision {
pub commit: Option<String>,
@@ -75,6 +91,8 @@ pub struct Manifest {
pub files: Vec<FileEntry>,
#[serde(default)]
pub migrations: Vec<Migration>,
#[serde(default)]
pub archive: Option<ArchiveInfo>,
}
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)
)
}
+464
View File
@@ -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<ArchivePart>,
cur: Option<(BufWriter<File>, 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<usize> {
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<String>) -> 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<String>,
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<Manifest> {
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<R: Read> Read for Tee<'_, R> {
fn read(&mut self, b: &mut [u8]) -> io::Result<usize> {
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<PathBuf>,
idx: usize,
cur: Option<File>,
}
impl Read for PartsReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
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<String, &FileEntry> = 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<u8>)> {
let mut v = Vec::new();
fn walk(dir: &Path, root: &Path, v: &mut Vec<(String, Vec<u8>)>) {
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<u8> = (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<zstd::Encoder<'static, Vec<u8>>>)) -> (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<zstd::Encoder<'static, Vec<u8>>>, 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");
}
}
+171
View File
@@ -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<String> {
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<Ra> {
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<Ra> {
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<String> {
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<Vec<String>>) {
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"));
}
}
+77 -2
View File
@@ -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<HashSet<String>>,
/// Cancel handle of the installation currently running, if any.
install_cancel: Mutex<Option<Cancel>>,
}
/// 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<String>) -> 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<String>) -> std::result::Result<ServerSummary, UiError> {
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: &registry, 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");
+26 -5
View File
@@ -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<ServerSummary[] | null>(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 (
<InstallServer
canCancel
onCancel={() => setView(servers.length > 0 ? "add" : "welcome")}
onDone={async (s) => {
await refresh();
setActiveId(s.id);
setView("welcome");
}}
/>
);
}
if (view === "import") {
return (
<ImportServer
canCancel={servers.length > 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 <Welcome onImport={() => setView("import")} />;
return (
<Welcome
onImport={() => setView("import")}
onInstall={() => setView("install")}
onBack={servers.length > 0 ? () => setView("welcome") : undefined}
/>
);
}
+17
View File
@@ -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<string>("default_install_dir"),
scan: (path: string) => invoke<ScanReport>("scan_server", { path }),
@@ -179,6 +191,11 @@ export const api = {
deleteBackup: (id: string, backupId: string) => invoke<void>("delete_backup", { id, backupId }),
restoreConfigs: (id: string, backupId: string) => invoke<RecoveryPoint>("restore_backup_configs", { id, backupId }),
restoreDatabase: (id: string, backupId: string, database: string) => invoke<DbRestore>("restore_backup_database", { id, backupId, database }),
installPreflight: (dest: string) => invoke<Preflight>("install_preflight", { dest }),
installNew: (dest: string, pkg?: string) => invoke<ServerSummary>("install_new", { dest, package: pkg ?? null }),
cancelInstall: () => invoke<void>("cancel_install"),
createAccount: (id: string, username: string, password: string, administrator: boolean) =>
invoke<void>("create_account", { id, username, password, administrator }),
previewPreset: (id: string, scope: Scope, preset: string) => invoke<PresetPreview>("preview_preset", { id, scope, preset }),
};
+168
View File
@@ -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<void> }) {
const [dest, setDest] = useState("C:\\Games\\CoA Server");
const [pre, setPre] = useState<Preflight | null>(null);
const [phase, setPhase] = useState<Phase>("choose");
const [step, setStep] = useState<InstallStep>({ step: "Preparing your server", percent: 0, detail: null });
const [error, setError] = useState<UiError | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [pkg, setPkg] = useState("");
const [installed, setInstalled] = useState<ServerSummary | null>(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<InstallStep>("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 (
<main className="mx-auto flex h-full max-w-xl flex-col justify-center px-8">
<h1 className="text-2xl font-semibold">Preparing your server</h1>
<p className="mt-1 text-muted">This can take a few minutes. You can leave this window open.</p>
<Card className="mt-8 p-6">
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 font-medium">
<Loader2 className="h-4 w-4 animate-spin text-gold" aria-hidden /> {step.step}
</span>
<span className="text-sm text-muted" role="status">{step.percent}%</span>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/10" role="progressbar" aria-valuenow={step.percent} aria-valuemin={0} aria-valuemax={100}>
<div className="h-full rounded-full bg-gold transition-[width] duration-300" style={{ width: `${step.percent}%` }} />
</div>
{step.detail && <p className="mt-2 text-xs text-muted">{step.detail}</p>}
</Card>
{step.step === "Downloading server" && (
<Button className="mt-4 self-start" variant="ghost" size="sm" onClick={() => void api.cancelInstall()}>
Cancel
</Button>
)}
</main>
);
}
if (phase === "done" && installed) {
return (
<main className="mx-auto flex h-full max-w-xl flex-col items-center justify-center px-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-ok/15">
<Check className="h-7 w-7 text-ok" aria-hidden />
</div>
<h1 className="mt-5 text-2xl font-semibold">Your server is ready</h1>
<p className="mt-1 text-muted">Everything is installed. Start it from the next screen.</p>
<Button className="mt-8" variant="primary" size="xl" onClick={() => void props.onDone(installed)}>
OPEN MY SERVER
</Button>
</main>
);
}
return (
<main className="mx-auto flex h-full max-w-2xl flex-col justify-center gap-5 px-8 py-10">
<div>
<h1 className="text-2xl font-semibold">Install a new server</h1>
<p className="mt-1 text-muted">Choose a folder, or keep the suggested one. Everything else is set up for you.</p>
</div>
<div>
<label htmlFor="dest" className="text-sm text-muted">Install to</label>
<div className="mt-1 flex gap-2">
<input
id="dest"
value={dest}
onChange={(e) => 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"
/>
<Button onClick={browse}>Browse…</Button>
</div>
</div>
{pre && pre.problems.length > 0 && (
<Card className="border-warn/40 p-4" role="alert">
{pre.problems.map((p) => (
<p key={p.code} className="text-sm text-warn">{p.message}</p>
))}
</Card>
)}
{pre?.ok && <p className="text-sm text-ok">This folder is ready. {Math.round(pre.free_bytes / (1 << 30))} GB free.</p>}
{error && (
<Card className="border-bad/40 p-4" role="alert">
<p className="font-medium text-bad">{error.human.code === "unknown" ? "The installation could not finish" : error.human.title}</p>
<p className="mt-1 text-sm text-muted">{error.human.code === "unknown" ? error.technical : error.human.message}</p>
<p className="mt-1 text-sm text-muted">Nothing was installed. You can try again; downloaded files are kept.</p>
<button className="mt-2 cursor-pointer text-xs text-muted underline" onClick={() => setShowDetails((v) => !v)}>
{showDetails ? "Hide" : "Show"} technical details
</button>
{showDetails && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-black/40 p-2 text-xs">{error.technical}</pre>}
</Card>
)}
<div>
<button onClick={() => setShowAdvanced((v) => !v)} aria-expanded={showAdvanced} className="flex cursor-pointer items-center gap-1 text-sm text-muted hover:text-ink">
{showAdvanced ? <ChevronDown className="h-4 w-4" aria-hidden /> : <ChevronRight className="h-4 w-4" aria-hidden />}
Advanced
</button>
{showAdvanced && (
<div className="mt-2">
<label htmlFor="pkg" className="text-sm text-muted">Install from a local package folder instead of downloading</label>
<div className="mt-1 flex gap-2">
<input id="pkg" value={pkg} onChange={(e) => 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" />
<Button size="sm" onClick={browsePackage}>Browse…</Button>
</div>
</div>
)}
</div>
<div className="flex items-center gap-3">
<Button variant="primary" size="md" disabled={!pre?.ok} onClick={() => void install()}>
Install
</Button>
{props.canCancel && (
<Button variant="ghost" onClick={props.onCancel}>
Cancel
</Button>
)}
</div>
</main>
);
}
+81
View File
@@ -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<boolean | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [admin, setAdmin] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<UiError | null>(null);
const [done, setDone] = useState<string | null>(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 (
<div className="max-w-xl">
<h1 className="text-2xl font-semibold">Players</h1>
<p className="mt-1 text-muted">Who is playing right now?</p>
<Card className="mt-6 p-6">
<h2 className="font-semibold">Create your account</h2>
<p className="mt-1 text-sm text-muted">This is the name and password you use to log in to the game.</p>
{online === false && <p className="mt-3 text-sm text-warn">Start the server first — accounts are created while it is running.</p>}
<div className="mt-4 flex flex-col gap-3">
<div>
<label htmlFor="acc-user" className="text-sm text-muted">Username</label>
<input id="acc-user" className={input + " mt-1 block"} value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="off" />
</div>
<div>
<label htmlFor="acc-pass" className="text-sm text-muted">Password</label>
<input id="acc-pass" type="password" className={input + " mt-1 block"} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
</div>
<label className="flex cursor-pointer items-start gap-2 text-sm">
<input type="checkbox" checked={admin} onChange={(e) => setAdmin(e.target.checked)} className="mt-1" />
<span>
Make this account an administrator
<span className="block text-xs text-muted">Administrators can use game-master commands in the game. Only do this for accounts you trust.</span>
</span>
</label>
</div>
<Button className="mt-5" variant="primary" disabled={busy || !online || !username || !password} onClick={() => void create()}>
Create account
</Button>
{done && <p className="mt-3 text-sm text-ok" role="status">Account “{done}” created. You can log in to the game with it now.</p>}
{error && (
<p className="mt-3 text-sm text-bad" role="alert">
{error.human.code === "unknown" ? error.technical : error.human.message}
</p>
)}
</Card>
</div>
);
}
+3
View File
@@ -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: {
<Overview key={server.id} server={server} onForget={() => props.onForget(server.id)} />
) : page === "bots" || page === "server" ? (
<SettingsPage key={`${server.id}-${page}`} serverId={server.id} scope={page} title={current.label} question={current.question} />
) : page === "players" ? (
<PlayersPage key={server.id} serverId={server.id} />
) : page === "backups" ? (
<BackupsPage key={server.id} serverId={server.id} />
) : (
+11 -5
View File
@@ -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 (
<main className="flex h-full flex-col items-center justify-center px-8">
<div className="mb-10 text-center">
@@ -11,12 +10,14 @@ export function Welcome({ onImport }: { onImport: () => void }) {
</div>
<div className="grid w-full max-w-3xl grid-cols-2 gap-5">
<Card className="p-7 opacity-60" aria-disabled="true">
<button
onClick={onInstall}
className="cursor-pointer rounded-card border border-line bg-card/90 p-7 text-left shadow-[0_8px_30px_rgb(0_0_0/0.28)] transition-colors hover:border-gold/60"
>
<Download className="mb-4 h-7 w-7 text-gold" aria-hidden />
<h2 className="text-lg font-semibold">Install new server</h2>
<p className="mt-1 text-sm text-muted">One-click setup with everything included.</p>
<p className="mt-4 inline-block rounded bg-white/5 px-2 py-1 text-xs text-muted">Coming in a later release</p>
</Card>
</button>
<button
onClick={onImport}
@@ -29,6 +30,11 @@ export function Welcome({ onImport }: { onImport: () => void }) {
</p>
</button>
</div>
{onBack && (
<button onClick={onBack} className="mt-8 cursor-pointer text-sm text-muted hover:text-ink">
Back to my servers
</button>
)}
</main>
);
}