mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-12 12:01:02 +00:00
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:
1 parent
0bc6059019
commit
e17c757d11
14 files changed
+1518
-14
No files matched your search
@@ -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"] }
|
||||
|
||||
@@ -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(®_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)");
|
||||
}
|
||||
@@ -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, ®(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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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: ®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");
|
||||
|
||||
+26
-5
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user