mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 20:01:01 +00:00
Offer signed intermediate upgrades for older server installations
This commit is contained in:
1 parent
093dbfcadc
commit
16396407ad
16 files changed
+217
-12
No files matched your search
Generated
+3
-3
@@ -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
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user