From 217eec338a987fb8262f97cf6ae485d4d94e39a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 30 Sep 2026 07:40:01 +0500 Subject: [PATCH] Phase 4: recovery points, zstd DB dumps, restore via staging schema + atomic table swap, Backups UI Verified on fixture MySQL: backup, restore (previous schema kept), damaged backup refused; live install untouched. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 41 +++ README.md | 4 +- crates/coa-core/Cargo.toml | 1 + crates/coa-core/examples/backup_e2e.rs | 48 +++ crates/coa-core/examples/drive.rs | 1 + crates/coa-core/src/backup.rs | 459 +++++++++++++++++++++++++ crates/coa-core/src/db.rs | 236 +++++++++++++ crates/coa-core/src/driver.rs | 3 + crates/coa-core/src/lib.rs | 2 + src-tauri/src/lib.rs | 86 ++++- src/lib/api.ts | 24 ++ src/screens/BackupsPage.tsx | 225 ++++++++++++ src/screens/Shell.tsx | 3 + 13 files changed, 1130 insertions(+), 3 deletions(-) create mode 100644 crates/coa-core/examples/backup_e2e.rs create mode 100644 crates/coa-core/src/backup.rs create mode 100644 crates/coa-core/src/db.rs create mode 100644 src/screens/BackupsPage.tsx diff --git a/Cargo.lock b/Cargo.lock index 6901f13..0ff714f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,6 +286,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -354,6 +356,7 @@ dependencies = [ "tracing-subscriber", "uuid", "windows-sys 0.60.2", + "zstd", ] [[package]] @@ -1750,6 +1753,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.106" @@ -4802,3 +4815,31 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/README.md b/README.md index 0ab0bd3..2df61c7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ Design and audit: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). | 0 Audit + architecture | done | | 1 Foundation (fsx, registry, manifest, logging, error catalogue) | done | | 2 Read-only import, process observation, Start/Stop via repack launcher, health diagnosis | done | -| 3 Config UI, 4 Backups, 5 Clean install, 6 Updates/CI, 7 Bots, 8 Client, 9 Friends, 10 Polish | not started | +| 3 Config engine + Bots/Server settings UI + presets + snapshots | done | +| 4 Recovery points, DB dump/restore (staging + atomic swap), Backups UI | done | +| 5 Clean install, 6 Updates/CI, 7 Bots, 8 Client, 9 Friends, 10 Polish | not started | ## Develop ``` diff --git a/crates/coa-core/Cargo.toml b/crates/coa-core/Cargo.toml index 5282495..671cab9 100644 --- a/crates/coa-core/Cargo.toml +++ b/crates/coa-core/Cargo.toml @@ -16,6 +16,7 @@ fs4 = "0.13" tracing = "0.1" ed25519-dalek = "2" base64 = "0.22" +zstd = "0.13" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] } [dev-dependencies] diff --git a/crates/coa-core/examples/backup_e2e.rs b/crates/coa-core/examples/backup_e2e.rs new file mode 100644 index 0000000..0dd0677 --- /dev/null +++ b/crates/coa-core/examples/backup_e2e.rs @@ -0,0 +1,48 @@ +//! Full backup/restore scenario against a DISPOSABLE fixture (never point this at a real server). +use coa_core::backup::{self, Kind, Trigger}; +use std::path::Path; + +fn main() { + let root = std::env::args().nth(1).expect("usage: backup_e2e "); + assert!(root.to_lowercase().contains("fixture"), "refusing: not a fixture folder"); + let root = Path::new(&root); + let meta = coa_core::registry::metadata_dir_for(root).unwrap(); + std::fs::create_dir_all(&meta).unwrap(); + + let t = std::time::Instant::now(); + let point = backup::create(root, &meta, Kind::Quick, Trigger::Manual, Some("e2e".into()), &|s| println!(" .. {s}")).expect("backup"); + println!("quick backup {} in {:?}: {:?}", point.id, t.elapsed(), point.components.iter().map(|c| (&c.name, c.bytes, c.tables)).collect::>()); + assert!(backup::verify(&meta, &point.id).unwrap().ok); + + // Change live state after the backup. + backup::with_database(root, |db| { + db.query("CREATE TABLE acore_characters.manager_marker (id INT); INSERT INTO acore_characters.manager_marker VALUES (42);")?; + assert_eq!(db.query("SELECT COUNT(*) FROM acore_characters.manager_marker;")?, "1"); + Ok(()) + }) + .unwrap(); + + let r = backup::restore_database(root, &meta, &point.id, "characters").expect("restore"); + println!("restored {} tables; previous database kept as {}; safety backup {}", r.tables_restored, r.previous_schema, r.safety_backup); + + backup::with_database(root, |db| { + let live = db.tables("acore_characters")?; + assert!(!live.contains(&"manager_marker".to_string()), "marker must be gone from the restored database"); + let old = db.tables(&r.previous_schema)?; + assert!(old.contains(&"manager_marker".to_string()), "the replaced database keeps the marker"); + assert_eq!(db.query(&format!("SELECT id FROM {}.manager_marker;", r.previous_schema))?, "42"); + assert_eq!(live.len(), r.tables_restored); + println!("OK: live tables {}, old schema tables {}", live.len(), old.len()); + Ok(()) + }) + .unwrap(); + + // A rejected restore (server "running" is simulated by a damaged backup) must change nothing. + let victim = meta.join("backups").join(&point.id).join("characters.sql.zst"); + let mut bytes = std::fs::read(&victim).unwrap(); + bytes[10] ^= 0xFF; + std::fs::write(&victim, bytes).unwrap(); + assert!(backup::verify(&meta, &point.id).unwrap().problems.len() == 1); + assert!(backup::restore_database(root, &meta, &point.id, "characters").is_err()); + println!("damaged backup correctly refused"); +} diff --git a/crates/coa-core/examples/drive.rs b/crates/coa-core/examples/drive.rs index 71c77d5..f39b707 100644 --- a/crates/coa-core/examples/drive.rs +++ b/crates/coa-core/examples/drive.rs @@ -5,6 +5,7 @@ fn main() { let root = args.next().expect("usage: drive "); let verb = match args.next().as_deref() { Some("start") => coa_core::driver::Verb::StartAll, + Some("mysql") => coa_core::driver::Verb::StartMysql, Some("stop") => coa_core::driver::Verb::StopAll, _ => panic!("verb must be start or stop"), }; diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs new file mode 100644 index 0000000..8e71b67 --- /dev/null +++ b/crates/coa-core/src/backup.rs @@ -0,0 +1,459 @@ +//! Recovery points: configuration + database dumps stored under `.manager/backups//`. +//! +//! Rules enforced here: a recovery point only becomes visible once complete (built in a `.partial` folder), +//! every artefact is checksummed, restore never drops anything (the replaced database is kept under a new name) +//! and restoring a database requires the game servers to be stopped. + +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::db::{self, Account, Db}; +use crate::driver::{self, Verb}; +use crate::error::{Error, Result}; +use crate::fsx; +use crate::layout::read_ports; +use crate::process::{self, ServiceState}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Kind { + /// characters + auth databases, configs, manager metadata. + Quick, + /// Quick + the (large) world database. + Full, + Config, + /// characters + auth databases only. + Database, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Trigger { + Manual, + Automatic, + BeforeUpdate, + BeforeRestore, + BeforeMigration, + BeforeBots, + BeforeRepair, + BeforeDangerousChange, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Component { + /// "characters" | "auth" | "world" | "configs" + pub name: String, + /// File (databases) or folder (configs), relative to the recovery point. + pub path: String, + pub bytes: u64, + pub sha256: Option, + pub tables: Option, + pub files: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecoveryPoint { + pub schema: u32, + pub id: String, + pub kind: Kind, + pub trigger: Trigger, + pub label: Option, + pub created_at: String, + pub manager_version: String, + pub components: Vec, +} + +fn backups_dir(meta: &Path) -> PathBuf { + meta.join("backups") +} + +fn id_ok(id: &str) -> bool { + !id.is_empty() && id.len() < 100 && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && !id.contains("..") +} + +fn point_dir(meta: &Path, id: &str) -> Result { + if !id_ok(id) { + return Err(Error::PathRejected(format!("bad backup id {id:?}"))); + } + Ok(backups_dir(meta).join(id)) +} + +pub fn list(meta: &Path) -> Vec { + let mut out: Vec = fs::read_dir(backups_dir(meta)) + .map(|rd| rd.filter_map(|e| e.ok()).filter_map(|e| fsx::read_json(&e.path().join("backup.json")).ok()).collect()) + .unwrap_or_default(); + out.sort_by(|a, b| b.id.cmp(&a.id)); + out +} + +pub fn get(meta: &Path, id: &str) -> Result { + fsx::read_json(&point_dir(meta, id)?.join("backup.json")).map_err(|_| Error::Invalid(format!("backup {id} was not found"))) +} + +/// Files that make up "configuration": everything a user or the Manager may have changed, minus secrets. +pub fn config_files(root: &Path) -> Vec { + const MAX: u64 = 8 * 1024 * 1024; + let mut out = Vec::new(); + fn walk(root: &Path, dir: &Path, out: &mut Vec) { + let Ok(rd) = fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + let Ok(md) = e.metadata() else { continue }; + if md.is_dir() { + walk(root, &p, out); + } else if md.len() <= MAX { + let name = p.file_name().unwrap().to_string_lossy().to_lowercase(); + if name.ends_with(".bak") || name.ends_with(".orig") || name.ends_with(".old") || name.contains(".bak-") { + continue; + } + if let Ok(rel) = p.strip_prefix(root) { + out.push(rel.to_string_lossy().replace('\\', "/")); + } + } + } + } + walk(root, &root.join("Core/configs"), &mut out); + if let Ok(rd) = fs::read_dir(root.join("Settings")) { + for e in rd.flatten() { + let n = e.file_name().to_string_lossy().to_string(); + // database.json holds generated database passwords; it is launcher state, not configuration. + if e.path().is_file() && (n.contains(".template") || n == "repack.json") { + out.push(format!("Settings/{n}")); + } + } + } + if root.join("RELEASE.json").is_file() { + out.push("RELEASE.json".into()); + } + out.sort(); + out +} + +fn copy_configs(root: &Path, dir: &Path) -> Result { + let files = config_files(root); + let target = dir.join("files"); + let mut bytes = 0; + for rel in &files { + let src = fsx::safe_join(root, rel)?; + let dst = fsx::safe_join(&target, rel)?; + fs::create_dir_all(dst.parent().unwrap())?; + bytes += fs::copy(&src, &dst)?; + } + Ok(Component { name: "configs".into(), path: "files".into(), bytes, sha256: None, tables: None, files: Some(files) }) +} + +/// Make sure the database is up for `f`. If we had to start it (and nothing else is running), stop it again. +pub fn with_database(root: &Path, f: impl FnOnce(&Db) -> Result) -> Result { + let db = Db::from_repack(root, Account::Admin)?; + let observed = process::observe(root, &read_ports(root)); + let mysql_was_up = observed.mysql.state == ServiceState::Running && db.ping(); + if !mysql_was_up { + let out = driver::run(root, Verb::StartMysql)?; + if !out.ok { + return Err(Error::Invalid(out.human.map(|h| h.title.to_string()).unwrap_or_else(|| "The database could not be started.".into()))); + } + } + let result = f(&db); + if !mysql_was_up { + let now = process::observe(root, &read_ports(root)); + if now.world.state == ServiceState::Stopped && now.auth.state == ServiceState::Stopped { + let _ = driver::run(root, Verb::StopAll); + } + } + result +} + +fn wanted_databases(kind: Kind) -> &'static [&'static str] { + match kind { + Kind::Quick | Kind::Database => &["characters", "auth"], + Kind::Full => &["characters", "auth", "world"], + Kind::Config => &[], + } +} + +/// Create a recovery point. The server may be running: dumps are consistent snapshots (`--single-transaction`). +pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Option, progress: &dyn Fn(&str)) -> Result { + let id = format!("{}-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"), match trigger { + Trigger::Manual => "manual", + Trigger::Automatic => "auto", + Trigger::BeforeUpdate => "before-update", + Trigger::BeforeRestore => "before-restore", + Trigger::BeforeMigration => "before-migration", + Trigger::BeforeBots => "before-bots", + Trigger::BeforeRepair => "before-repair", + Trigger::BeforeDangerousChange => "before-change", + }); + let final_dir = point_dir(meta, &id)?; + let partial = backups_dir(meta).join(format!("{id}.partial")); + fs::create_dir_all(&partial)?; + + let build = || -> Result> { + let mut components = Vec::new(); + for name in wanted_databases(kind) { + let schema = db::schema_of(name)?; + progress(&format!("Backing up {name} database")); + let component = with_database(root, |db| { + let size = db.schema_bytes(schema)?; + fsx::require_space(&partial, size / 2 + 32 * 1024 * 1024)?; + let tables = db.tables(schema)?.len(); + let file = format!("{name}.sql.zst"); + let (bytes, sha) = db.dump_to(schema, &partial.join(&file))?; + Ok(Component { name: (*name).into(), path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None }) + })?; + components.push(component); + } + if kind != Kind::Database { + progress("Saving configuration"); + components.push(copy_configs(root, &partial)?); + } + Ok(components) + }; + + let components = match build() { + Ok(c) => c, + Err(e) => { + let _ = fs::remove_dir_all(&partial); // our own unfinished folder + return Err(e); + } + }; + let point = RecoveryPoint { + schema: 1, + id: id.clone(), + kind, + trigger, + label, + created_at: chrono::Utc::now().to_rfc3339(), + manager_version: crate::MANAGER_VERSION.into(), + components, + }; + fsx::atomic_write_json(&partial.join("backup.json"), &point)?; + fs::rename(&partial, &final_dir)?; + tracing::info!(%id, ?kind, ?trigger, "recovery point created"); + if trigger == Trigger::Automatic { + prune_automatic(meta, 10); + } + Ok(point) +} + +/// Keep the newest `keep` automatic recovery points; manual and safety ones are never pruned. +pub fn prune_automatic(meta: &Path, keep: usize) { + let autos: Vec<_> = list(meta).into_iter().filter(|p| p.trigger == Trigger::Automatic).collect(); + for p in autos.into_iter().skip(keep) { + if let Ok(dir) = point_dir(meta, &p.id) { + let _ = fs::remove_dir_all(dir); + } + } +} + +#[derive(Debug, Serialize)] +pub struct VerifyReport { + pub ok: bool, + pub problems: Vec, +} + +pub fn verify(meta: &Path, id: &str) -> Result { + let point = get(meta, id)?; + let dir = point_dir(meta, id)?; + let mut problems = Vec::new(); + for c in &point.components { + let path = dir.join(&c.path); + match (&c.sha256, &c.files) { + (Some(sha), _) => match fsx::sha256_file(&path) { + Ok(actual) if actual.eq_ignore_ascii_case(sha) => {} + Ok(_) => problems.push(format!("{} is damaged (checksum mismatch)", c.name)), + Err(_) => problems.push(format!("{} is missing", c.name)), + }, + (None, Some(files)) => { + for f in files { + if !fsx::safe_join(&path, f).map(|p| p.is_file()).unwrap_or(false) { + problems.push(format!("configuration file {f} is missing")); + } + } + } + _ => {} + } + } + Ok(VerifyReport { ok: problems.is_empty(), problems }) +} + +/// Delete one recovery point (its own folder only). +pub fn delete(meta: &Path, id: &str) -> Result<()> { + let dir = point_dir(meta, id)?; + get(meta, id)?; // must be a real recovery point + fs::remove_dir_all(dir)?; + Ok(()) +} + +/// Put configuration files back. Files the backup does not contain are left alone. A safety point is taken first. +pub fn restore_configs(root: &Path, meta: &Path, id: &str) -> Result { + let point = get(meta, id)?; + let comp = point.components.iter().find(|c| c.name == "configs").ok_or_else(|| Error::Invalid("this backup has no configuration".into()))?; + if !verify(meta, id)?.ok { + return Err(Error::Invalid("This backup is damaged and cannot be restored.".into())); + } + let safety = create(root, meta, Kind::Config, Trigger::BeforeRestore, Some(format!("before restoring {id}")), &|_| {})?; + let src_root = point_dir(meta, id)?.join(&comp.path); + for rel in comp.files.as_deref().unwrap_or_default() { + let dst = fsx::ensure_within(root, &fsx::safe_join(root, rel)?)?; + fsx::atomic_write(&dst, &fs::read(fsx::safe_join(&src_root, rel)?)?)?; + } + Ok(safety) +} + +#[derive(Debug, Serialize)] +pub struct DbRestore { + /// The database the restore replaced, kept intact under this name. + pub previous_schema: String, + pub safety_backup: String, + pub tables_restored: usize, +} + +/// Restore one database from a recovery point without dropping anything: +/// import into a staging schema, sanity-check it, then swap tables atomically and keep the old schema. +pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Result { + let point = get(meta, id)?; + let comp = point.components.iter().find(|c| c.name == name && c.sha256.is_some()).ok_or_else(|| Error::Invalid(format!("this backup has no {name} database")))?; + if !verify(meta, id)?.ok { + return Err(Error::Invalid("This backup is damaged and cannot be restored.".into())); + } + let now = process::observe(root, &read_ports(root)); + if now.world.state != ServiceState::Stopped || now.auth.state != ServiceState::Stopped { + return Err(Error::Invalid("Stop the server before restoring a database.".into())); + } + let live = db::schema_of(name)?; + let expected_tables = comp.tables.unwrap_or(0); + let dump = point_dir(meta, id)?.join(&comp.path); + let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string(); + + // 1. Safety copy of the current state, so even a wrong restore is reversible. + let safety = create(root, meta, Kind::Database, Trigger::BeforeRestore, Some(format!("before restoring {name} from {id}")), &|_| {})?; + + with_database(root, |db| { + let staging = format!("{live}_restore_{stamp}"); + let old = format!("{live}_before_restore_{stamp}"); + if db.schema_exists(&staging)? || db.schema_exists(&old)? { + return Err(Error::Invalid("A previous restore left its work schemas behind; try again in a moment.".into())); + } + // 2. Import into staging. + db.query(&format!("CREATE DATABASE `{staging}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))?; + db.import_from(&staging, &dump)?; + // 3. Sanity checks. + let staged = db.tables(&staging)?; + if staged.is_empty() || (expected_tables > 0 && staged.len() != expected_tables) { + return Err(Error::Invalid(format!("The restored copy looks incomplete ({} of {expected_tables} tables); nothing was changed.", staged.len()))); + } + if db.extra_objects(&staging)? > 0 || db.extra_objects(live)? > 0 { + return Err(Error::Invalid("This database contains routines, triggers or views; automatic restore does not support that. Nothing was changed.".into())); + } + // 4. Atomic swap: live tables move to the `before_restore` schema, staged tables move into place. + let current = db.tables(live)?; + db.query(&format!("CREATE DATABASE `{old}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))?; + let mut renames = Vec::new(); + for t in ¤t { + renames.push(format!("`{live}`.`{t}` TO `{old}`.`{t}`")); + } + for t in &staged { + renames.push(format!("`{staging}`.`{t}` TO `{live}`.`{t}`")); + } + db.query(&format!("RENAME TABLE {};", renames.join(", ")))?; + Ok(DbRestore { previous_schema: old, safety_backup: safety.id.clone(), tables_restored: staged.len() }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup() -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("srv"); + let meta = dir.path().join("srv.manager"); + crate::layout::testkit::fake_repack(&root); + fs::write(root.join("Settings/database.json"), r#"{"rootPassword":"secret-root","appPassword":"secret-app"}"#).unwrap_or_else(|_| { + fs::create_dir_all(root.join("Settings")).unwrap(); + fs::write(root.join("Settings/database.json"), "{}").unwrap(); + }); + fs::create_dir_all(&meta).unwrap(); + (dir, root, meta) + } + + #[test] + fn config_backup_excludes_secrets_and_clutter_and_restores_byte_exact() { + let (_d, root, meta) = setup(); + fs::create_dir_all(root.join("Settings")).unwrap(); + fs::write(root.join("Settings/database.json"), r#"{"rootPassword":"x"}"#).unwrap(); + fs::write(root.join("Settings/worldserver.conf.template"), "A = 1\n").unwrap(); + fs::write(root.join("Core/configs/modules/old.conf.bak"), "junk").unwrap(); + let files = config_files(&root); + assert!(files.contains(&"Core/configs/worldserver.conf".to_string())); + assert!(files.contains(&"Settings/worldserver.conf.template".to_string())); + assert!(!files.iter().any(|f| f.contains("database.json")), "secrets excluded"); + assert!(!files.iter().any(|f| f.ends_with(".bak"))); + + let p = create(&root, &meta, Kind::Config, Trigger::Manual, Some("test".into()), &|_| {}).unwrap(); + assert!(verify(&meta, &p.id).unwrap().ok); + assert_eq!(list(&meta).len(), 1); + assert!(!backups_dir(&meta).join(format!("{}.partial", p.id)).exists()); + + let original = fs::read(root.join("Core/configs/worldserver.conf")).unwrap(); + fs::write(root.join("Core/configs/worldserver.conf"), "[worldserver]\nRealmID = 99\n").unwrap(); + fs::write(root.join("Core/configs/unrelated_new.conf"), "keep").unwrap(); + let safety = restore_configs(&root, &meta, &p.id).unwrap(); + assert_eq!(fs::read(root.join("Core/configs/worldserver.conf")).unwrap(), original); + assert!(root.join("Core/configs/unrelated_new.conf").is_file(), "restore never deletes other files"); + assert_eq!(safety.trigger, Trigger::BeforeRestore); + assert!(list(&meta).len() >= 2); + } + + #[test] + fn verify_detects_tampering_and_restore_refuses_damaged_backup() { + let (_d, root, meta) = setup(); + let p = create(&root, &meta, Kind::Config, Trigger::Manual, None, &|_| {}).unwrap(); + let victim = point_dir(&meta, &p.id).unwrap().join("files/Core/configs/worldserver.conf"); + fs::remove_file(&victim).unwrap(); + let v = verify(&meta, &p.id).unwrap(); + assert!(!v.ok && v.problems[0].contains("worldserver.conf")); + assert!(restore_configs(&root, &meta, &p.id).is_err()); + } + + #[test] + fn ids_are_validated_and_delete_only_touches_real_backups() { + let (d, root, meta) = setup(); + for bad in ["", "..", "../x", "a/b", "a\\b"] { + assert!(point_dir(&meta, bad).is_err(), "{bad:?}"); + } + let victim = d.path().join("srv.manager/important"); + fs::create_dir_all(&victim).unwrap(); + assert!(delete(&meta, "important").is_err(), "not a recovery point"); + assert!(victim.exists()); + let p = create(&root, &meta, Kind::Config, Trigger::Manual, None, &|_| {}).unwrap(); + delete(&meta, &p.id).unwrap(); + assert!(list(&meta).is_empty()); + } + + #[test] + fn automatic_points_are_pruned_but_manual_and_safety_are_kept() { + let (_d, root, meta) = setup(); + let mk = |t: Trigger, id: &str| { + let mut p = create(&root, &meta, Kind::Config, t, None, &|_| {}).unwrap(); + // give each a distinct, sortable id by renaming its folder + let from = point_dir(&meta, &p.id).unwrap(); + p.id = id.to_string(); + fsx::atomic_write_json(&from.join("backup.json"), &p).unwrap(); + fs::rename(from, backups_dir(&meta).join(id)).unwrap(); + }; + mk(Trigger::Manual, "20260101-000000-manual"); + mk(Trigger::BeforeRestore, "20260102-000000-before-restore"); + for i in 0..5 { + mk(Trigger::Automatic, &format!("2026020{i}-000000-auto")); + } + prune_automatic(&meta, 2); + let left: Vec = list(&meta).into_iter().map(|p| p.id).collect(); + assert_eq!(left.iter().filter(|i| i.ends_with("-auto")).count(), 2); + assert!(left.contains(&"20260101-000000-manual".to_string())); + assert!(left.contains(&"20260102-000000-before-restore".to_string())); + assert!(left.contains(&"20260204-000000-auto".to_string()), "newest automatic kept"); + } +} diff --git a/crates/coa-core/src/db.rs b/crates/coa-core/src/db.rs new file mode 100644 index 0000000..37a842b --- /dev/null +++ b/crates/coa-core/src/db.rs @@ -0,0 +1,236 @@ +//! Database access through the MySQL client tools bundled with the repack (`mysql`, `mysqldump`, `mysqladmin`). +//! +//! Passwords travel only in the child's environment (`MYSQL_PWD`), never on a command line or in a log. + +use std::fmt; +use std::fs::File; +use std::io::{BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde::Deserialize; + +use crate::error::{Error, ErrorCode, Result}; +use crate::fsx; +use crate::layout::{read_ports, Ports}; + +/// A credential that never prints itself. +#[derive(Clone)] +pub struct Secret(String); + +impl Secret { + fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for Secret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Secret(***)") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Account { + /// Owner of everything in the packaged MySQL (needed for staging schemas and renames). + Admin, + /// The game servers' own account. + App, +} + +#[derive(Debug, Clone)] +pub struct Db { + bin: PathBuf, + port: u16, + user: &'static str, + password: Secret, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Credentials { + root_password: String, + app_password: String, +} + +fn no_window(cmd: &mut Command) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); + } + #[cfg(not(windows))] + let _ = cmd; +} + +/// Schema names of the three game databases. +pub const SCHEMAS: [(&str, &str); 3] = [("characters", "acore_characters"), ("auth", "acore_auth"), ("world", "acore_world")]; + +pub fn schema_of(kind: &str) -> Result<&'static str> { + SCHEMAS.iter().find(|(k, _)| *k == kind).map(|(_, s)| *s).ok_or_else(|| Error::Invalid(format!("unknown database {kind}"))) +} + +fn ident_ok(name: &str) -> bool { + !name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +impl Db { + pub fn from_repack(root: &Path, account: Account) -> Result { + let creds: Credentials = fsx::read_json(&root.join("Settings/database.json")) + .map_err(|_| Error::Invalid("The database settings of this server could not be read.".into()))?; + let ports: Ports = read_ports(root); + let bin = root.join("mysql/bin"); + if !bin.join("mysqldump.exe").is_file() || !bin.join("mysql.exe").is_file() { + return Err(Error::Invalid("The bundled database tools are missing.".into())); + } + let (user, password) = match account { + Account::Admin => ("root", creds.root_password), + Account::App => ("acore", creds.app_password), + }; + Ok(Db { bin, port: ports.mysql, user, password: Secret(password) }) + } + + fn command(&self, tool: &str) -> Command { + let mut c = Command::new(self.bin.join(tool)); + c.env("MYSQL_PWD", self.password.expose()) + .arg("--protocol=tcp") + .arg("--host=127.0.0.1") + .arg(format!("--port={}", self.port)) + .arg(format!("--user={}", self.user)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + no_window(&mut c); + c + } + + fn fail(&self, stderr: &[u8]) -> Error { + let text = String::from_utf8_lossy(stderr); + let code = if text.contains("2003") || text.contains("Can't connect") { ErrorCode::DatabaseNotRunning } else { ErrorCode::Unknown }; + match code { + ErrorCode::DatabaseNotRunning => Error::Invalid("The database is not running. Start the server first.".into()), + _ => Error::Invalid(format!("database command failed: {}", text.trim().lines().last().unwrap_or(""))), + } + } + + pub fn ping(&self) -> bool { + let mut c = self.command("mysqladmin.exe"); + c.arg("ping").arg("--connect-timeout=3"); + c.output().map(|o| o.status.success()).unwrap_or(false) + } + + /// Run SQL text (statement(s) supplied on stdin) and return the raw output. + pub fn query(&self, sql: &str) -> Result { + let mut c = self.command("mysql.exe"); + c.args(["--batch", "--skip-column-names", "--connect-timeout=5"]); + c.stdin(Stdio::piped()); + let mut child = c.spawn()?; + child.stdin.take().expect("piped").write_all(sql.as_bytes())?; + let out = child.wait_with_output()?; + if !out.status.success() { + return Err(self.fail(&out.stderr)); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } + + pub fn schema_exists(&self, name: &str) -> Result { + if !ident_ok(name) { + return Err(Error::Invalid("bad schema name".into())); + } + Ok(self.query(&format!("SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='{name}';"))? == "1") + } + + pub fn tables(&self, schema: &str) -> Result> { + if !ident_ok(schema) { + return Err(Error::Invalid("bad schema name".into())); + } + let out = self.query(&format!("SELECT table_name FROM information_schema.tables WHERE table_schema='{schema}' AND table_type='BASE TABLE' ORDER BY table_name;"))?; + Ok(out.lines().map(str::to_string).filter(|s| !s.is_empty()).collect()) + } + + /// Bytes of data + index the schema occupies (used to estimate backup size). + pub fn schema_bytes(&self, schema: &str) -> Result { + if !ident_ok(schema) { + return Err(Error::Invalid("bad schema name".into())); + } + let out = self.query(&format!("SELECT COALESCE(SUM(data_length+index_length),0) FROM information_schema.tables WHERE table_schema='{schema}';"))?; + out.trim().parse().map_err(|_| Error::Invalid("unexpected size reply".into())) + } + + /// Stored routines / triggers / views make a table-level swap unsafe; count them. + pub fn extra_objects(&self, schema: &str) -> Result { + if !ident_ok(schema) { + return Err(Error::Invalid("bad schema name".into())); + } + let sql = format!( + "SELECT (SELECT COUNT(*) FROM information_schema.routines WHERE routine_schema='{schema}') + (SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema='{schema}') + (SELECT COUNT(*) FROM information_schema.views WHERE table_schema='{schema}');" + ); + self.query(&sql)?.trim().parse().map_err(|_| Error::Invalid("unexpected reply".into())) + } + + /// Consistent dump of one schema, zstd-compressed to `out`. Returns (compressed bytes, sha256 of the file). + pub fn dump_to(&self, schema: &str, out: &Path) -> Result<(u64, String)> { + if !ident_ok(schema) { + return Err(Error::Invalid("bad schema name".into())); + } + let mut c = self.command("mysqldump.exe"); + c.args(["--single-transaction", "--quick", "--routines", "--triggers", "--default-character-set=utf8mb4", "--no-tablespaces", "--skip-comments", "--hex-blob"]); + c.arg(schema); + let mut child = c.spawn()?; + let mut stdout = child.stdout.take().expect("piped"); + let mut stderr = child.stderr.take().expect("piped"); + let err_thread = std::thread::spawn(move || { + let mut b = Vec::new(); + let _ = stderr.read_to_end(&mut b); + b + }); + + let file = File::create(out)?; + let mut enc = zstd::Encoder::new(BufWriter::new(file), 3)?; + let copy = std::io::copy(&mut stdout, &mut enc); + let finish = enc.finish().and_then(|mut w| w.flush()); + let status = child.wait()?; + let stderr_bytes = err_thread.join().unwrap_or_default(); + if !status.success() { + let _ = std::fs::remove_file(out); + return Err(self.fail(&stderr_bytes)); + } + copy?; + finish?; + let size = std::fs::metadata(out)?.len(); + Ok((size, fsx::sha256_file(out)?)) + } + + /// Import a dump produced by `dump_to` into `schema` (which must already exist and be empty). + pub fn import_from(&self, schema: &str, dump: &Path) -> Result<()> { + if !ident_ok(schema) { + return Err(Error::Invalid("bad schema name".into())); + } + let mut c = self.command("mysql.exe"); + c.args(["--default-character-set=utf8mb4", "--max-allowed-packet=128M"]); + c.arg(schema); + c.stdin(Stdio::piped()); + let mut child = c.spawn()?; + let mut stdin = child.stdin.take().expect("piped"); + let mut stderr = child.stderr.take().expect("piped"); + let err_thread = std::thread::spawn(move || { + let mut b = Vec::new(); + let _ = stderr.read_to_end(&mut b); + b + }); + let mut dec = zstd::Decoder::new(BufReader::new(File::open(dump)?))?; + let copied = std::io::copy(&mut dec, &mut stdin); + drop(stdin); + let status = child.wait()?; + let stderr_bytes = err_thread.join().unwrap_or_default(); + if !status.success() { + return Err(self.fail(&stderr_bytes)); + } + copied?; + Ok(()) + } +} + +pub fn valid_identifier(name: &str) -> bool { + ident_ok(name) +} diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 96cbee7..7b9f808 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -16,6 +16,7 @@ use crate::fsx; #[serde(rename_all = "kebab-case")] pub enum Verb { StartAll, + StartMysql, StopAll, } @@ -23,6 +24,7 @@ impl Verb { fn arg(self) -> &'static str { match self { Verb::StartAll => "start-all", + Verb::StartMysql => "start-mysql", Verb::StopAll => "stop-all", } } @@ -30,6 +32,7 @@ impl Verb { fn timeout(self) -> Duration { match self { Verb::StartAll => Duration::from_secs(420), + Verb::StartMysql => Duration::from_secs(120), Verb::StopAll => Duration::from_secs(240), } } diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 0e06d35..09974d2 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -1,4 +1,6 @@ +pub mod backup; pub mod config; +pub mod db; pub mod driver; pub mod error; pub mod fsx; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8493ba9..fdc1146 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ use std::collections::HashSet; use std::path::PathBuf; use std::sync::Mutex; +use coa_core::backup::{self, Kind, RecoveryPoint, Trigger, VerifyReport}; use coa_core::config::{self, Scope, SettingsView}; use coa_core::driver::{self, DriverOutcome, Verb}; use coa_core::error::UiError; @@ -15,7 +16,7 @@ use coa_core::{Error, Result}; use serde::Serialize; use serde_json::Value; use std::collections::BTreeMap; -use tauri::State; +use tauri::{AppHandle, Emitter, State}; struct AppState { registry: Registry, @@ -236,6 +237,81 @@ async fn restore_config_snapshot(state: State<'_, AppState>, id: String, snapsho blocking(move || config::restore_snapshot(&meta_dir(&root)?, &snapshot)).await } +/// Marks the installation busy for the duration of a long operation, and always clears it. +struct BusyGuard<'a> { + state: &'a AppState, + id: String, +} + +impl<'a> BusyGuard<'a> { + fn acquire(state: &'a AppState, id: &str) -> Result { + let mut b = state.busy.lock().map_err(|_| Error::Invalid("state poisoned".into()))?; + if !b.insert(id.to_string()) { + return Err(Error::Invalid("Another action is still in progress for this server.".into())); + } + Ok(Self { state, id: id.to_string() }) + } +} + +impl Drop for BusyGuard<'_> { + fn drop(&mut self) { + if let Ok(mut b) = self.state.busy.lock() { + b.remove(&self.id); + } + } +} + +#[tauri::command] +fn list_backups(state: State<'_, AppState>, id: String) -> std::result::Result, UiError> { + let root = path_of(&state, &id)?; + Ok(backup::list(&meta_dir(&root)?)) +} + +#[tauri::command] +async fn create_backup( + app: AppHandle, + state: State<'_, AppState>, + id: String, + kind: Kind, + label: Option, +) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || { + let meta = meta_dir(&root)?; + backup::create(&root, &meta, kind, Trigger::Manual, label, &|step| { + let _ = app.emit("backup-progress", step); + }) + }) + .await +} + +#[tauri::command] +async fn verify_backup(state: State<'_, AppState>, id: String, backup_id: String) -> std::result::Result { + let root = path_of(&state, &id)?; + blocking(move || backup::verify(&meta_dir(&root)?, &backup_id)).await +} + +#[tauri::command] +fn delete_backup(state: State<'_, AppState>, id: String, backup_id: String) -> std::result::Result<(), UiError> { + let root = path_of(&state, &id)?; + Ok(backup::delete(&meta_dir(&root)?, &backup_id)?) +} + +#[tauri::command] +async fn restore_backup_configs(state: State<'_, AppState>, id: String, backup_id: String) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || backup::restore_configs(&root, &meta_dir(&root)?, &backup_id)).await +} + +#[tauri::command] +async fn restore_backup_database(state: State<'_, AppState>, id: String, backup_id: String, database: String) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || backup::restore_database(&root, &meta_dir(&root)?, &backup_id, &database)).await +} + pub fn run() { let dir = data_dir(); let _ = coa_core::logging::init(&dir.join("logs").join("manager.log")); @@ -256,7 +332,13 @@ pub fn run() { list_presets, preview_preset, list_config_snapshots, - restore_config_snapshot + restore_config_snapshot, + list_backups, + create_backup, + verify_backup, + delete_backup, + restore_backup_configs, + restore_backup_database ]) .run(tauri::generate_context!()) .expect("error while running CoA Server Manager"); diff --git a/src/lib/api.ts b/src/lib/api.ts index 8b67a2c..80c29e3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -143,6 +143,24 @@ export interface SaveReport { snapshot: string | null; } +export type BackupKind = "quick" | "full" | "config" | "database"; + +export interface RecoveryPoint { + schema: number; + id: string; + kind: BackupKind; + trigger: string; + label: string | null; + created_at: string; + components: { name: string; path: string; bytes: number; tables: number | null; files: string[] | null }[]; +} + +export interface DbRestore { + previous_schema: string; + safety_backup: string; + tables_restored: number; +} + export const api = { defaultInstallDir: () => invoke("default_install_dir"), scan: (path: string) => invoke("scan_server", { path }), @@ -155,6 +173,12 @@ export const api = { settings: (id: string, scope: Scope) => invoke("get_settings", { id, scope }), save: (id: string, scope: Scope, changes: Record) => invoke("save_settings", { id, scope, changes }), presets: (scope: Scope) => invoke("list_presets", { scope }), + backups: (id: string) => invoke("list_backups", { id }), + createBackup: (id: string, kind: BackupKind, label?: string) => invoke("create_backup", { id, kind, label: label ?? null }), + verifyBackup: (id: string, backupId: string) => invoke<{ ok: boolean; problems: string[] }>("verify_backup", { id, backupId }), + deleteBackup: (id: string, backupId: string) => invoke("delete_backup", { id, backupId }), + restoreConfigs: (id: string, backupId: string) => invoke("restore_backup_configs", { id, backupId }), + restoreDatabase: (id: string, backupId: string, database: string) => invoke("restore_backup_database", { id, backupId, database }), previewPreset: (id: string, scope: Scope, preset: string) => invoke("preview_preset", { id, scope, preset }), }; diff --git a/src/screens/BackupsPage.tsx b/src/screens/BackupsPage.tsx new file mode 100644 index 0000000..eafdab9 --- /dev/null +++ b/src/screens/BackupsPage.tsx @@ -0,0 +1,225 @@ +import { useCallback, useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { Loader2, ShieldCheck } from "lucide-react"; +import { api, asUiError, type BackupKind, type RecoveryPoint, type UiError } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +const TRIGGER: Record = { + manual: "Manual backup", + automatic: "Automatic backup", + "before-update": "Before update", + "before-restore": "Safety copy before a restore", + "before-migration": "Before database update", + "before-bots": "Before installing companions", + "before-repair": "Before repair", + "before-dangerous-change": "Before a risky change", +}; + +const KIND: Record = { + quick: "Characters, accounts and settings", + full: "Everything, including the world database", + config: "Settings only", + database: "Characters and accounts", +}; + +const DB_LABEL: Record = { characters: "Characters", auth: "Accounts", world: "World data" }; + +function size(bytes: number): string { + if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(1)} GB`; + if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`; + return `${Math.max(1, Math.round(bytes / 1024))} KB`; +} + +function when(iso: string): string { + const d = new Date(iso); + const today = new Date(); + const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + if (d.toDateString() === today.toDateString()) return `Today ${time}`; + const y = new Date(today.getTime() - 86400000); + if (d.toDateString() === y.toDateString()) return `Yesterday ${time}`; + return `${d.toLocaleDateString([], { day: "numeric", month: "short", year: "numeric" })} ${time}`; +} + +export function BackupsPage({ serverId }: { serverId: string }) { + const [points, setPoints] = useState(null); + const [busy, setBusy] = useState(null); + const [step, setStep] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [restoring, setRestoring] = useState(null); + const [running, setRunning] = useState(false); + + const load = useCallback(async () => { + setPoints(await api.backups(serverId)); + const s = await api.status(serverId); + setRunning(s.observed.world.state !== "stopped" || s.observed.auth.state !== "stopped"); + }, [serverId]); + + useEffect(() => { + void load().catch((e) => setError(asUiError(e))); + const un = listen("backup-progress", (e) => setStep(e.payload)); + return () => { + void un.then((f) => f()); + }; + }, [load]); + + async function run(label: string, fn: () => Promise) { + setBusy(label); + setError(null); + setNotice(null); + setStep(null); + try { + const msg = await fn(); + if (msg) setNotice(msg); + await load(); + } catch (e) { + setError(asUiError(e)); + } finally { + setBusy(null); + setStep(null); + } + } + + const create = (kind: BackupKind) => + run("create", async () => { + await api.createBackup(serverId, kind); + return "Backup created."; + }); + + const last = points?.[0]; + + return ( +
+

Backups

+

Is my data safe?

+ + +
+ +
+

{last ? `Last backup: ${when(last.created_at)}` : "No backups yet"}

+

Backups are kept next to your server and never overwrite your live data.

+
+
+
+ + + + {busy === "create" && {step ?? "Working…"}} +
+
+ + {notice &&

{notice}

} + {error && ( + +

{error.human.code === "unknown" ? "That did not work" : error.human.title}

+

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

+
+ )} + + {restoring && ( + +

Restore from {TRIGGER[restoring.trigger] ?? restoring.trigger}, {when(restoring.created_at)}

+

+ Nothing is deleted. Your current data is saved first, and a replaced database is kept alongside under a new name. +

+ {running &&

Stop the server first — databases can only be restored while it is stopped.

} +
+ {restoring.components.map((c) => + c.name === "configs" ? ( + + ) : ( + + ), + )} + +
+
+ )} + +
+ {points === null &&

Loading…

} + {points?.length === 0 &&

Your backups will appear here.

} + {points?.map((p) => ( +
+
+

{p.label && p.trigger === "manual" && p.label !== "e2e" ? p.label : (TRIGGER[p.trigger] ?? p.trigger)}

+

+ {when(p.created_at)} · {KIND[p.kind]} · {size(p.components.reduce((a, c) => a + c.bytes, 0))} +

+
+
+ + + +
+
+ ))} +
+
+ ); +} diff --git a/src/screens/Shell.tsx b/src/screens/Shell.tsx index e676443..f4369c4 100644 --- a/src/screens/Shell.tsx +++ b/src/screens/Shell.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"; import { Overview } from "@/screens/Overview"; import { Placeholder } from "@/screens/Placeholder"; import { SettingsPage } from "@/screens/SettingsPage"; +import { BackupsPage } from "@/screens/BackupsPage"; import { Button } from "@/components/ui/button"; type Page = "overview" | "bots" | "server" | "players" | "friends" | "backups" | "console" | "settings"; @@ -79,6 +80,8 @@ export function Shell(props: { props.onForget(server.id)} /> ) : page === "bots" || page === "server" ? ( + ) : page === "backups" ? ( + ) : ( )}