Offer signed intermediate upgrades for older server installations

This commit is contained in:
Дмитрий committed 2026-10-16 05:20:12 +05:00
1 parent 093dbfcadc
commit 16396407ad
16 files changed
+217 -12

No files matched your search

Generated
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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"
@@ -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.
+1
View File
@@ -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;
+4 -1
View File
@@ -296,6 +296,8 @@ pub fn plan(root: &Path, meta: &InstallMeta, manifest: &Manifest, resolutions: &
pub struct Preview {
pub from_version: Option<String>,
pub to_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_version: Option<String>,
pub items: Vec<PlanItem>,
pub conflicts: Vec<String>,
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() {
+180
View File
@@ -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<String>,
}
pub fn select(meta: &InstallMeta, source: Source, key: &str) -> Result<Selection> {
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<u8>)>) -> Result<Selection> {
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<u8>)>, visited: &mut BTreeSet<String>, depth: usize) -> Result<Option<Source>> {
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<Selection> {
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<serde_json::Value> {
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());
}
}
}
}
+7
View File
@@ -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,
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+5 -3
View File
@@ -847,8 +847,10 @@ async fn check_update(state: State<'_, AppState>, id: String, source: Option<Str
let access = if background.unwrap_or(false) { update::DatabaseAccess::OnlyIfRunning } else { update::DatabaseAccess::Start };
blocking(move || {
let (_, meta) = install_meta(&root)?;
let src = update_source(source)?;
update::preview_with(&root, &meta, &src, coa_core::signing::EMBEDDED_PUBLIC_KEY, &Default::default(), access)
let selection = coa_core::update_route::select(&meta, update_source(source)?, coa_core::signing::EMBEDDED_PUBLIC_KEY)?;
let mut preview = update::preview_with(&root, &meta, &selection.source, coa_core::signing::EMBEDDED_PUBLIC_KEY, &Default::default(), access)?;
preview.latest_version = selection.latest_version;
Ok(preview)
})
.await
}
@@ -871,8 +873,8 @@ async fn apply_update(
let _guard = BusyGuard::acquire(&state, &id)?;
blocking(move || {
let src = update_source(source)?;
let (dir, meta) = install_meta(&root)?;
let src = coa_core::update_route::select(&meta, update_source(source)?, coa_core::signing::EMBEDDED_PUBLIC_KEY)?.source;
let (manifest, _) = coa_core::pkgsource::fetch_manifest(&src, coa_core::signing::EMBEDDED_PUBLIC_KEY)?;
update::validate_candidate(&meta, &manifest)?;
// Files cannot be replaced while the server runs: stop it first (gracefully), like the Stop button.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CoA Server Manager",
"version": "0.6.17",
"version": "0.6.18",
"identifier": "com.coa.servermanager",
"build": {
"beforeDevCommand": "npm run dev",
+1
View File
@@ -430,6 +430,7 @@ const en = {
"upd.folderHint": "Update package folder (leave empty to use the official source)",
"upd.available": "Version {to} is available",
"upd.availableFrom": "Version {to} is available (you have {from})",
"upd.intermediate": "Update to {to} first, then check again to continue to {latest}. Each step uses a tested upgrade path.",
"upd.summary": "Download {size} · {files} file(s) change",
"upd.summaryDb": "Download {size} · {files} file(s) change · {db} database update(s)",
"upd.conflict": "This file was modified outside CoA Server Manager.",
+1
View File
@@ -412,6 +412,7 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"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.",
+1
View File
@@ -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;
+1
View File
@@ -545,6 +545,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
{upd.available && upd.preview && (
<div className="mt-4 rounded-md border border-gold/40 bg-gold/5 p-3 text-sm">
<p>{t("overview.updateAvail", { to: upd.preview.to_version, from: upd.preview.from_version ?? "?" })}</p>
{upd.preview.latest_version && <p>{t("upd.intermediate", { to: upd.preview.to_version, latest: upd.preview.latest_version })}</p>}
{needsDecision && <p className="mt-1 text-muted">{t("overview.updateConflicts")}</p>}
{upd.applying && (
<div className="mt-2">
+1
View File
@@ -234,6 +234,7 @@ export function SettingsHome({ serverId, path, onForget }: { serverId: string; p
<p className="font-medium">
{preview.from_version ? t("upd.availableFrom", { to: preview.to_version, from: preview.from_version }) : t("upd.available", { to: preview.to_version })}
</p>
{preview.latest_version && <p className="mt-1 text-sm text-warn" role="status">{t("upd.intermediate", { to: preview.to_version, latest: preview.latest_version })}</p>}
<p className="mt-1 text-sm text-muted">
{!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 })