diff --git a/Cargo.lock b/Cargo.lock index 285a37e..0fd30ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.17" +version = "0.6.18" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.17" +version = "0.6.18" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.17" +version = "0.6.18" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 9ea449f..4784707 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/coa-core", "crates/coa-release", "src-tauri"] [workspace.package] -version = "0.6.17" +version = "0.6.18" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261016-intermediate-updates.md b/changelog.d/20261016-intermediate-updates.md new file mode 100644 index 0000000..8e8cc68 --- /dev/null +++ b/changelog.d/20261016-intermediate-updates.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Older servers find a tested intermediate update instead of being blocked +--- +When the latest release cannot update an older server directly, Manager offers a signed intermediate release from its compatibility matrix and explains how to continue to the latest version. diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 3476ebf..174917b 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -44,6 +44,7 @@ pub mod release; pub mod report; pub mod release_schema; pub mod update; +pub mod update_route; mod update_isolation; mod mysql_snapshot; pub mod wine; diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 7737e3a..1ececb9 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -296,6 +296,8 @@ pub fn plan(root: &Path, meta: &InstallMeta, manifest: &Manifest, resolutions: & pub struct Preview { pub from_version: Option, pub to_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_version: Option, pub items: Vec, pub conflicts: Vec, pub migrations: usize, @@ -364,6 +366,7 @@ pub fn preview_with(root: &Path, meta: &InstallMeta, source: &Source, trusted_ke Ok(Preview { from_version: meta.core.version.clone(), to_version: m.version.clone(), + latest_version: None, conflicts: items.iter().filter(|i| i.action == Action::Conflict).map(|i| i.path.clone()).collect(), items, migrations: m.migrations.len(), @@ -404,7 +407,7 @@ fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { return Err(Error::Invalid(format!("This package targets {} rather than {platform}.", c.platform))); } if meta.core.version.as_ref().is_none_or(|version| version != &manifest.version && !c.source_versions.contains(version)) { - return Err(Error::Invalid("This server version is outside the release's tested compatibility matrix.".into())); + return Err(Error::Invalid(format!("Installed server {} is outside the compatibility matrix for {}. Tested source versions: {}.", meta.core.version.as_deref().unwrap_or("unknown"), manifest.version, c.source_versions.join(", ")))); } } if let Some(installed) = meta.core.version.as_deref() { diff --git a/crates/coa-core/src/update_route.rs b/crates/coa-core/src/update_route.rs new file mode 100644 index 0000000..73cb9c7 --- /dev/null +++ b/crates/coa-core/src/update_route.rs @@ -0,0 +1,180 @@ +use std::collections::BTreeSet; + +use crate::{fsx, manifest::Manifest, pkgsource::{self, Source}, registry::InstallMeta, update, Error, Result}; + +const RELEASES: &str = "https://github.com/Corfirean/coa-server-build/releases/download/server-"; + +#[derive(Debug)] +pub struct Selection { + pub source: Source, + pub latest_version: Option, +} + +pub fn select(meta: &InstallMeta, source: Source, key: &str) -> Result { + select_with(meta, source, &mut |source| pkgsource::fetch_manifest(source, key)) +} + +fn select_with(meta: &InstallMeta, source: Source, fetch: &mut impl FnMut(&Source) -> Result<(Manifest, Vec)>) -> Result { + let (manifest, _) = fetch(&source)?; + if update::validate_candidate(meta, &manifest).is_ok() { + return Ok(Selection { source, latest_version: None }); + } + let original = update::validate_candidate(meta, &manifest).unwrap_err(); + if !matches!(&source, Source::Url(url) if url.trim_end_matches('/') == format!("{RELEASES}{}", manifest.version)) { + return Err(original); + } + let Some(installed) = meta.core.version.as_deref().and_then(crate::manifest::parse_version) else { return Err(original); }; + if manifest.kind != crate::manifest::Kind::Update + || manifest.compatibility.as_ref().is_none_or(|c| c.platform != if cfg!(windows) { "windows-x86_64" } else { "linux-x86_64" }) + || !manifest.compatible_with_manager(crate::MANAGER_VERSION) + || crate::manifest::parse_version(&manifest.version).is_none_or(|version| version <= installed) { + return Err(original); + } + let mut visited = BTreeSet::new(); + let Some(source) = find(meta, &manifest, fetch, &mut visited, 0)? else { return Err(original); }; + Ok(Selection { source, latest_version: Some(manifest.version) }) +} + +fn find(meta: &InstallMeta, manifest: &Manifest, fetch: &mut impl FnMut(&Source) -> Result<(Manifest, Vec)>, visited: &mut BTreeSet, depth: usize) -> Result> { + if depth >= 8 || visited.len() >= 32 { return Ok(None); } + let Some(compatibility) = &manifest.compatibility else { return Ok(None); }; + let Some(installed) = meta.core.version.as_deref().and_then(crate::manifest::parse_version) else { return Ok(None); }; + let target = crate::manifest::parse_version(&manifest.version).unwrap(); + let mut versions: Vec<_> = compatibility.source_versions.iter().filter_map(|version| { + crate::manifest::parse_version(version).filter(|number| *number > installed && *number < target).map(|number| (number, version)) + }).collect(); + versions.sort_by(|left, right| right.0.cmp(&left.0)); + for (_, version) in versions { + let Some(database) = compatibility.source_databases.get(version) else { continue; }; + if !visited.insert(version.clone()) || visited.len() > 32 { continue; } + let source = Source::Url(format!("{RELEASES}{version}")); + let (bridge, bytes) = fetch(&source)?; + if bridge.version != *version || fsx::sha256_bytes(&bytes) != database.manifest_sha256 { + return Err(Error::InvalidManifest("The intermediate update differs from the signed compatibility matrix.".into())); + } + if update::validate_candidate(meta, &bridge).is_ok() { return Ok(Some(source)); } + if bridge.kind != crate::manifest::Kind::Update || !bridge.compatible_with_manager(crate::MANAGER_VERSION) + || bridge.compatibility.as_ref().is_none_or(|c| c.platform != compatibility.platform) { continue; } + if let Some(source) = find(meta, &bridge, fetch, visited, depth + 1)? { return Ok(Some(source)); } + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::STANDARD, Engine}; + use ed25519_dalek::{Signer, SigningKey}; + use std::{fs, path::Path}; + + fn package(version: &str, from: &str, digest: &str) -> serde_json::Value { + serde_json::json!({"schema":1,"kind":"update","version":version,"core":{"commit":"a".repeat(40)}, + "builtAt":"test","minManagerVersion":"0.6.16","files":[],"migrations":[],"archive":null, + "compatibility":{"schema":1,"platform":if cfg!(windows) {"windows-x86_64"} else {"linux-x86_64"}, + "coreCommit":"a".repeat(40),"moduleCommits":{"squid":"b".repeat(40)},"clientPatchVersion":"1.7", + "sourceVersions":[from],"sourceDatabases":{from:{"manifestSha256":digest,"schemaSha256":null}}}}) + } + + fn meta(version: &str) -> InstallMeta { + let mut meta = InstallMeta::new(crate::registry::InstallKind::New, Path::new("unused")); + meta.core.version = Some(version.into()); + meta + } + + fn signed_fixture(values: &[serde_json::Value], tamper: Option<&str>) -> (tempfile::TempDir, String) { + let folder = tempfile::tempdir().unwrap(); + let signing = SigningKey::from_bytes(&[37; 32]); + for value in values { + let version = value["version"].as_str().unwrap(); + let dir = folder.path().join(version); + fs::create_dir(&dir).unwrap(); + let bytes = serde_json::to_vec(value).unwrap(); + fs::write(dir.join("manifest.json.sig"), STANDARD.encode(signing.sign(&bytes).to_bytes())).unwrap(); + fs::write(dir.join("manifest.json"), &bytes).unwrap(); + if tamper == Some(version) { fs::write(dir.join("manifest.json"), b"tampered").unwrap(); } + } + (folder, STANDARD.encode(signing.verifying_key().to_bytes())) + } + + fn select_fixture(values: &[serde_json::Value], installed: &str, tamper: Option<&str>) -> Result { + let (folder, key) = signed_fixture(values, tamper); + let latest = values.last().unwrap()["version"].as_str().unwrap(); + select_with(&meta(installed), Source::Url(format!("{RELEASES}{latest}")), &mut |source| { + let Source::Url(url) = source else { unreachable!() }; + let version = url.strip_prefix(RELEASES).unwrap(); + pkgsource::fetch_manifest(&Source::Dir(folder.path().join(version)), &key) + }) + } + + fn pair() -> Vec { + let bridge = package("1.1.0", "1.0.0", &"c".repeat(64)); + let digest = fsx::sha256_bytes(&serde_json::to_vec(&bridge).unwrap()); + vec![bridge, package("1.2.0", "1.1.0", &digest)] + } + + #[test] + fn finds_a_signed_intermediate_without_relaxing_candidate_validation() { + let selection = select_fixture(&pair(), "1.0.0", None).unwrap(); + assert!(matches!(selection.source, Source::Url(url) if url.ends_with("1.1.0"))); + assert_eq!(selection.latest_version.as_deref(), Some("1.2.0")); + let latest = Manifest::parse(&serde_json::to_vec(pair().last().unwrap()).unwrap()).unwrap(); + assert!(update::validate_candidate(&meta("1.0.0"), &latest).is_err()); + } + + #[test] + fn direct_updates_and_current_versions_do_not_request_an_intermediate() { + for installed in ["1.1.0", "1.2.0"] { + let selection = select_fixture(&pair(), installed, None).unwrap(); + assert!(selection.latest_version.is_none()); + } + } + + #[test] + fn rejects_tampered_or_replaced_intermediate_manifests() { + assert!(select_fixture(&pair(), "1.0.0", Some("1.1.0")).is_err()); + let mut values = pair(); + values[0]["builtAt"] = "replaced".into(); + assert!(select_fixture(&values, "1.0.0", None).unwrap_err().to_string().contains("differs")); + } + + #[test] + fn follows_multiple_tested_edges_and_rejects_missing_paths() { + let mut values = pair(); + let digest = fsx::sha256_bytes(&serde_json::to_vec(values.last().unwrap()).unwrap()); + values.push(package("1.3.0", "1.2.0", &digest)); + let selection = select_fixture(&values, "1.0.0", None).unwrap(); + assert!(matches!(selection.source, Source::Url(url) if url.ends_with("1.1.0"))); + assert!(select_fixture(&values, "0.9.0", None).is_err()); + assert!(select_fixture(&values, "2.0.0", None).is_err()); + } + + #[test] + fn never_routes_custom_sources_or_unknown_installations() { + let values = pair(); + let (folder, key) = signed_fixture(&values, None); + let mut calls = 0; + let result = select_with(&meta("1.0.0"), Source::Dir(folder.path().join("1.2.0")), &mut |source| { + calls += 1; + pkgsource::fetch_manifest(source, &key) + }); + assert!(result.is_err()); + assert_eq!(calls, 1); + assert!(select_fixture(&values, "unknown", None).is_err()); + } + + #[test] + fn rejects_incompatible_platforms_and_manager_versions() { + for index in [0, 1] { + for (field, value) in [("minManagerVersion", "99.0.0"), ("platform", "other-platform")] { + let mut values = pair(); + if field == "platform" { values[index]["compatibility"][field] = value.into(); } + else { values[index][field] = value.into(); } + if index == 0 { + let digest = fsx::sha256_bytes(&serde_json::to_vec(&values[0]).unwrap()); + values[1]["compatibility"]["sourceDatabases"]["1.1.0"]["manifestSha256"] = digest.into(); + } + assert!(select_fixture(&values, "1.0.0", None).is_err()); + } + } + } +} diff --git a/crates/coa-release/src/main.rs b/crates/coa-release/src/main.rs index 0b08488..d3a4faf 100644 --- a/crates/coa-release/src/main.rs +++ b/crates/coa-release/src/main.rs @@ -47,6 +47,13 @@ fn run() -> Result<(), String> { let e = |x: coa_core::Error| x.to_string(); let part = a.get("part-size").and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_PART_SIZE); match cmd.as_str() { + "plan-update" => { + let mut meta = coa_core::registry::InstallMeta::new(coa_core::registry::InstallKind::New, std::path::Path::new("unused")); + meta.core.version = Some(need(&a, "from")?.clone()); + let selection = coa_core::update_route::select(&meta, coa_core::pkgsource::Source::Url(need(&a, "source")?.clone()), coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; + let (manifest, _) = coa_core::pkgsource::fetch_manifest(&selection.source, coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; + println!("{}", serde_json::json!({"from":meta.core.version,"next":manifest.version,"latest":selection.latest_version})); + } "verify-install" => { coa_core::release_schema::verify_install_package( &PathBuf::from(need(&a, "dir")?), coa_core::signing::EMBEDDED_PUBLIC_KEY, diff --git a/package-lock.json b/package-lock.json index 1eb5436..5b24704 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.17", + "version": "0.6.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.17", + "version": "0.6.18", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 0332207..b20dd5f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.17", + "version": "0.6.18", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6656434..7964346 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -847,8 +847,10 @@ async fn check_update(state: State<'_, AppState>, id: String, source: Option> = { "upd.folderHint": "Папка с пакетом обновления (оставьте пустым, чтобы использовать официальный источник)", "upd.available": "Доступна версия {to}", "upd.availableFrom": "Доступна версия {to} (у вас {from})", + "upd.intermediate": "Сначала обновитесь до {to}, затем проверьте обновления ещё раз для перехода на {latest}. Каждый этап использует проверенный путь обновления.", "upd.summary": "Загрузка {size} · изменится файлов: {files}", "upd.summaryDb": "Загрузка {size} · изменится файлов: {files} · обновлений базы данных: {db}", "upd.conflict": "Этот файл был изменён вне CoA Server Manager.", diff --git a/src/lib/api.ts b/src/lib/api.ts index 2b34c11..f1a4fc4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -307,6 +307,7 @@ export interface UpdatePlanItem { export interface UpdatePreview { from_version: string | null; to_version: string; + latest_version?: string; items: UpdatePlanItem[]; conflicts: string[]; migrations: number; diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index 643bdab..d2e162f 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -545,6 +545,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o {upd.available && upd.preview && (

{t("overview.updateAvail", { to: upd.preview.to_version, from: upd.preview.from_version ?? "?" })}

+ {upd.preview.latest_version &&

{t("upd.intermediate", { to: upd.preview.to_version, latest: upd.preview.latest_version })}

} {needsDecision &&

{t("overview.updateConflicts")}

} {upd.applying && (
diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx index 525cdd9..ff2197f 100644 --- a/src/screens/SettingsHome.tsx +++ b/src/screens/SettingsHome.tsx @@ -234,6 +234,7 @@ export function SettingsHome({ serverId, path, onForget }: { serverId: string; p

{preview.from_version ? t("upd.availableFrom", { to: preview.to_version, from: preview.from_version }) : t("upd.available", { to: preview.to_version })}

+ {preview.latest_version &&

{t("upd.intermediate", { to: preview.to_version, latest: preview.latest_version })}

}

{!preview.database_check_error && (preview.pending_migrations ?? preview.migrations) > 0 ? t("upd.summaryDb", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length, db: preview.pending_migrations ?? preview.migrations })