From 113f7c98dac6af5477ad748396395587c217ce0b Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:10:03 +0800 Subject: [PATCH 1/2] Export, import and local backups on the control plane and the CLI - Endpoints: /config/items, /config/export, /config/import/preview, /config/import, /backups, /backups/file, /backups/preview, /backups/restore, /client-prefs, /pinned-keys. Import goes item by item (new, same, conflict, error; import, overwrite, rename, skip) and says what each row refers to; restore can take a subset of items, keeps the local settings, ChatGPT sign-ins and pinned keys, and in safe mode brings a version back whole with the current control key. - Local backups replace the change history: every write leaves a full configuration pack (plugins and client preferences included, control key masked) before and after, the last 10 are kept, rotations make none, and the old history files go. /config/history and /config/rollback are removed; ConfigOrigin drops rollback and gains sync, import, restore, repair. - New events gateway_keys_changed and client_prefs_changed. Over a remote connection the client preferences stay in the app. - RemoteBackups lets sync add the versions of the sync location to the list; pinned keys are kept in the data directory. - A disabled upstream may lack its credential, which is how imported ones arrive. A plugin that can rewrite tool calls is never approved by an import or a restore. - CLI: twcore export / import / backup list / backup restore; config history and config rollback are gone. - Control API version 50. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 + bin/twcore/Cargo.toml | 2 + bin/twcore/src/main.rs | 117 +-- bin/twcore/src/pack_cli.rs | 403 +++++++++++ crates/tw-api/msg-codes.txt | 17 +- crates/tw-api/src/ep.rs | 30 +- crates/tw-api/src/lib.rs | 76 +- crates/tw-api/src/pack.rs | 382 ++++++++++ crates/tw-api/src/ts.rs | 2 +- crates/tw-config/src/control_key.rs | 3 +- crates/tw-config/src/credential.rs | 40 +- crates/tw-config/src/history.rs | 498 ++----------- crates/tw-config/src/remote.rs | 7 +- crates/tw-config/src/wire.rs | 7 +- crates/tw-control/Cargo.toml | 3 + crates/tw-control/src/backups.rs | 794 +++++++++++++++++++++ crates/tw-control/src/config.rs | 86 ++- crates/tw-control/src/lib.rs | 50 +- crates/tw-control/tests/backups.rs | 566 +++++++++++++++ crates/tw-control/tests/contract.rs | 8 +- crates/tw-control/tests/gate.rs | 20 +- crates/tw-control/tests/m2_acceptance.rs | 38 +- crates/tw-control/tests/plugin_defaults.rs | 4 +- crates/tw-control/tests/plugins.rs | 20 +- crates/tw-control/tests/pricing.rs | 2 +- crates/tw-control/tests/resources.rs | 15 +- crates/tw-control/tests/sync.rs | 32 +- crates/tw-gateway/src/plugin/source.rs | 135 ++++ crates/tw-observe/src/bus.rs | 2 + crates/tw-pack/Cargo.toml | 2 + crates/tw-pack/src/backup.rs | 445 ++++++++++++ crates/tw-pack/src/disk.rs | 303 ++++++++ crates/tw-pack/src/export.rs | 227 ++++++ crates/tw-pack/src/import.rs | 589 +++++++++++++++ crates/tw-pack/src/import/tests.rs | 524 ++++++++++++++ crates/tw-pack/src/lib.rs | 13 +- crates/tw-pack/src/model/items.rs | 11 +- crates/tw-pack/src/model/mod.rs | 12 + crates/tw-pack/src/model/tests.rs | 7 +- crates/tw-pack/src/model/write.rs | 49 +- crates/tw-pack/src/restore.rs | 187 +++++ crates/tw-pack/src/restore/tests.rs | 105 +++ crates/tw-store/src/recorder.rs | 3 + docs/config.md | 37 +- docs/config.zh-CN.md | 20 +- 45 files changed, 5187 insertions(+), 710 deletions(-) create mode 100644 bin/twcore/src/pack_cli.rs create mode 100644 crates/tw-api/src/pack.rs create mode 100644 crates/tw-control/src/backups.rs create mode 100644 crates/tw-control/tests/backups.rs create mode 100644 crates/tw-pack/src/backup.rs create mode 100644 crates/tw-pack/src/disk.rs create mode 100644 crates/tw-pack/src/export.rs create mode 100644 crates/tw-pack/src/import.rs create mode 100644 crates/tw-pack/src/import/tests.rs create mode 100644 crates/tw-pack/src/restore.rs create mode 100644 crates/tw-pack/src/restore/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 03cbac05..7f74297d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3759,6 +3759,7 @@ dependencies = [ "tw-guard", "tw-link", "tw-observe", + "tw-pack", "tw-pricing", "tw-secret", "tw-store", @@ -3894,6 +3895,8 @@ dependencies = [ "sha2 0.11.0", "tempfile", "thiserror 2.0.20", + "tracing", + "tw-api", "tw-config", "tw-engine", "tw-pricing", @@ -4015,6 +4018,7 @@ dependencies = [ "tw-guard", "tw-link", "tw-observe", + "tw-pack", "tw-pricing", "tw-secret", "tw-store", diff --git a/bin/twcore/Cargo.toml b/bin/twcore/Cargo.toml index 3d2d9e65..8455b64c 100644 --- a/bin/twcore/Cargo.toml +++ b/bin/twcore/Cargo.toml @@ -16,6 +16,8 @@ categories.workspace = true [dependencies] tw-config = { workspace = true } tw-control = { workspace = true } +# `twcore export` / `import` / `backup`:配置包和本机备份 +tw-pack = { workspace = true } tw-gateway = { workspace = true } tw-yaml = { workspace = true } tw-api = { workspace = true } diff --git a/bin/twcore/src/main.rs b/bin/twcore/src/main.rs index 22466920..391ab9d1 100644 --- a/bin/twcore/src/main.rs +++ b/bin/twcore/src/main.rs @@ -11,6 +11,7 @@ use clap::{Parser, Subcommand}; mod call; mod lockfile; +mod pack_cli; mod proc; mod upgrade; @@ -103,6 +104,40 @@ enum Command { #[arg(short, long)] out: Option, }, + /// Export the configuration as a configuration pack (.twconfig) + Export { + /// Export only these upstreams (comma-separated), with what they refer to + #[arg(long, value_delimiter = ',')] + providers: Vec, + /// Leave the keys out: upstream keys, header values, AWS keys, proxy passwords and + /// gateway keys are left empty + #[arg(long)] + no_secrets: bool, + /// Encrypt the file with a passphrase, asked for or taken from TWCORE_PASSPHRASE + #[arg(long)] + encrypt: bool, + /// The file to write + #[arg(short, long)] + out: PathBuf, + }, + /// Import a configuration pack: what is not here is added, and what differs is chosen item + /// by item + Import { + /// The configuration pack (.twconfig) + file: PathBuf, + /// Show what would happen and change nothing + #[arg(long)] + dry_run: bool, + /// What to do with every item that differs from the one here; asked item by item + /// without it + #[arg(long, value_enum)] + on_conflict: Option, + }, + /// The last versions of the configuration, and restoring one + Backup { + #[command(subcommand)] + what: BackupCmd, + }, /// Replace this twcore with another release from GitHub; the configuration is left alone // // 只给单独装的 twcore 用(服务器上)。桌面应用包里的那份由应用更新,见 upgrade.rs @@ -140,6 +175,21 @@ enum RemoteCmd { Disable, } +#[derive(Subcommand)] +enum BackupCmd { + /// List the versions, newest first + List, + /// Go back to a version. Settings that belong to this machine, ChatGPT sign-ins and the + /// gateway keys clients set up here use are kept + Restore { + /// The id from `twcore backup list` + id: String, + /// Show what would change and change nothing + #[arg(long)] + dry_run: bool, + }, +} + #[derive(Subcommand)] enum ConfigCmd { /// Print the configuration as it is, with its version @@ -159,10 +209,6 @@ enum ConfigCmd { #[arg(long, conflicts_with_all = ["int", "bool_value"])] null: bool, }, - /// List the versions in the history - History, - /// Roll back to a version - Rollback { version: String }, } fn main() -> Result<()> { @@ -182,6 +228,8 @@ fn main() -> Result<()> { let cli = Cli::parse(); let path = cli.config.clone().unwrap_or_else(tw_config::default_path); + // 配置包、备份按「代码」「数据」分开比较插件:拆法要插件那一套 + tw_control::backups::install_codec(); match cli.command { Command::Init { force } => cmd_init(&path, force), @@ -191,6 +239,21 @@ fn main() -> Result<()> { Command::Config { what } => cmd_config(&path, what), Command::ControlKey { rotate } => cmd_control_key(&path, rotate), Command::Remote { what } => cmd_remote(&path, what.unwrap_or(RemoteCmd::Show)), + Command::Export { + providers, + no_secrets, + encrypt, + out, + } => pack_cli::export(&path, providers, no_secrets, encrypt, out), + Command::Import { + file, + dry_run, + on_conflict, + } => pack_cli::import(&path, &file, dry_run, on_conflict), + Command::Backup { what } => match what { + BackupCmd::List => pack_cli::list(&path), + BackupCmd::Restore { id, dry_run } => pack_cli::restore(&path, &id, dry_run), + }, Command::Call { path: endpoint, method, @@ -212,7 +275,7 @@ fn main() -> Result<()> { /// 改配置。 /// /// **不连控制面,直接操作文件。**理由是这个命令必须在 core 没跑的时候 -/// 也能用 —— 「配置写坏了导致 core 起不来」正是最需要 `config rollback` +/// 也能用 —— 「配置写坏了导致 core 起不来」正是最需要 `backup restore` /// 的时刻,而那时控制面根本不存在。 /// /// 代价是 core 正在跑时,改动要等它的文件监听发现(几百毫秒)。那条路 @@ -225,40 +288,6 @@ fn cmd_config(path: &Path, what: ConfigCmd) -> Result<()> { eprintln!("── {} · {}", c.path.display(), c.version()); Ok(()) } - ConfigCmd::History => { - let all = tw_config::history::list(path)?; - if all.is_empty() { - println!("No versions yet; the first change to the configuration makes one."); - return Ok(()); - } - let now = tw_config::store::read(path).map(|c| c.version()).ok(); - // 新的在前 —— 要找的几乎总是最近那几版 - for v in all.iter().rev() { - let mark = if Some(&v.version) == now.as_ref() { - "← current" - } else { - " " - }; - println!( - "{mark} {} {:<10} {} bytes {}", - v.version, - v.origin.label(), - v.bytes, - fmt_time(v.at_ms) - ); - } - println!(); - println!( - "To roll back: twcore config rollback (the first few characters are enough)" - ); - Ok(()) - } - ConfigCmd::Rollback { version } => { - let text = tw_config::history::rollback(path, &version)?; - println!("rolled back to {}", tw_config::store::version_of(&text)); - eprintln!("(a running core picks this up within a second)"); - Ok(()) - } ConfigCmd::Set { path: pointer, value, @@ -293,10 +322,11 @@ fn cmd_config(path: &Path, what: ConfigCmd) -> Result<()> { let next = tw_yaml::set(&cur.text, &steps, &scalar)?; // **先校验再写。**写完才发现读不回来,那份坏配置已经在盘上了。 tw_config::try_parse(&next).map_err(|r| anyhow::anyhow!("{r}"))?; - // 改之前那一版进历史,这样这条命令也能被 rollback 撤销 - let _ = tw_config::history::snapshot(path, &cur.text, tw_config::history::Origin::Cli); + // 前后各留一个备份版本,这样这条命令也能被 `twcore backup restore` 撤销 + let _ = + tw_pack::backup::record_before(path, &cur.text, tw_config::history::Origin::Cli); tw_config::store::write_if_unchanged(path, &cur.fingerprint, &next)?; - let _ = tw_config::history::snapshot(path, &next, tw_config::history::Origin::Cli); + let _ = tw_pack::backup::record(path, &next, tw_config::history::Origin::Cli); println!( "changed; the new version is {}", tw_config::store::version_of(&next) @@ -884,6 +914,9 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> Some(r) => manager.standing_in(r), None => manager, }); + // 磁盘上此刻的样子和最新的备份版本不一样就留一版:第一次运行、升级之后、core 没在跑时 + // 命令行改过。配置读不进来时也留:一键修复之前的那一份在备份里 + manager.record_startup(); // 默认插件(随 core 发的那几个):没给过的装上(停用着),没动过的换成新版。 // 启动时在控制面起来之前走一遍,界面第一次取插件就看得到它们;之后每换入一份 // 配置再走一遍。**不挡启动**:哪个没办成只记一行、说一声。安全模式不走 —— diff --git a/bin/twcore/src/pack_cli.rs b/bin/twcore/src/pack_cli.rs new file mode 100644 index 00000000..dcc4c79e --- /dev/null +++ b/bin/twcore/src/pack_cli.rs @@ -0,0 +1,403 @@ +//! `twcore export` / `import` / `backup`:配置包和本机备份的命令行。 +//! +//! **不连控制面,直接操作文件**,和 `twcore config` 一样:没有界面的服务器(远程 core)、 +//! 配置坏到 core 起不来时都用得上。core 正在跑时,它的文件监听在一秒之内换入写下去的配置, +//! 插件目录的监听重读插件。 +//! +//! 规矩和界面上的一样(同一份 `tw-pack`):导入逐项加、冲突逐项选,恢复留着本机设置、 +//! ChatGPT 的登录和接管的客户端在用的密钥。 + +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use tw_config::history::Origin; +use tw_pack::model::{ItemId, ItemKind}; +use tw_pack::{Pack, PackOrigin}; + +/// 冲突怎么处理(`--on-conflict`) +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum OnConflict { + /// Replace the item here with the one in the pack + Overwrite, + /// Import it under a new name, such as `relay (2)` + Rename, + /// Keep the item here + Skip, +} + +/// 口令:环境变量 `TWCORE_PASSPHRASE`,没有就在终端上问(不回显) +fn passphrase(confirm: bool) -> Result { + if let Ok(p) = std::env::var("TWCORE_PASSPHRASE") { + return Ok(p); + } + let first = ask_hidden("Passphrase: ")?; + if confirm { + if first.chars().count() < tw_pack::MIN_PASSPHRASE { + bail!( + "the passphrase needs at least {} characters", + tw_pack::MIN_PASSPHRASE + ); + } + let again = ask_hidden("Passphrase again: ")?; + if again != first { + bail!("the two passphrases differ"); + } + } + Ok(first) +} + +fn ask_hidden(prompt: &str) -> Result { + eprint!("{prompt}"); + std::io::stderr().flush().ok(); + let _quiet = Quiet::on(); + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line)?; + eprintln!(); + Ok(line.trim_end_matches(['\r', '\n']).to_string()) +} + +/// 读口令时关掉终端的回显,走的时候恢复。不是终端(管道)就什么都不做。 +/// **只在 unix 上关**:Windows 的控制台照常回显,要藏起来就用环境变量 +struct Quiet { + #[cfg(unix)] + saved: Option, +} + +impl Quiet { + fn on() -> Self { + #[cfg(unix)] + { + // SAFETY: 只对标准输入调 tcgetattr / tcsetattr,传进去的是栈上的结构体 + unsafe { + let mut t: libc::termios = std::mem::zeroed(); + if libc::isatty(0) == 1 && libc::tcgetattr(0, &mut t) == 0 { + let saved = t; + t.c_lflag &= !libc::ECHO; + libc::tcsetattr(0, libc::TCSANOW, &t); + return Quiet { saved: Some(saved) }; + } + } + Quiet { saved: None } + } + #[cfg(not(unix))] + { + Quiet {} + } + } +} + +impl Drop for Quiet { + fn drop(&mut self) { + #[cfg(unix)] + if let Some(t) = self.saved { + // SAFETY: 同上 + unsafe { + libc::tcsetattr(0, libc::TCSANOW, &t); + } + } + } +} + +fn ask(prompt: &str) -> Result { + eprint!("{prompt}"); + std::io::stderr().flush().ok(); + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line)?; + Ok(line.trim().to_lowercase()) +} + +fn current(path: &Path, origin: PackOrigin) -> Result<(tw_config::Loaded, Pack)> { + let cur = tw_config::store::read(path)?; + let pack = tw_pack::disk::current(path, &cur.text, origin); + Ok((cur, pack)) +} + +fn model(e: tw_pack::model::ModelError) -> anyhow::Error { + anyhow!("{e}") +} + +/// 写下去:插件文件、通用偏好、配置,配置没写成就把前两样还原。前后各留一个备份版本 +fn write_back( + path: &Path, + cur: &tw_config::Loaded, + before: &Pack, + after: &Pack, + origin: Origin, +) -> Result<()> { + let _ = tw_pack::backup::record_before(path, &cur.text, origin); + let undo = tw_pack::disk::write_plugins(path, &before.plugins, &after.plugins) + .context("writing the plugin files")?; + let prefs_changed = after.client_prefs != before.client_prefs; + if prefs_changed && let Err(e) = tw_pack::disk::write_prefs(path, after.client_prefs.as_ref()) { + undo.restore(); + return Err(e).context("writing the client preferences"); + } + if let Err(e) = tw_config::store::write_if_unchanged(path, &cur.fingerprint, &after.config_yaml) + { + undo.restore(); + if prefs_changed { + let _ = tw_pack::disk::write_prefs(path, before.client_prefs.as_ref()); + } + return Err(e.into()); + } + let _ = tw_pack::backup::record(path, &after.config_yaml, origin); + Ok(()) +} + +fn describe(id: &ItemId) -> String { + let kind = match id.kind { + ItemKind::Provider => "upstream", + ItemKind::Proxy => "proxy", + ItemKind::Key => "gateway key", + ItemKind::Alias => "alias", + ItemKind::Group => "group", + ItemKind::Route => "route", + ItemKind::PriceSheet => "price sheet", + ItemKind::SecurityRule => "custom rule", + ItemKind::Plugin => "plugin", + ItemKind::ProviderOrder => "the order of upstreams", + ItemKind::RouteOrder => "the order of routes", + ItemKind::PluginOrder => "the order of plugins", + ItemKind::Global => "settings", + ItemKind::ClientPrefs => "the app's preferences", + }; + if id.name.is_empty() { + kind.to_string() + } else { + format!("{kind} `{}`", id.name) + } +} + +// ─────────────────────────────────────────────────────────── export + +pub fn export( + path: &Path, + providers: Vec, + no_secrets: bool, + encrypt: bool, + out: PathBuf, +) -> Result<()> { + let (_, local) = current(path, PackOrigin::Export)?; + let scope = (!providers.is_empty()).then(|| { + providers + .iter() + .map(|p| ItemId::new(ItemKind::Provider, p.trim())) + .collect::>() + }); + if let Some(ids) = &scope { + let snap = tw_pack::model::snapshot(&local).map_err(model)?; + if let Some(missing) = ids.iter().find(|id| !snap.items.contains_key(*id)) { + bail!("there is no upstream named `{}`", missing.name); + } + } + let exported = tw_pack::export::export( + &local, + tw_pack::export::Options { + scope, + secrets: !no_secrets, + client_prefs: None, + origin: PackOrigin::Cli, + }, + ) + .map_err(model)?; + let pass = encrypt.then(|| passphrase(true)).transpose()?; + let seal = match &pass { + Some(p) => tw_pack::Seal::Passphrase(p), + None => tw_pack::Seal::None, + }; + let bytes = tw_pack::write(&exported.pack, seal).map_err(|e| anyhow!("{e}"))?; + tw_pack::disk::write_private(&out, &bytes) + .with_context(|| format!("writing {}", out.display()))?; + println!( + "exported {} items to {}", + exported.included.len(), + out.display() + ); + if !exported.omitted_logins.is_empty() { + println!( + "not exported: the ChatGPT sign-in of {}; sign in again after importing", + exported.omitted_logins.join(", ") + ); + } + if !no_secrets && pass.is_none() { + eprintln!("the file holds keys in plain text; keep it safe"); + } + Ok(()) +} + +// ─────────────────────────────────────────────────────────── import + +fn read_pack(file: &Path) -> Result { + let bytes = std::fs::read(file).with_context(|| format!("reading {}", file.display()))?; + let pass = tw_pack::is_encrypted(&bytes) + .then(|| passphrase(false)) + .transpose()?; + let unseal = match &pass { + Some(p) => tw_pack::Unseal::Passphrase(p), + None => tw_pack::Unseal::None, + }; + tw_pack::read(&bytes, unseal).map_err(|e| anyhow!("{e}")) +} + +pub fn import( + path: &Path, + file: &Path, + dry_run: bool, + on_conflict: Option, +) -> Result<()> { + use tw_pack::import::{Action, Decision, Status}; + let incoming = read_pack(file)?; + let (cur, local) = current(path, PackOrigin::Import)?; + let ctx = tw_pack::import::Context { + has_aws_profile: &|p| tw_config::aws_profile::credentials(p).is_ok(), + new_key: &tw_config::generate_key, + with_prefs: true, + }; + let rows = tw_pack::import::preview(&local, &incoming, &ctx).map_err(|e| anyhow!("{e}"))?; + let mut decisions = Vec::new(); + for r in &rows { + let what = describe(&r.item); + let action = match r.status { + Status::Same => { + println!(" same {what}"); + continue; + } + Status::Error => { + let why = r.error.as_ref().map(|e| e.text.clone()).unwrap_or_default(); + println!(" error {what}: {why}"); + continue; + } + Status::New => { + println!(" new {what}"); + Action::Import + } + Status::Conflict => { + println!(" differs {what} ({})", r.differs.join(", ")); + let choice = match on_conflict { + Some(c) => c, + None if dry_run => continue, + None => loop { + let a = if r.global { + ask(" [o]verwrite or [s]kip? ")? + } else { + ask(" [o]verwrite, [r]ename or [s]kip? ")? + }; + match a.as_str() { + "o" | "overwrite" => break OnConflict::Overwrite, + "r" | "rename" if !r.global => break OnConflict::Rename, + "s" | "skip" => break OnConflict::Skip, + _ => {} + } + }, + }; + match (choice, &r.suggested_name) { + (OnConflict::Overwrite, _) => Action::Overwrite, + (OnConflict::Rename, Some(n)) => Action::Rename(n.clone()), + _ => Action::Skip, + } + } + }; + decisions.push(Decision { + item: r.item.clone(), + action, + }); + } + if dry_run { + println!("(dry run: nothing was written)"); + return Ok(()); + } + let applied = + tw_pack::import::apply(&local, &incoming, &decisions, &ctx).map_err(|e| anyhow!("{e}"))?; + write_back(path, &cur, &local, &applied.pack, Origin::Import)?; + println!( + "imported; the new version is {}", + tw_config::store::version_of(&applied.pack.config_yaml) + ); + eprintln!("(a running core picks this up within a second)"); + Ok(()) +} + +// ─────────────────────────────────────────────────────────── backup + +pub fn list(path: &Path) -> Result<()> { + let all = tw_pack::backup::list(path).map_err(|e| anyhow!("{e}"))?; + if all.is_empty() { + println!("No versions yet; the first change to the configuration makes one."); + return Ok(()); + } + let now = current(path, PackOrigin::Ui) + .ok() + .filter(|(_, p)| tw_pack::model::snapshot(p).is_ok()) + .map(|(_, p)| p.content_id()); + let mut marked = false; + for b in all.iter().rev() { + let Ok(pack) = tw_pack::backup::open(b) else { + continue; + }; + let current = !marked && Some(pack.content_id()) == now; + marked |= current; + println!( + "{} {} {} {:<22} upstreams {}, plugins {} core {}", + if current { "← current" } else { " " }, + b.id, + crate::fmt_time(b.at_ms), + b.origin.label(), + pack.manifest.counts.providers, + pack.manifest.counts.plugins, + pack.manifest.core_version, + ); + } + println!(); + println!("To restore one: twcore backup restore "); + Ok(()) +} + +pub fn restore(path: &Path, id: &str, dry_run: bool) -> Result<()> { + use tw_pack::restore::{Change, Kept}; + let b = tw_pack::backup::find(path, id).map_err(|e| anyhow!("{e}"))?; + let backup = tw_pack::backup::open(&b).map_err(|e| anyhow!("{e}"))?; + let (cur, local) = current(path, PackOrigin::Restore)?; + let pinned = tw_pack::disk::read_pinned(path); + let after = if tw_pack::model::snapshot(&local).is_err() { + println!("the configuration here does not load; the whole version comes back"); + tw_pack::restore::whole(&backup, &cur.text) + } else { + let rows = tw_pack::restore::preview(&local, &backup, &pinned, true).map_err(model)?; + if rows.is_empty() { + println!("this version is the same as the configuration now"); + return Ok(()); + } + for r in &rows { + let change = match r.change { + Change::Add => "add ", + Change::Remove => "remove", + Change::Modify => "change", + }; + let kept = match r.kept { + Some(Kept::KeyInUse) => " (kept: a client set up on this machine uses it)", + None => "", + }; + println!(" {change} {}{kept}", describe(&r.item)); + } + if dry_run { + println!("(dry run: nothing was written)"); + return Ok(()); + } + tw_pack::restore::apply(&local, &backup, None, &pinned, true) + .map_err(model)? + .pack + }; + if dry_run { + println!("(dry run: nothing was written)"); + return Ok(()); + } + tw_config::try_parse(&after.config_yaml).map_err(|r| anyhow!("{r}"))?; + write_back(path, &cur, &local, &after, Origin::Restore)?; + println!( + "restored; the new version is {}", + tw_config::store::version_of(&after.config_yaml) + ); + eprintln!("(a running core picks this up within a second)"); + Ok(()) +} diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index 41d57420..ee9b9edd 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -3,6 +3,10 @@ # A second column marks codes a UI does not translate: # passthrough the sentence is only placeholders (system or upstream text); show `text` # test only produced by tests; never reaches a UI +backup.io +backup.not_found +backup.prefs_too_large +backup.write_failed config.alias_blank_model config.alias_chained config.alias_duplicate @@ -174,7 +178,6 @@ control.name_taken_not_zai control.name_whitespace control.no_keys control.no_such_endpoint -control.no_such_version control.not_a_chatgpt_account control.patch.no_entry control.patch.not_an_entry @@ -432,6 +435,15 @@ gw.ws.proxy_unsupported gw.ws.send_failed gw.ws.upstream_broke gw.ws.upstream_closed +import.exists +import.key_value_taken +import.missing_reference +import.name_taken +import.no_aws_profile +import.not_in_pack +import.plugin_mismatch +import.plugin_missing +import.skipped_reference l1.config.bad_url l1.config.no_host l1.config.proxy_addr_form @@ -498,6 +510,9 @@ pack.item_shape pack.missing_entry pack.need_key pack.need_passphrase +pack.no_such_item +pack.not_base64 +pack.passphrase_short pack.schema_too_new pack.too_large pack.too_new diff --git a/crates/tw-api/src/ep.rs b/crates/tw-api/src/ep.rs index bb99fd01..49d9c18a 100644 --- a/crates/tw-api/src/ep.rs +++ b/crates/tw-api/src/ep.rs @@ -28,13 +28,37 @@ endpoints! { /// 诊断包(Markdown,已脱敏) Diagnostics: GET "/diagnostics", () => String, text; - // ─────────────────────────────────────────────── 配置原文与历史 + // ─────────────────────────────────────────────── 配置原文 GetConfig: GET "/config", () => api::ConfigText; PatchConfig: PATCH "/config", api::ConfigPatch => api::ConfigWritten; PutConfig: PUT "/config", api::ConfigWrite => api::ConfigWritten; - ConfigHistory: GET "/config/history", () => Vec; ConfigAt: GET "/config/at", api::ConfigAtQuery => api::ConfigAt; - ConfigRollback: POST "/config/rollback", api::RollbackRequest => api::ConfigWritten; + // ─────────────────────────────────────────────── 配置包、导入导出、备份 + // + // 一个项目是配置里有名字的一样东西(上游、密钥、路由……),加上全局设置的每一节和 + // Lite 的通用偏好。导入、恢复都按项目列、按项目选。**备份的几个在安全模式下照样可用**: + // 配置读不进来时从备份恢复。 + /// 配置里的每个项目,和它引用的项目 + ConfigItems: GET "/config/items", () => Vec; + /// 导出成配置包:全部,或者选的几项连同它们引用的。**不写任何东西** + ExportConfig: POST "/config/export", api::ExportRequest => api::ExportedFile; + /// 导入会怎么做:逐项新增、相同、冲突、错误。**不写任何东西** + ImportPreview: POST "/config/import/preview", api::ImportSource => api::ImportPreview; + /// 照逐项的处理导入。预览之后配置变了是 409,重新预览 + ImportConfig: POST "/config/import", api::ImportRequest => api::ConfigWritten; + /// 备份版本,新的在前 + Backups: GET "/backups", () => Vec; + /// 一个版本另存为配置包 + BackupFile: POST "/backups/file", api::BackupFileRequest => api::ExportedFile; + /// 恢复到一个版本会改什么。**不写任何东西** + RestorePreview: POST "/backups/preview", api::RestorePreviewRequest => api::RestorePreview; + /// 恢复到一个版本(全部,或者选的几项)。恢复本身也留一个版本 + Restore: POST "/backups/restore", api::RestoreRequest => api::ConfigWritten; + /// Lite 的通用偏好 + GetClientPrefs: GET "/client-prefs", () => api::ClientPrefs; + PutClientPrefs: PUT "/client-prefs", api::ClientPrefs => (); + /// 本机接管的客户端在用的网关密钥 + PinKeys: PUT "/pinned-keys", api::PinnedKeys => (); /// 配置读不进来时,一键修复会改哪几处 ConfigRepairPlan: GET "/config/repair", () => api::ConfigRepair; /// 照那几处修好写回:取值改回默认值、删掉不认识的字段 diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index e80b0174..b1924591 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -91,6 +91,10 @@ macro_rules! slug_enum { }; } +// 配置包那一半:导出、导入、备份与恢复(`slug_enum!` 在上面定义,所以在这里声明) +mod pack; +pub use pack::*; + // ---------------------------------------------------------------- 取值固定的词 // // 下面这些是散在各处的字段共用的词表,按主题排。只在一个地方用到的集合写在 @@ -368,12 +372,18 @@ slug_enum! { Cli = "cli", /// 别的程序或者编辑器 External = "external", - /// 回滚 - Rollback = "rollback", - /// OAuth 凭据轮换之后写回 + /// OAuth 凭据轮换之后写回。**不留备份版本** Rotation = "rotation", /// core 自己:装上它自带的默认插件,或者把没动过的默认插件换成新版 Defaults = "defaults", + /// 应用了另一台设备经同步位置传来的版本 + Sync = "sync", + /// 导入了一个配置包 + Import = "import", + /// 恢复了一个备份版本 + Restore = "restore", + /// 一键修复 + Repair = "repair", } } @@ -946,7 +956,25 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// 没有在跑的请求是 null,有请求却都还没开始生成是 0。[`RunningView`] 多了 `tokens_per_sec` /// (这一个请求此刻的速度)和 `output_tokens`(到目前为止估的输出 token),第一个 token 之前、 /// 非流式的是 null。 -pub const CONTROL_API_VERSION: u32 = 49; +/// +/// **50 起有配置包(`.twconfig`)和本机备份**,修改记录换成了备份:`GET /config/history`、 +/// `POST /config/rollback`(`ConfigVersion`、`RollbackRequest`)删了,换成 `GET /backups` +/// ([`BackupEntry`]:本机最近 10 个版本,开着同步时连同同步位置上的,同一个版本一行)、 +/// `POST /backups/file`(另存为配置包)、`POST /backups/preview`([`RestorePreview`]:按项目 +/// 列出差异)、`POST /backups/restore`(可以只恢复几项,本机设置和 ChatGPT 的登录留着,接管的 +/// 客户端在用的密钥不删)。一版是一个完整的配置包,连同插件和 Lite 的通用偏好。新端点 +/// `GET /config/items`([`ItemNode`])、`POST /config/export`([`ExportRequest`] → +/// [`ExportedFile`])、`POST /config/import/preview`([`ImportSource`] → [`ImportPreview`]:逐项 +/// 新增、相同、冲突、错误)和 `POST /config/import`([`ImportRequest`]:逐项导入、覆盖、改名 +/// 导入或跳过)。Lite 的通用偏好由 core 存:`GET` / `PUT /client-prefs`([`ClientPrefs`], +/// 不解析,最大 64 KiB),变了发 [`Event::ClientPrefsChanged`]。`PUT /pinned-keys` +/// ([`PinnedKeys`])报告接管的客户端在用哪几把密钥。导入覆盖、恢复改了或删了网关密钥时发 +/// [`Event::GatewayKeysChanged`]。[`ConfigOrigin`] 删了 `rollback`,多了 `sync`、`import`、 +/// `restore`、`repair`(一键修复以前记成 `ui`)。备份相关的端点在安全模式下照样可用。配置包的 +/// 码是 `pack.*`,导入的是 `import.*`,备份与恢复的是 `backup.*`。停用着的上游可以缺凭据 +/// (密钥、OAuth、AWS 访问密钥空着,ChatGPT 账号没登录):导入时就是这样进来的。照 49 写的 +/// 界面调不到删掉的那两个端点。 +pub const CONTROL_API_VERSION: u32 = 50; /// `/live` 的速度看最近多久(见 [`LiveView::tokens_per_sec`]),毫秒 pub const LIVE_RATE_WINDOW_MS: u64 = 3_000; @@ -1601,7 +1629,7 @@ pub enum Event { id: u64, /// 内容版本号,和 `PATCH /config` 的 `base_version` 是同一个 version: String, - /// `ui` / `cli` / `external` / `rollback` / `rotation` / `defaults` + /// 谁写的,见 [`ConfigOrigin`] origin: ConfigOrigin, at_ms: u64, }, @@ -1619,7 +1647,7 @@ pub enum Event { line: Option, /// 出错那一行的原文,**已脱敏** excerpt: Option, - /// 这一版是谁写的:`ui` / `cli` / `external` / `rollback` / `rotation` / `defaults`。 + /// 这一版是谁写的([`ConfigOrigin`])。 /// /// **界面靠它区分「用户在编辑器里写错了」和「界面自己刚写坏了」** —— /// 前者要提醒,后者是保存失败,那条路自己会报。 @@ -1679,6 +1707,17 @@ pub enum Event { /// 「进行中」的计数和每一行的状态都靠事件,丢一个结局,那一行就永远停在 /// 「进行中」。 EventsDropped { id: u64, count: u64, at_ms: u64 }, + /// 这几把网关密钥的值变了或者没了:导入覆盖、恢复、同步改的。 + /// + /// **本机接管的客户端配置里写着密钥的值**:界面收到它,照「更换密钥后更新客户端」那一套 + /// 改写用着这几把的客户端。名字是变之前的那些 + GatewayKeysChanged { + id: u64, + names: Vec, + at_ms: u64, + }, + /// Lite 的通用偏好变了(导入、恢复、同步、别的界面存的):重读 `GET /client-prefs` 并应用 + ClientPrefsChanged { id: u64, at_ms: u64 }, } slug_enum! { @@ -1940,6 +1979,8 @@ impl Event { | Event::RequestCancelled { id, .. } | Event::LocallyAnswered { id, .. } | Event::EventsDropped { id, .. } + | Event::GatewayKeysChanged { id, .. } + | Event::ClientPrefsChanged { id, .. } | Event::ConfigReloaded { id, .. } | Event::ListenChanged { id, .. } | Event::ConfigRejected { id, .. } @@ -4003,29 +4044,6 @@ pub struct BaseVersion { pub base_version: Option, } -/// 历史里的一版。 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -pub struct ConfigVersion { - pub version: String, - pub at_ms: u64, - /// `ui` / `cli` / `external` / `rollback` / `rotation` / `defaults` - pub origin: ConfigOrigin, - pub bytes: u64, - /// 这一版是现在跑着的那一版吗。 - /// - /// **历史里包括当前版本**,所以列表最上面那条通常就是它 —— 不标 - /// 出来的话,用户会以为第一条是「上一版」然后回滚到自己身上。 - #[serde(default)] - pub current: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -pub struct RollbackRequest { - pub version: String, -} - /// 一段时间的汇总。 /// /// **实测和估算分开,没有价格的单独数。**「今日 $12.40 实测 + ~$0.80 diff --git a/crates/tw-api/src/pack.rs b/crates/tw-api/src/pack.rs new file mode 100644 index 00000000..b5b2b242 --- /dev/null +++ b/crates/tw-api/src/pack.rs @@ -0,0 +1,382 @@ +//! 配置包那一半的契约:导出、导入、本机备份与恢复、Lite 的通用偏好、固定密钥。 +//! +//! 配置包(`.twconfig`)的格式和「项目」的划分在 core 的 `tw-pack` 里;这里只有线上的样子。 +//! 一个项目是配置里有名字的一样东西([`ItemRef`]),导入和恢复都按项目列、按项目选。 + +use serde::{Deserialize, Serialize}; + +use crate::{ConfigOrigin, Msg}; + +slug_enum! { + /// 配置里的一样东西是哪一种。 + pub enum ItemKind { + /// 上游 + Provider = "provider", + /// 出站代理 + Proxy = "proxy", + /// 网关密钥 + Key = "key", + /// 模型别名 + Alias = "alias", + /// 策略组 + Group = "group", + Route = "route", + /// 价目表 + PriceSheet = "price_sheet", + /// 自定义安全规则。名字是 `<分项>/<规则名>`(`content/项目代号`) + SecurityRule = "security_rule", + Plugin = "plugin", + /// 上游的先后(没写路由时就是故障转移的顺序)。名字是空的 + ProviderOrder = "provider_order", + /// 路由的先后。名字是空的 + RouteOrder = "route_order", + /// 插件的先后(运行的顺序)。名字是空的 + PluginOrder = "plugin_order", + /// 全局设置的一节:`failover` / `security` / `default_route` / `default_key` / + /// `client_probes` / `pricing` + Global = "global", + /// Lite 的通用偏好。名字是空的 + ClientPrefs = "client_prefs", + } +} + +/// 一个项目:种类加名字。 +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ItemRef { + pub kind: ItemKind, + pub name: String, +} + +/// `GET /config/items`:配置里的每个项目,和它引用的项目(导出所选项目时随附的那些)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ItemNode { + pub item: ItemRef, + /// 直接引用的项目:路由 → 上游、策略组;上游 → 出站代理、价目表…… + pub refs: Vec, +} + +/// 导出哪些。线上是 `"all"`,或者 `{ "items": [ItemRef, …] }`。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum ExportScope { + /// 全部配置 + All, + /// 这几项,**连同它们引用的项目**(core 补齐) + Items(Vec), +} + +/// `POST /config/export`。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ExportRequest { + pub scope: ExportScope, + /// 带上密钥:上游密钥、请求头的值、AWS 凭据、代理密码、网关密钥。不带时这些值是空的, + /// 写成 `${变量名}` 的照样带着(那不是密钥) + pub secrets: bool, + /// 用口令加密。至少 8 个字符;不给是不加密 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passphrase: Option, + /// Lite 的通用偏好,**经远程连接导出时由 Lite 附上**(那时偏好在 Lite 那边)。经本机连接 + /// 导出时不看它:用 core 存着的那一份 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts", ts(type = "unknown"))] + pub client_prefs: Option, +} + +/// 导出(或另存)的文件。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ExportedFile { + /// 建议的文件名,`ThinkWatch-2026-10-18.twconfig` + pub file_name: String, + /// 文件内容,base64 + pub data_b64: String, + /// 包里有哪些项目(随附的也在里面) + pub included: Vec, + /// 登录没有随文件导出的上游(ChatGPT 账号):导入之后要重新登录 + pub omitted_logins: Vec, +} + +/// 一个配置包的内容,导入之前看。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ImportSource { + pub data_b64: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passphrase: Option, +} + +slug_enum! { + /// 完整的配置,还是选出来的几项。 + pub enum PackKind { + Full = "full", + /// 选择项目导出、分享 + Partial = "partial", + } +} + +slug_enum! { + /// 配置包是怎么来的。 + pub enum PackOrigin { + Export = "export", + Share = "share", + Ui = "ui", + Cli = "cli", + External = "external", + Defaults = "defaults", + Sync = "sync", + Import = "import", + Restore = "restore", + Repair = "repair", + /// 更新的版本才有的来源 + Other = "other", + } +} + +/// 配置包里有多少东西。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PackCounts { + pub providers: u32, + pub proxies: u32, + pub clients: u32, + pub aliases: u32, + pub groups: u32, + pub routes: u32, + pub price_sheets: u32, + pub security_rules: u32, + pub plugins: u32, +} + +/// 配置包的说明(`manifest.json`)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PackInfo { + pub kind: PackKind, + pub created_at_ms: u64, + /// 哪个版本的 Core 写的 + pub core_version: String, + /// 带没带密钥 + pub secrets: bool, + pub encrypted: bool, + pub origin: PackOrigin, + pub counts: PackCounts, +} + +/// `POST /config/import/preview`:导入会怎么做。**什么都不写**。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ImportPreview { + pub manifest: PackInfo, + /// 按磁盘上哪一版算的。导入时带回来:配置在这中间变了就是 409,重新预览 + pub base_version: String, + /// 包里的每个项目,按种类、名字排。顺序项目不在里面:新加的接在后面 + pub rows: Vec, + /// 包里的 Lite 通用偏好。**经远程连接导入时由 Lite 照它在本机应用**,core 不写;经本机 + /// 连接导入时它是 `client_prefs` 那一行,core 写下并发 [`crate::Event::ClientPrefsChanged`] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts", ts(type = "unknown"))] + pub client_prefs: Option, +} + +slug_enum! { + /// 包里的一项和本机比。 + pub enum ImportStatus { + /// 本机没有:直接导入,可以不选 + New = "new", + /// 本机有,内容一样:不处理 + Same = "same", + /// 本机有,内容不同:必须选覆盖、改名导入或跳过(全局设置只有覆盖、跳过) + Conflict = "conflict", + /// 导入不了,原因在 `error` + Error = "error", + } +} + +slug_enum! { + /// 导入时要知道的一件事。 + pub enum ImportFlag { + /// 包里不带密钥:上游导入为停用,填上密钥之后启用;网关密钥重新生成 + MissingKey = "missing_key", + /// ChatGPT 账号上游:登录不随文件带,导入为停用,登录之后启用 + NeedsLogin = "needs_login", + /// 插件:导入为停用。改得了工具调用的插件启用时要批准 + PluginDisabled = "plugin_disabled", + } +} + +/// 包里的一项。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ImportRow { + pub item: ItemRef, + pub status: ImportStatus, + /// 全局设置、通用偏好:冲突时只能覆盖或跳过 + pub global: bool, + pub flags: Vec, + /// 冲突时哪几个合并单位不同:上游是 `conn`(地址、协议、密钥、请求头、代理)、`models`、 + /// `billing`、`other`(并发、身份、余额、停用);插件是 `script`、`settings`、`enabled`; + /// 全局设置和通用偏好是字段的路径(`failover.pause_secs`);别的是 `all` + pub differs: Vec, + /// 改名导入时的缺省名字(`公司中转 (2)`)。冲突的、有名字的项目才有 + pub suggested_name: Option, + pub error: Option, + /// 它引用的、包里也有的项目。**被跳过的项目被谁引用着,谁就导入不了**(除非引用的那一项 + /// 是改名导入):界面按它把那几行标成错误 + pub refs: Vec, +} + +slug_enum! { + /// 对一项怎么处理。 + pub enum ImportAction { + /// 本机没有的:导入 + Import = "import", + /// 用包里的替换本机同名的那一项。本机引用它的地方不变 + Overwrite = "overwrite", + /// 用新名字导入,包里引用它的项目跟着改用新名字 + Rename = "rename", + Skip = "skip", + } +} + +/// 对一项的处理。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ImportDecision { + pub item: ItemRef, + pub action: ImportAction, + /// 改名导入的新名字。自定义安全规则只写规则名 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_name: Option, +} + +/// `POST /config/import`。没有写进 `decisions` 的项目不导入。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ImportRequest { + pub data_b64: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passphrase: Option, + /// 预览时的那一版 + pub base_version: String, + pub decisions: Vec, +} + +/// 一个版本里有多少东西。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct BackupCounts { + pub providers: u32, + pub plugins: u32, +} + +/// 一个备份版本(`GET /backups`),新的在前。本机的和同步位置上的同一个版本是一行。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct BackupEntry { + /// 恢复、另存时带上 + pub id: String, + pub at_ms: u64, + pub origin: ConfigOrigin, + /// 同步来的版本:哪台设备上传的 + pub device: Option, + /// 存在本机 + pub local: bool, + /// 存在同步位置 + pub sync: bool, + pub counts: BackupCounts, + pub core_version: String, + /// 和现在的配置是同一份内容 + pub current: bool, +} + +/// `POST /backups/file`:一个版本另存为配置包。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct BackupFileRequest { + pub id: String, + /// 用口令加密。至少 8 个字符;不给是不加密 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passphrase: Option, +} + +/// `POST /backups/preview`。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct RestorePreviewRequest { + pub id: String, +} + +/// 恢复会改什么。**什么都不写**。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct RestorePreview { + /// 恢复时带回来 + pub base_version: String, + /// 和现在不同的项目。缺省全选;取消的保持现在的样子 + pub rows: Vec, + /// 开着同步时,恢复的结果会同步到几台别的设备。没开同步是 0 + pub sync_devices: u32, +} + +slug_enum! { + /// 恢复对一项做什么。 + pub enum RestoreChange { + /// 那一版有、现在没有 + Add = "add", + /// 现在有、那一版没有 + Remove = "remove", + Modify = "modify", + } +} + +slug_enum! { + /// 恢复时为什么留着它。 + pub enum KeptReason { + /// 本机接管的客户端正在用这把网关密钥:不删 + KeyInUse = "key_in_use", + } +} + +/// 恢复里的一项。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct RestoreRow { + pub item: ItemRef, + pub change: RestoreChange, + /// 不照那一版改的原因。有的话这一项保持现在的样子 + pub kept: Option, +} + +/// `POST /backups/restore`。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct RestoreRequest { + pub id: String, + pub base_version: String, + /// 只恢复这几项。不给是全部 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub items: Option>, +} + +/// Lite 的通用偏好(语言、外观、菜单栏、通知、检查更新),**core 不解析**,最大 64 KiB。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ClientPrefs { + /// 还没存过是空 + #[cfg_attr(feature = "ts", ts(type = "unknown"))] + pub prefs: Option, +} + +/// Lite 的通用偏好最大多少字节(写成 JSON 之后) +pub const CLIENT_PREFS_MAX: usize = 64 * 1024; + +/// `PUT /pinned-keys`:本机接管的客户端正在用的网关密钥。恢复、导入、同步不删它们。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PinnedKeys { + pub names: Vec, +} diff --git a/crates/tw-api/src/ts.rs b/crates/tw-api/src/ts.rs index 258d6142..308cbf1c 100644 --- a/crates/tw-api/src/ts.rs +++ b/crates/tw-api/src/ts.rs @@ -446,7 +446,7 @@ mod tests { // core 自己写配置(默认插件)的那一版有自己的来源 assert_eq!( decl_of(&ts, "ConfigOrigin"), - "export type ConfigOrigin = \"ui\" | \"cli\" | \"external\" | \"rollback\" | \"rotation\" | \"defaults\"" + "export type ConfigOrigin = \"ui\" | \"cli\" | \"external\" | \"rotation\" | \"defaults\" | \"sync\" | \"import\" | \"restore\" | \"repair\"" ); } diff --git a/crates/tw-config/src/control_key.rs b/crates/tw-config/src/control_key.rs index 28ddcf8c..9bfbd1ad 100644 --- a/crates/tw-config/src/control_key.rs +++ b/crates/tw-config/src/control_key.rs @@ -126,9 +126,8 @@ pub fn rotate_file(path: &Path) -> Result { let cur = store::read(path)?; let (next, key) = rotate(&cur.text)?; crate::try_parse(&next).map_err(|r| EnsureError::Rejected(Box::new(r)))?; - let _ = crate::history::snapshot(path, &cur.text, crate::history::Origin::Cli); + // **不留备份版本**:备份里的钥匙是打码的,换钥匙前后的两版存下来一模一样 store::write_if_unchanged(path, &cur.fingerprint, &next)?; - let _ = crate::history::snapshot(path, &next, crate::history::Origin::Cli); Ok(key) } diff --git a/crates/tw-config/src/credential.rs b/crates/tw-config/src/credential.rs index 37051a85..35454503 100644 --- a/crates/tw-config/src/credential.rs +++ b/crates/tw-config/src/credential.rs @@ -541,9 +541,16 @@ impl Provider { } /// 凭据写法对不对。**不联网,不读环境变量** —— 那是转发时的事。 + /// + /// **停用着的上游可以缺凭据**:密钥、OAuth、AWS 访问密钥空着,ChatGPT 账号还没登录。 + /// 导入一个不带密钥的配置包、别的设备传来一家 ChatGPT 账号上游时就是这样 —— 上游先 + /// 停用着进来,填上密钥(登录)之后才启用得了。写法上的毛病(请求头写错、两种凭据 + /// 都写了)照样不收 pub fn check_credential(&self) -> Result<(), CredentialError> { + let waiting = self.disabled; if let Some(k) = &self.key && k.is_blank() + && !waiting { return Err(CredentialError::EmptyKey); } @@ -552,7 +559,7 @@ impl Provider { if self.key.is_some() { return Err(CredentialError::KeyAndOauth); } - if o.refresh.trim().is_empty() || o.endpoint.trim().is_empty() { + if (o.refresh.trim().is_empty() && !waiting) || o.endpoint.trim().is_empty() { return Err(CredentialError::EmptyOauth); } // **订阅账号的令牌不接。**Anthropic 规定 Claude 订阅的 OAuth 令牌不得由 @@ -574,7 +581,8 @@ impl Provider { } // **Codex 后端只认登录拿到的凭据**,API 密钥发过去只会是一个 401 let chatgpt = self.effective_protocol() == Some(Protocol::Chatgpt); - if chatgpt && crate::chatgpt::is_backend(&self.base_url) && self.oauth.is_none() { + if chatgpt && crate::chatgpt::is_backend(&self.base_url) && self.oauth.is_none() && !waiting + { return Err(CredentialError::ChatgptWithoutLogin); } // 接 ChatGPT 账号的前提是如实说明请求来自 ThinkWatch:转发客户端的身份就是冒充它 @@ -657,9 +665,10 @@ impl Provider { Some(_) => {} None => { let blank = |s: &Option| s.as_ref().is_none_or(Secret::is_blank); - if blank(&aws.access_key_id) - || blank(&aws.secret_access_key) - || aws.session_token.as_ref().is_some_and(Secret::is_blank) + if !self.disabled + && (blank(&aws.access_key_id) + || blank(&aws.secret_access_key) + || aws.session_token.as_ref().is_some_and(Secret::is_blank)) { return Err(CredentialError::AwsEmpty); } @@ -964,6 +973,27 @@ mod tests { lookalike.check_credential().unwrap(); } + /// 停用着的上游可以缺凭据(导入时没带密钥、别的设备上登录的 ChatGPT 账号);启用就不行。 + /// 写法上的毛病照样不收 + #[test] + fn a_disabled_upstream_may_wait_for_its_credential() { + for yaml in [ + "name: k\nbase_url: https://relay.example\nkey: ''\n", + "name: c\nbase_url: https://chatgpt.com/backend-api/codex\n", + "name: o\nbase_url: https://relay.example\noauth: { refresh: '', endpoint: https://t.example }\n", + "name: b\nbase_url: https://bedrock-runtime.us-east-1.amazonaws.com\naws: { access_key_id: '', secret_access_key: '' }\n", + ] { + assert!(p(yaml).check_credential().is_err(), "{yaml}"); + let off = p(&format!("{yaml}disabled: true\n")); + off.check_credential() + .unwrap_or_else(|e| panic!("{yaml}: {e}")); + } + let both = p( + "name: x\nbase_url: https://relay.example\nkey: ''\noauth: { refresh: r, endpoint: https://t.example }\ndisabled: true\n", + ); + assert_eq!(both.check_credential(), Err(CredentialError::KeyAndOauth)); + } + #[test] fn a_chatgpt_account_takes_only_a_login_and_its_identity_is_not_configurable() { let bare = p("name: c\nbase_url: https://chatgpt.com/backend-api/codex\n"); diff --git a/crates/tw-config/src/history.rs b/crates/tw-config/src/history.rs index 48015ca5..11a845ac 100644 --- a/crates/tw-config/src/history.rs +++ b/crates/tw-config/src/history.rs @@ -1,28 +1,10 @@ -//! 变更历史与回滚(M2 验收「改坏能一键回滚」)。 +//! 一次配置改动的来源。 //! -//! **存的是每一版,包括当前这一版。**写之前存一次「改之前」、生效之后 -//! 存一次「改之后」,去重之后的效果就是「每个存在过的版本各一条」,而 -//! 最新那条就是现在跑着的。 -//! -//! 为什么不只存「改之前」:那样最新的一版永远不在历史里,于是「改坏了 -//! 想回到上一版」和「回到上上版」在列表上长得一模一样,用户要自己数。 -//! 而且外部改动那条路径根本没有「写之前」可言。 -//! -//! 存的是整份文本,不是 diff。理由很实际:一份配置几 KB,五十个版本也 -//! 就几百 KB,而**用 diff 存会让回滚依赖一条完整的链** —— 中间任何一环 -//! 坏掉,后面全部作废。回滚是出事时用的功能,它自己不能有脆弱的前提。 - -use std::path::{Path, PathBuf}; +//! 每一版配置存在哪儿、留几版、怎么恢复,在 `tw-pack` 的本机备份里(`tw_pack::backup`): +//! 一版是一个完整的配置包,连同插件和 Lite 的通用偏好。这里只剩「谁改的」这个词 —— +//! 写配置的每一条路都要说它,而那几条路不都够得着 `tw-pack`。 -use crate::store::{self, StoreError}; - -/// 留多少版。 -/// -/// 五十版在一份几 KB 的配置上是几百 KB,而**回滚要找的那一版几乎总在 -/// 最近几次里** —— 更早的版本留着只是安慰。 -pub const KEEP: usize = 50; - -/// 一次改动的来源。**记下来是为了让历史列表能读懂** —— +/// 一次改动的来源。**记下来是为了让备份列表能读懂** —— /// 「界面改的」和「你自己在编辑器里改的」在排查时是完全不同的线索。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Origin { @@ -30,462 +12,90 @@ pub enum Origin { Cli, /// 外部编辑(编辑器、脚本),我们是从文件监听发现的 External, - Rollback, - /// token 端点换发了新的 refresh token,我们把它写回去了。 + /// core 自己装上它自带的默认插件、或者把没动过的默认插件换成新版。不是人发起的, + /// 要一眼认得出来 + Defaults, + /// 应用了另一台设备经同步位置传来的版本 + Sync, + /// 导入了一个配置包 + Import, + /// 恢复了一个备份版本 + Restore, + /// 一键修复 + Repair, + /// token 端点换发了新的凭据,我们把它写回去了。 /// - /// **不是人发起的写入**,所以它在历史里要能一眼认出来 —— 用户看到「配置 - /// 变了」时,第一个问题是「谁改的」。 + /// **不是人发起的写入,也不留备份版本**:恢复到一次轮换之前,拿到的是一个已经作废的 + /// token;会轮换的服务器一小时一次,几天就把用户自己改过的那些版本挤出去了 Rotation, - /// core 自己装上它自带的默认插件、或者把没动过的默认插件换成新版。同样不是 - /// 人发起的,同样要一眼认得出来 - Defaults, } impl Origin { - /// 控制面发给界面的值,也是历史文件名里的那一段。 + /// 控制面发给界面的值,也是备份文件名里的那一段。 pub fn slug(&self) -> &'static str { match self { Origin::Ui => "ui", Origin::Cli => "cli", Origin::External => "external", - Origin::Rollback => "rollback", - Origin::Rotation => "rotation", Origin::Defaults => "defaults", + Origin::Sync => "sync", + Origin::Import => "import", + Origin::Restore => "restore", + Origin::Repair => "repair", + Origin::Rotation => "rotation", } } - fn parse(s: &str) -> Origin { + + /// 反过来。认不出的是外部编辑:一个说不出来源的版本,最可能就是有人直接改了文件 + pub fn parse(s: &str) -> Origin { match s { "ui" => Origin::Ui, "cli" => Origin::Cli, - "rollback" => Origin::Rollback, - "rotation" => Origin::Rotation, "defaults" => Origin::Defaults, + "sync" => Origin::Sync, + "import" => Origin::Import, + "restore" => Origin::Restore, + "repair" => Origin::Repair, + "rotation" => Origin::Rotation, _ => Origin::External, } } + + /// 命令行里的说法 pub fn label(&self) -> &'static str { match self { Origin::Ui => "the interface", Origin::Cli => "the command line", Origin::External => "an outside edit", - Origin::Rollback => "a rollback", - Origin::Rotation => "a credential rotation", Origin::Defaults => "the default plugins", + Origin::Sync => "sync", + Origin::Import => "an import", + Origin::Restore => "a restore", + Origin::Repair => "a repair", + Origin::Rotation => "a credential rotation", } } } -/// 历史里的一版。 -#[derive(Debug, Clone)] -pub struct Version { - pub file: PathBuf, - /// 毫秒时间戳。**文件名里就带着它** —— 历史目录直接按名字排序就是 - /// 时间序,不必读每个文件的 mtime(备份工具会把 mtime 全改成同一天) - pub at_ms: u64, - pub origin: Origin, - /// 内容版本号,和 `store::version_of` 是同一个 - pub version: String, - pub bytes: u64, -} - -fn history_dir(config_path: &Path) -> PathBuf { - config_path - .parent() - .unwrap_or(Path::new(".")) - .join("history") -} - -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - -/// 把当前这一版存进历史。 -/// -/// **内容和上一版一模一样时什么都不做。**否则一次「保存但没改动」会在 -/// 历史里堆一版噪音,而回滚列表里全是同一份内容的时候,它就没用了。 -pub fn snapshot( - config_path: &Path, - text: &str, - origin: Origin, -) -> Result, StoreError> { - // **凭据轮换不进历史**。三条理由,第一条就够了: - // - // 1. **回滚到一次轮换之前,拿到的是一个已经作废的 token。**服务器 - // 换发新的那一刻就把旧的废了 —— 这一版不是「一个可以退回去的 - // 状态」,是个陷阱。 - // 2. 会轮换的服务器一小时一次,两天就把 50 版全占满了,用户自己 - // 改过的那些全被挤出去 —— 而那才是他要回滚的东西。 - // 3. 每一版都是一份明文密钥的副本,而这几份副本永远派不上用场。 - // - // 「谁改的、改了什么」由 `ConfigReloaded` 事件和日志记着,不靠这里。 - if matches!(origin, Origin::Rotation) { - return Ok(None); - } - let dir = history_dir(config_path); - let version = store::version_of(text); - let all = list(config_path)?; - if let Some(last) = all.last() - && last.version == version - { - return Ok(None); - } - std::fs::create_dir_all(&dir).map_err(|source| StoreError::Io { - path: dir.clone(), - source, - })?; - // **时间戳必须严格递增。**同一毫秒里连着存两版(表单自动保存、 - // 脚本连改)会得到同一个 at_ms,而那时「哪一版更新」就只能听 - // `read_dir` 的顺序 —— 于是 prune 可能删掉最新的那一版,回滚也会 - // 回到一个说不清的地方。 - // - // 代价是一次连写里的时间戳可能比真实时刻晚几毫秒。**顺序永远不 - // 会错,而顺序才是回滚依赖的东西**,时间只是给人看的。 - let at_ms = match all.last() { - Some(last) if now_ms() <= last.at_ms => last.at_ms + 1, - _ => now_ms(), - }; - // 名字里带上时间、来源和版本号 —— 光看文件名就能读懂这一版是什么。 - let file = dir.join(format!( - "{at_ms}-{}-{}.yaml", - origin.slug(), - &version["blake3:".len()..] - )); - // **钥匙不进历史**:存的是打码的原文,版本号仍按真原文算(文件名里那一段), - // 「哪一版是现在这版」照样对得上。回滚到这一版时打码换回当时磁盘上的钥匙 - // (见 [`crate::control_key::unmask`])—— 回滚不该顺手把门锁换掉 - store::write_atomic(&file, &crate::control_key::mask(text))?; - prune(config_path)?; - Ok(Some(Version { - file, - at_ms, - origin, - version, - bytes: text.len() as u64, - })) -} - -/// 历史列表,**从旧到新**。 -pub fn list(config_path: &Path) -> Result, StoreError> { - let dir = history_dir(config_path); - let rd = match std::fs::read_dir(&dir) { - Ok(r) => r, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(source) => return Err(StoreError::Io { path: dir, source }), - }; - let mut out = Vec::new(); - for e in rd.flatten() { - let name = e.file_name().to_string_lossy().to_string(); - let Some(v) = parse_name(&name) else { continue }; - let bytes = e.metadata().map(|m| m.len()).unwrap_or(0); - out.push(Version { - file: e.path(), - bytes, - ..v - }); - } - // 按时间戳排,不按文件名的字典序 —— 时间戳位数会变(跨过 10 的 - // 幂次),那时字典序和时间序就分家了。 - out.sort_by_key(|v| v.at_ms); - Ok(out) -} - -fn parse_name(name: &str) -> Option { - let stem = name.strip_suffix(".yaml")?; - let mut it = stem.splitn(3, '-'); - let at_ms: u64 = it.next()?.parse().ok()?; - let origin = Origin::parse(it.next()?); - let hash = it.next()?; - Some(Version { - file: PathBuf::new(), - at_ms, - origin, - version: format!("blake3:{hash}"), - bytes: 0, - }) -} - -/// 超出上限的老版本删掉。 -fn prune(config_path: &Path) -> Result<(), StoreError> { - let all = list(config_path)?; - if all.len() <= KEEP { - return Ok(()); - } - for v in &all[..all.len() - KEEP] { - let _ = std::fs::remove_file(&v.file); - } - Ok(()) -} - -/// 取某一版的内容。 -pub fn read(v: &Version) -> Result { - std::fs::read_to_string(&v.file).map_err(|source| StoreError::Io { - path: v.file.clone(), - source, - }) -} - -#[derive(Debug, thiserror::Error)] -pub enum RollbackError { - #[error("the version history has no {0}")] - NoSuchVersion(String), - #[error(transparent)] - Store(#[from] StoreError), -} - -/// 回到某一版。 -/// -/// **回滚本身也是一次改动,也要进历史。**否则「回滚之后发现回错了」 -/// 就没有退路了 —— 而那是回滚最常见的用法之一。 -pub fn rollback(config_path: &Path, version: &str) -> Result { - let all = list(config_path)?; - let target = all - .iter() - .rev() - .find(|v| v.version == version || v.version.ends_with(version)) - .ok_or_else(|| RollbackError::NoSuchVersion(version.to_string()))?; - let mut text = read(target)?; - // 先把「现在这一版」存进历史,再覆盖 - if let Ok(cur) = store::read(config_path) { - snapshot(config_path, &cur.text, Origin::Rollback)?; - // 历史里的钥匙是打码的,换回现在这一把 - let real = crate::control_key::raw_in(&cur.text).ok().flatten(); - if let Ok(t) = crate::control_key::unmask(&text, real.as_deref()) { - text = t; - } - } - store::write_atomic(config_path, &text)?; - Ok(text) -} - #[cfg(test)] mod tests { use super::*; - fn setup() -> (tempfile::TempDir, PathBuf) { - let d = tempfile::tempdir().unwrap(); - let p = d.path().join("config.yaml"); - std::fs::write(&p, "version: 1\nport: 8788\n").unwrap(); - (d, p) - } - - #[test] - fn a_snapshot_can_be_read_back_byte_for_byte() { - let (_d, p) = setup(); - let text = "version: 1\n# 注释也要留着\nport: 8788\n"; - let v = snapshot(&p, text, Origin::Ui).unwrap().unwrap(); - assert_eq!(read(&v).unwrap(), text); - } - #[test] - fn saving_the_same_content_twice_does_not_add_a_second_version() { - // 一次「保存但没改动」在历史里堆一版噪音,而回滚列表里全是同一 - // 份内容的时候它就没用了。 - let (_d, p) = setup(); - snapshot(&p, "a: 1\n", Origin::Ui).unwrap().unwrap(); - assert!(snapshot(&p, "a: 1\n", Origin::Ui).unwrap().is_none()); - assert_eq!(list(&p).unwrap().len(), 1); - } - - #[test] - fn the_origin_of_each_change_survives_a_restart() { - // 「界面改的」和「你自己在编辑器里改的」在排查时是完全不同的线索, - // 而它必须能从磁盘上读回来 —— 内存里记着的东西活不过一次重启。 - let (_d, p) = setup(); - snapshot(&p, "a: 1\n", Origin::Ui).unwrap(); - snapshot(&p, "a: 2\n", Origin::External).unwrap(); - snapshot(&p, "a: 3\n", Origin::Cli).unwrap(); - let got: Vec = list(&p).unwrap().iter().map(|v| v.origin).collect(); - assert_eq!(got, vec![Origin::Ui, Origin::External, Origin::Cli]); - } - - #[test] - fn history_is_ordered_oldest_first_even_across_a_digit_boundary() { - // 按文件名的字典序排会在时间戳位数变化时和时间序分家。 - let (_d, p) = setup(); - let dir = history_dir(&p); - std::fs::create_dir_all(&dir).unwrap(); - for (ms, body) in [(999u64, "a: 1\n"), (1000, "a: 2\n"), (10000, "a: 3\n")] { - std::fs::write( - dir.join(format!("{ms}-ui-{}.yaml", &store::hash_of(body)[..12])), - body, - ) - .unwrap(); - } - let got: Vec = list(&p).unwrap().iter().map(|v| v.at_ms).collect(); - assert_eq!(got, vec![999, 1000, 10000]); - } - - #[test] - fn rolling_back_restores_the_old_text_exactly() { - let (_d, p) = setup(); - let old = "version: 1\n# 我要找回这个注释\nport: 8788\n"; - let v = snapshot(&p, old, Origin::Ui).unwrap().unwrap(); - std::fs::write(&p, "version: 1\nport: 9999\n").unwrap(); - let back = rollback(&p, &v.version).unwrap(); - assert_eq!(back, old); - assert_eq!(std::fs::read_to_string(&p).unwrap(), old); - } - - #[test] - fn rolling_back_is_itself_undoable() { - // **「回滚之后发现回错了」是回滚最常见的用法之一。** - let (_d, p) = setup(); - let v1 = snapshot(&p, "a: 1\n", Origin::Ui).unwrap().unwrap(); - std::fs::write(&p, "a: 2\n").unwrap(); - rollback(&p, &v1.version).unwrap(); - assert_eq!(std::fs::read_to_string(&p).unwrap(), "a: 1\n"); - // 回滚前那一版(a: 2)进了历史,所以能再回去 - let v2 = list(&p) - .unwrap() - .into_iter() - .find(|v| v.origin == Origin::Rollback) - .expect("回滚没留下退路"); - assert_eq!(read(&v2).unwrap(), "a: 2\n"); - rollback(&p, &v2.version).unwrap(); - assert_eq!(std::fs::read_to_string(&p).unwrap(), "a: 2\n"); - } - - #[test] - fn a_short_version_prefix_is_enough_to_name_a_version() { - // 用户会从界面上抄一小截,或者只记得前几位。 - let (_d, p) = setup(); - let v = snapshot(&p, "a: 1\n", Origin::Ui).unwrap().unwrap(); - std::fs::write(&p, "a: 2\n").unwrap(); - let short = &v.version["blake3:".len()..]; - assert_eq!(rollback(&p, short).unwrap(), "a: 1\n"); - } - - #[test] - fn asking_for_a_version_that_is_not_there_says_so() { - let (_d, p) = setup(); - let e = rollback(&p, "blake3:deadbeef").unwrap_err(); - assert!(matches!(e, RollbackError::NoSuchVersion(_)), "{e:?}"); - } - - #[test] - fn a_rotation_never_enters_the_history() { - // **回滚到一次轮换之前,拿到的是一个已经作废的 token** —— - // 那一版不是可以退回去的状态,是个陷阱。而且一小时一次的轮换 - // 两天就能把用户自己改过的那些全挤出去。 - let (_d, p) = setup(); - snapshot(&p, "version: 1\nport: 1\n", Origin::Ui).unwrap(); - for i in 0..5 { - let text = format!("version: 1\nport: 1\n# rot {i}\n"); - assert!( - snapshot(&p, &text, Origin::Rotation).unwrap().is_none(), - "轮换进历史了" - ); + fn every_origin_reads_back_from_its_word() { + for o in [ + Origin::Ui, + Origin::Cli, + Origin::External, + Origin::Defaults, + Origin::Sync, + Origin::Import, + Origin::Restore, + Origin::Repair, + Origin::Rotation, + ] { + assert_eq!(Origin::parse(o.slug()), o); } - let all = list(&p).unwrap(); - assert_eq!(all.len(), 1, "历史里不该有轮换那几版:{all:?}"); - assert_eq!(all[0].origin.label(), "the interface"); - } - - #[test] - fn old_versions_are_pruned_but_the_recent_ones_all_survive() { - let (_d, p) = setup(); - for i in 0..KEEP + 10 { - snapshot(&p, &format!("a: {i}\n"), Origin::Ui).unwrap(); - } - let all = list(&p).unwrap(); - assert_eq!(all.len(), KEEP); - // 留下的是最近的那些 - assert_eq!( - read(all.last().unwrap()).unwrap(), - format!("a: {}\n", KEEP + 9) - ); - } - - #[test] - fn history_files_are_0600_too_because_they_contain_the_same_keys() { - // 一份历史版本和当前配置一样有明文密钥。权限漏在这里等于没漏在 - // 那里 —— 而这类遗漏最容易发生在「顺手加的辅助功能」上。 - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let (_d, p) = setup(); - let v = snapshot(&p, "key: sk-secret\n", Origin::Ui) - .unwrap() - .unwrap(); - let mode = std::fs::metadata(&v.file).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600, "历史文件权限是 {mode:o}"); - } - } - - #[test] - fn an_empty_history_is_not_an_error() { - // 第一次运行时历史目录还不存在。那是正常状态。 - let (_d, p) = setup(); - assert!(list(&p).unwrap().is_empty()); - } - - #[test] - fn a_stray_file_in_the_history_directory_is_ignored_not_fatal() { - // 用户往那个目录里丢过东西、或者别的工具留下过东西。 - let (_d, p) = setup(); - let dir = history_dir(&p); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("README.txt"), "hi").unwrap(); - std::fs::write(dir.join("不是时间戳-ui-abc.yaml"), "a: 1\n").unwrap(); - snapshot(&p, "a: 1\n", Origin::Ui).unwrap(); - assert_eq!(list(&p).unwrap().len(), 1); - } -} - -#[cfg(test)] -mod ordering_tests { - use super::*; - - #[test] - fn versions_written_in_the_same_millisecond_keep_their_order() { - // 表单自动保存、脚本连改都会撞在同一毫秒里。撞上之后「哪一版 - // 更新」如果只能听 read_dir 的顺序,prune 会删掉最新的那一版, - // 而回滚会回到一个说不清的地方。 - let d = tempfile::tempdir().unwrap(); - let p = d.path().join("config.yaml"); - let mut stamps = Vec::new(); - for i in 0..200 { - let v = snapshot(&p, &format!("a: {i}\n"), Origin::Ui) - .unwrap() - .unwrap(); - stamps.push(v.at_ms); - } - assert!( - stamps.windows(2).all(|w| w[0] < w[1]), - "时间戳没有严格递增:{stamps:?}" - ); - let listed = list(&p).unwrap(); - assert_eq!( - read(listed.last().unwrap()).unwrap(), - "a: 199\n", - "最新的那一版不是最后一个" - ); - } - - /// 历史里没有钥匙;回滚回去,钥匙是现在这一把,不是当时那一把。 - #[test] - fn history_never_holds_the_control_key_and_a_rollback_keeps_the_current_one() { - let d = tempfile::tempdir().unwrap(); - let p = d.path().join("config.yaml"); - let old_key = "a".repeat(64); - let new_key = "b".repeat(64); - let with = |k: &str, port: u16| { - format!("version: 1\nlisten:\n control:\n key: {k}\nport: {port}\n") - }; - let v1 = snapshot(&p, &with(&old_key, 1), Origin::Ui) - .unwrap() - .unwrap(); - assert_eq!(v1.version, store::version_of(&with(&old_key, 1))); - let stored = read(&v1).unwrap(); - assert!(!stored.contains(&old_key), "{stored}"); - assert!(stored.contains(tw_api::control::KEY_MASK), "{stored}"); - - std::fs::write(&p, with(&new_key, 2)).unwrap(); - let back = rollback(&p, &v1.version).unwrap(); - assert_eq!(back, with(&new_key, 1)); - assert_eq!(std::fs::read_to_string(&p).unwrap(), with(&new_key, 1)); + assert_eq!(Origin::parse("rollback"), Origin::External); } } diff --git a/crates/tw-config/src/remote.rs b/crates/tw-config/src/remote.rs index 56f5f278..bc307c6c 100644 --- a/crates/tw-config/src/remote.rs +++ b/crates/tw-config/src/remote.rs @@ -96,7 +96,10 @@ pub fn disable(text: &str) -> Result, PatchError> { } } -/// 改完先校验、再写,前后两版进历史。和 `control_key::rotate_file` 同一条路。 +/// 改完先校验、再写。和 `control_key::rotate_file` 同一条路。 +/// +/// **备份版本不在这里留**:备份在 `tw-pack`,这个 crate 够不着它。core 正在跑时文件监听 +/// 换入这一版、留下它;没在跑时下一次启动留(`twcore` 起来时先记一版磁盘上的样子) fn write_checked( path: &Path, f: impl FnOnce(&str) -> Result, PatchError>, @@ -106,9 +109,7 @@ fn write_checked( return Ok(current(&cur.text)); }; let cfg = crate::try_parse(&next).map_err(|r| EnsureError::Rejected(Box::new(r)))?; - let _ = crate::history::snapshot(path, &cur.text, crate::history::Origin::Cli); store::write_if_unchanged(path, &cur.fingerprint, &next)?; - let _ = crate::history::snapshot(path, &next, crate::history::Origin::Cli); Ok(cfg.listen.control.remote) } diff --git a/crates/tw-config/src/wire.rs b/crates/tw-config/src/wire.rs index 1ee7917b..e5e59c84 100644 --- a/crates/tw-config/src/wire.rs +++ b/crates/tw-config/src/wire.rs @@ -104,9 +104,12 @@ impl From for tw_api::ConfigOrigin { Origin::Ui => Self::Ui, Origin::Cli => Self::Cli, Origin::External => Self::External, - Origin::Rollback => Self::Rollback, - Origin::Rotation => Self::Rotation, Origin::Defaults => Self::Defaults, + Origin::Sync => Self::Sync, + Origin::Import => Self::Import, + Origin::Restore => Self::Restore, + Origin::Repair => Self::Repair, + Origin::Rotation => Self::Rotation, } } } diff --git a/crates/tw-control/Cargo.toml b/crates/tw-control/Cargo.toml index eb339a88..725d07ae 100644 --- a/crates/tw-control/Cargo.toml +++ b/crates/tw-control/Cargo.toml @@ -19,6 +19,9 @@ tw-types = { workspace = true } tw-link = { workspace = true } # 列网卡用 getifaddrs —— 比引一个 crate 便宜 libc = { workspace = true } +# 配置包(导出、导入、本机备份、恢复)。文件内容在线上是 base64 +tw-pack = { workspace = true } +base64 = { workspace = true } tw-config = { workspace = true } tw-guard = { workspace = true } bytes = { workspace = true } diff --git a/crates/tw-control/src/backups.rs b/crates/tw-control/src/backups.rs new file mode 100644 index 00000000..286a91a9 --- /dev/null +++ b/crates/tw-control/src/backups.rs @@ -0,0 +1,794 @@ +//! 配置包、导入导出、本机备份与恢复、Lite 的通用偏好、固定密钥。 +//! +//! 格式、项目的划分、导入和恢复的规矩都在 `tw-pack`;这里把它们接到控制面上:读磁盘上此刻 +//! 的样子、写插件文件和配置(同一条写入路径:核版本、先校验再写、留备份版本、换入)、发事件。 +//! +//! # 写的顺序 +//! +//! 和插件页一样**先写文件、再写配置**,攥着 `Plugins::edits`:插件文件和通用偏好先落盘,配置 +//! 一换入网关就照它重读插件;配置没写成(版本对不上、校验没过),刚写的文件按写之前的样子 +//! 还原。 +//! +//! # 远程连接 +//! +//! Lite 的通用偏好经远程连接时在 Lite 那边:导出用请求里带来的那一份,导入、恢复不写 core +//! 这边的偏好(导入预览把包里的偏好交给 Lite,由它在本机应用)。 +//! +//! # 安全模式 +//! +//! 备份的几个端点在安全模式下照样可用:配置读不进来时,恢复把那一版整份写回去,只有控制面 +//! 的钥匙换成现在这一把([`tw_pack::restore::whole`])。 + +use std::collections::BTreeSet; +use std::future::Future; +use std::pin::Pin; + +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use base64::Engine; +use tw_api::ep; +use tw_config::history::Origin; +use tw_pack::model::{self, ItemId, ItemKind}; +use tw_pack::{Pack, PackError, PackOrigin, Seal, Unseal}; +use tw_types::{Msg, msg}; + +use crate::contract::RouterExt; +use crate::{ApplyError, ControlState, Fail, apply_fail, fail, unreadable_config}; + +pub fn router() -> axum::Router { + axum::Router::new() + .at(ep::ConfigItems, items) + .at(ep::ExportConfig, export) + .at(ep::ImportPreview, import_preview) + .at(ep::ImportConfig, import) + .at(ep::Backups, backups) + .at(ep::BackupFile, backup_file) + .at(ep::RestorePreview, restore_preview) + .at(ep::Restore, restore) + .at(ep::GetClientPrefs, get_prefs) + .at(ep::PutClientPrefs, put_prefs) + .at(ep::PinKeys, pin_keys) +} + +// ─────────────────────────────────────────────────────────── 同步位置上的版本 + +/// 一个装箱的 future。 +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// 同步位置上的一个版本。 +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteVersionInfo { + /// [`Pack::content_id`]:和本机同一份内容的版本靠它合成一行 + pub content_id: String, + pub at_ms: u64, + /// 哪台设备上传的 + pub device_name: String, + pub core_version: String, + pub counts: tw_pack::Counts, +} + +/// 同步位置上的备份版本。**同步那一路实现并装上**([`crate::ConfigManager::set_remote_backups`]), +/// 没开同步时没有。 +pub trait RemoteBackups: Send + Sync { + /// 同步位置上留着的版本。连不上就是空的:列表照样列本机的 + fn list(&self) -> BoxFuture<'_, Vec>; + /// 取一个版本(已经解开) + fn fetch<'a>(&'a self, content_id: &'a str) -> BoxFuture<'a, Result>; + /// 恢复之后会同步到几台别的设备 + fn other_devices(&self) -> BoxFuture<'_, u32> { + Box::pin(async { 0 }) + } +} + +/// 同步位置上的版本在 `BackupEntry.id` 里写成这样:`sync-` +const SYNC_ID: &str = "sync-"; + +// ─────────────────────────────────────────────────────────── 插件的拆法 + +/// 插件源码的拆法:代码和数据分开(`tw_gateway::plugin::source`)。**进程里装一次**, +/// `ConfigManager::new` 和命令行都会调,装过了什么都不做 +pub fn install_codec() { + tw_pack::model::install_codec(Box::new(Codec)); +} + +struct Codec; + +impl tw_pack::model::PluginCodec for Codec { + fn split(&self, source: &str) -> Option<(String, serde_json::Value)> { + tw_gateway::plugin::source::split_data(source) + } + fn join(&self, source: &str, data: &serde_json::Value) -> Option { + tw_gateway::plugin::source::join_data(source, data) + } + fn shipped(&self, id: &str) -> Option { + tw_gateway::plugin::defaults::ALL + .iter() + .find(|(i, _)| *i == id) + .map(|(_, s)| s.to_string()) + } + fn needs_approval(&self, source: &str) -> bool { + tw_gateway::plugin::source::steers_declared(source.as_bytes()) + } +} + +// ─────────────────────────────────────────────────────────── 换成线上的样子 + +pub(crate) fn kind_to_api(k: ItemKind) -> tw_api::ItemKind { + use tw_api::ItemKind as A; + match k { + ItemKind::Provider => A::Provider, + ItemKind::Proxy => A::Proxy, + ItemKind::Key => A::Key, + ItemKind::Alias => A::Alias, + ItemKind::Group => A::Group, + ItemKind::Route => A::Route, + ItemKind::PriceSheet => A::PriceSheet, + ItemKind::SecurityRule => A::SecurityRule, + ItemKind::Plugin => A::Plugin, + ItemKind::ProviderOrder => A::ProviderOrder, + ItemKind::RouteOrder => A::RouteOrder, + ItemKind::PluginOrder => A::PluginOrder, + ItemKind::Global => A::Global, + ItemKind::ClientPrefs => A::ClientPrefs, + } +} + +pub(crate) fn kind_from_api(k: tw_api::ItemKind) -> ItemKind { + // 两边的词一样 + ItemKind::from_slug(k.slug()).expect("the two kinds of item share their words") +} + +pub(crate) fn item_ref(id: &ItemId) -> tw_api::ItemRef { + tw_api::ItemRef { + kind: kind_to_api(id.kind), + name: id.name.clone(), + } +} + +pub(crate) fn item_id(r: &tw_api::ItemRef) -> ItemId { + ItemId::new(kind_from_api(r.kind), r.name.clone()) +} + +fn origin_to_api(o: PackOrigin) -> tw_api::PackOrigin { + tw_api::PackOrigin::from_slug(o.slug()).unwrap_or(tw_api::PackOrigin::Other) +} + +fn counts(c: tw_pack::Counts) -> tw_api::PackCounts { + tw_api::PackCounts { + providers: c.providers, + proxies: c.proxies, + clients: c.clients, + aliases: c.aliases, + groups: c.groups, + routes: c.routes, + price_sheets: c.price_sheets, + security_rules: c.security_rules, + plugins: c.plugins, + } +} + +fn pack_info(m: &tw_pack::Manifest) -> tw_api::PackInfo { + tw_api::PackInfo { + kind: match m.kind { + tw_pack::PackKind::Full => tw_api::PackKind::Full, + tw_pack::PackKind::Partial => tw_api::PackKind::Partial, + }, + created_at_ms: m.created_at_ms, + core_version: m.core_version.clone(), + secrets: m.secrets, + encrypted: m.encrypted, + origin: origin_to_api(m.origin), + counts: counts(m.counts), + } +} + +// ─────────────────────────────────────────────────────────── 失败 + +fn bad_pack(e: PackError) -> Fail { + fail(StatusCode::BAD_REQUEST, e.msg()) +} + +/// 配置(本机的、包里的)拆不开。**由更新的版本写的**说成「太新」 +fn model_fail(e: model::ModelError, pack: Option<&Pack>) -> Fail { + if let Some(p) = pack + && e.is_too_new() + { + return bad_pack(PackError::TooNew { + format: p.manifest.format, + core_version: p.manifest.core_version.clone(), + }); + } + match e { + model::ModelError::Config(r) => apply_fail(ApplyError::Rejected(*r)), + other => fail(StatusCode::UNPROCESSABLE_ENTITY, other.msg()), + } +} + +fn io_fail(path: &std::path::Path, e: std::io::Error) -> Fail { + fail( + StatusCode::INTERNAL_SERVER_ERROR, + msg!( + "backup.write_failed", path = path.display(), detail = e => + "{path} could not be written: {detail}" + ), + ) +} + +fn backup_fail(e: tw_pack::backup::BackupError) -> Fail { + let code = match &e { + tw_pack::backup::BackupError::NotFound { .. } => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + fail(code, e.msg()) +} + +fn decode(b64: &str) -> Result, Fail> { + base64::engine::general_purpose::STANDARD + .decode(b64.trim()) + .map_err(|e| { + fail( + StatusCode::BAD_REQUEST, + msg!( + "pack.not_base64", detail = e => + "The file content is not valid base64: {detail}" + ), + ) + }) +} + +/// 口令:至少 8 个字符 +fn passphrase(p: Option<&str>) -> Result, Fail> { + match p { + None => Ok(None), + Some(p) if p.chars().count() >= tw_pack::MIN_PASSPHRASE => Ok(Some(p)), + Some(_) => Err(fail( + StatusCode::BAD_REQUEST, + msg!( + "pack.passphrase_short", min = tw_pack::MIN_PASSPHRASE => + "The passphrase needs at least {min} characters." + ), + )), + } +} + +fn open_pack(data_b64: &str, pass: Option<&str>) -> Result { + let bytes = decode(data_b64)?; + let unseal = match pass { + Some(p) => Unseal::Passphrase(p), + None => Unseal::None, + }; + tw_pack::read(&bytes, unseal).map_err(bad_pack) +} + +fn file_of(pack: &Pack, pass: Option<&str>) -> Result<(String, String), Fail> { + let seal = match pass { + Some(p) => Seal::Passphrase(p), + None => Seal::None, + }; + let bytes = tw_pack::write(pack, seal).map_err(bad_pack)?; + Ok(( + tw_pack::export::file_name(pack.manifest.created_at_ms), + base64::engine::general_purpose::STANDARD.encode(bytes), + )) +} + +// ─────────────────────────────────────────────────────────── 此刻的样子 + +/// 磁盘上此刻的配置(原文和版本)连同它的插件、通用偏好 +fn current(s: &ControlState, origin: PackOrigin) -> Result<(tw_config::Loaded, Pack), Fail> { + let cur = s.cfg.current().map_err(unreadable_config)?; + let pack = tw_pack::disk::current(s.config_path(), &cur.text, origin); + Ok((cur, pack)) +} + +/// 通用偏好由 core 存着吗:经本机连接是,经远程连接不是(在 Lite 那边) +fn core_keeps_prefs() -> bool { + !crate::remote::is_remote() +} + +/// 写下去:插件文件、通用偏好、配置,**按这个顺序**,配置没写成就把前两样还原。之后报 +/// 网关密钥换了、偏好变了 +async fn land( + s: &ControlState, + before: &Pack, + after: &Pack, + base_version: &str, + origin: Origin, + changed_keys: Vec, +) -> Result { + let path = s.config_path().to_path_buf(); + let _edit = s.gateway.plugins.edits.lock().await; + let undo = + tw_pack::disk::write_plugins(&path, &before.plugins, &after.plugins).map_err(|e| { + io_fail( + &tw_config::plugins::dir_in(&tw_pack::disk::dir_of(&path)), + e, + ) + })?; + let prefs_changed = core_keeps_prefs() && after.client_prefs != before.client_prefs; + if prefs_changed && let Err(e) = tw_pack::disk::write_prefs(&path, after.client_prefs.as_ref()) + { + undo.restore(); + return Err(io_fail(&tw_pack::disk::prefs_path(&path), e)); + } + let written = s + .cfg + .write(&after.config_yaml, Some(base_version), origin) + .await; + let version = match written { + Ok(v) => v, + Err(e) => { + undo.restore(); + if prefs_changed { + let _ = tw_pack::disk::write_prefs(&path, before.client_prefs.as_ref()); + } + return Err(apply_fail(e)); + } + }; + if prefs_changed { + s.bus().emit(tw_api::Event::ClientPrefsChanged { + id: s.bus().next_id(), + at_ms: crate::config::now_ms(), + }); + } + if !changed_keys.is_empty() { + s.bus().emit(tw_api::Event::GatewayKeysChanged { + id: s.bus().next_id(), + names: changed_keys, + at_ms: crate::config::now_ms(), + }); + } + Ok(version) +} + +// ─────────────────────────────────────────────────────────── 项目 + +async fn items(State(s): State) -> Result>, Fail> { + let (_, pack) = current(&s, PackOrigin::Export)?; + let snap = model::snapshot(&pack).map_err(|e| model_fail(e, None))?; + Ok(Json( + snap.items + .iter() + .filter(|(id, _)| !id.kind.is_order()) + .filter(|(id, _)| core_keeps_prefs() || id.kind != ItemKind::ClientPrefs) + .map(|(id, item)| tw_api::ItemNode { + item: item_ref(id), + refs: item + .refs + .iter() + .filter(|r| !r.kind.is_order()) + .map(item_ref) + .collect(), + }) + .collect(), + )) +} + +// ─────────────────────────────────────────────────────────── 导出 + +async fn export( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let pass = passphrase(req.passphrase.as_deref())?; + let (_, mut local) = current(&s, PackOrigin::Export)?; + if !core_keeps_prefs() { + local.client_prefs = req.client_prefs.clone(); + } + let scope = match &req.scope { + tw_api::ExportScope::All => None, + tw_api::ExportScope::Items(items) => { + let snap = model::snapshot(&local).map_err(|e| model_fail(e, None))?; + let ids: Vec = items.iter().map(item_id).collect(); + if let Some(missing) = ids.iter().find(|id| !snap.items.contains_key(*id)) { + return Err(fail( + StatusCode::NOT_FOUND, + msg!( + "pack.no_such_item", kind = missing.kind.slug(), name = &missing.name => + "There is no {kind} named `{name}`." + ), + )); + } + Some(ids) + } + }; + let out = tw_pack::export::export( + &local, + tw_pack::export::Options { + scope, + secrets: req.secrets, + client_prefs: None, + origin: PackOrigin::Export, + }, + ) + .map_err(|e| model_fail(e, None))?; + let (file_name, data_b64) = file_of(&out.pack, pass)?; + Ok(Json(tw_api::ExportedFile { + file_name, + data_b64, + included: out.included.iter().map(item_ref).collect(), + omitted_logins: out.omitted_logins, + })) +} + +// ─────────────────────────────────────────────────────────── 导入 + +fn import_ctx() -> tw_pack::import::Context<'static> { + tw_pack::import::Context { + has_aws_profile: &|p| tw_config::aws_profile::credentials(p).is_ok(), + new_key: &tw_config::generate_key, + with_prefs: core_keeps_prefs(), + } +} + +fn import_fail(e: tw_pack::import::ImportError, pack: &Pack) -> Fail { + use tw_pack::import::ImportError as E; + match e { + E::Model(m) => model_fail(m, Some(pack)), + E::Rejected(r) => apply_fail(ApplyError::Rejected(*r)), + E::Exists(_) | E::NameTaken(..) => fail(StatusCode::CONFLICT, e.msg()), + E::NotInPack(_) => fail(StatusCode::NOT_FOUND, e.msg()), + E::Item(..) => fail(StatusCode::UNPROCESSABLE_ENTITY, e.msg()), + } +} + +fn row_view(r: tw_pack::import::Row) -> tw_api::ImportRow { + use tw_pack::import::{Flag, Status}; + tw_api::ImportRow { + item: item_ref(&r.item), + status: match r.status { + Status::New => tw_api::ImportStatus::New, + Status::Same => tw_api::ImportStatus::Same, + Status::Conflict => tw_api::ImportStatus::Conflict, + Status::Error => tw_api::ImportStatus::Error, + }, + global: r.global, + flags: r + .flags + .into_iter() + .map(|f| match f { + Flag::MissingKey => tw_api::ImportFlag::MissingKey, + Flag::NeedsLogin => tw_api::ImportFlag::NeedsLogin, + Flag::PluginDisabled => tw_api::ImportFlag::PluginDisabled, + }) + .collect(), + differs: r.differs, + suggested_name: r.suggested_name, + error: r.error, + refs: r.refs.iter().map(item_ref).collect(), + } +} + +async fn import_preview( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let incoming = open_pack(&req.data_b64, req.passphrase.as_deref())?; + let (cur, local) = current(&s, PackOrigin::Import)?; + let rows = tw_pack::import::preview(&local, &incoming, &import_ctx()) + .map_err(|e| import_fail(e, &incoming))?; + Ok(Json(tw_api::ImportPreview { + manifest: pack_info(&incoming.manifest), + base_version: cur.version(), + rows: rows.into_iter().map(row_view).collect(), + client_prefs: incoming.client_prefs.clone(), + })) +} + +async fn import( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let incoming = open_pack(&req.data_b64, req.passphrase.as_deref())?; + let (cur, local) = current(&s, PackOrigin::Import)?; + if cur.version() != req.base_version { + return Err(apply_fail(ApplyError::Stale { + base: req.base_version, + current: cur.version(), + })); + } + let decisions: Vec = req + .decisions + .iter() + .map(|d| tw_pack::import::Decision { + item: item_id(&d.item), + action: match d.action { + tw_api::ImportAction::Import => tw_pack::import::Action::Import, + tw_api::ImportAction::Overwrite => tw_pack::import::Action::Overwrite, + tw_api::ImportAction::Rename => { + tw_pack::import::Action::Rename(d.new_name.clone().unwrap_or_default()) + } + tw_api::ImportAction::Skip => tw_pack::import::Action::Skip, + }, + }) + .collect(); + let applied = tw_pack::import::apply(&local, &incoming, &decisions, &import_ctx()) + .map_err(|e| import_fail(e, &incoming))?; + // 直接写原文的那几条路一样:装上、打开、批准一个改工具调用的插件要点头。导入落下去时这种 + // 插件停在待批准,这一关照样过一遍 + crate::plugins::guard_raw_write(&s, &cur.text, &applied.pack.config_yaml).await?; + let version = land( + &s, + &local, + &applied.pack, + &cur.version(), + Origin::Import, + applied.changed_keys, + ) + .await?; + Ok(Json(tw_api::ConfigWritten { version })) +} + +// ─────────────────────────────────────────────────────────── 备份 + +async fn backups(State(s): State) -> Result>, Fail> { + let path = s.config_path().to_path_buf(); + let all = tw_pack::backup::list(&path).map_err(backup_fail)?; + // 现在的内容:配置读不进来(安全模式)时没有哪一版算「当前」 + let now = s + .cfg + .current() + .ok() + .map(|cur| tw_pack::disk::current(&path, &cur.text, PackOrigin::Ui)) + .filter(|p| model::snapshot(p).is_ok()) + .map(|p| p.content_id()); + let mut rows: Vec<(tw_api::BackupEntry, String)> = Vec::new(); + for b in all.iter().rev() { + let Ok(pack) = tw_pack::backup::open(b) else { + continue; + }; + let content = pack.content_id(); + rows.push(( + tw_api::BackupEntry { + id: b.id.clone(), + at_ms: b.at_ms, + origin: b.origin.into(), + device: None, + local: true, + sync: false, + counts: tw_api::BackupCounts { + providers: pack.manifest.counts.providers, + plugins: pack.manifest.counts.plugins, + }, + core_version: pack.manifest.core_version.clone(), + current: false, + }, + content, + )); + } + if let Some(remote) = s.cfg.remote_backups() { + for v in remote.list().await { + // 同一份内容只并进本机最新的那一行 + match rows.iter_mut().find(|(e, c)| *c == v.content_id && !e.sync) { + Some((e, _)) => { + e.sync = true; + e.device = Some(v.device_name.clone()); + } + None => rows.push(( + tw_api::BackupEntry { + id: format!("{SYNC_ID}{}", v.content_id), + at_ms: v.at_ms, + origin: tw_api::ConfigOrigin::Sync, + device: Some(v.device_name.clone()), + local: false, + sync: true, + counts: tw_api::BackupCounts { + providers: v.counts.providers, + plugins: v.counts.plugins, + }, + core_version: v.core_version.clone(), + current: false, + }, + v.content_id.clone(), + )), + } + } + } + // 「当前」只标一行:内容和现在一样的版本里最新的那个 + if let Some(now) = &now + && let Some((e, _)) = rows + .iter_mut() + .filter(|(_, c)| c == now) + .max_by_key(|(e, _)| e.at_ms) + { + e.current = true; + } + let mut out: Vec = rows.into_iter().map(|(e, _)| e).collect(); + out.sort_by_key(|e| std::cmp::Reverse(e.at_ms)); + Ok(Json(out)) +} + +/// 按标识取一个版本:本机的读文件,同步位置上的去取 +async fn backup_pack(s: &ControlState, id: &str) -> Result { + if let Some(content) = id.strip_prefix(SYNC_ID) { + let Some(remote) = s.cfg.remote_backups() else { + return Err(backup_fail(tw_pack::backup::BackupError::NotFound { + id: id.to_string(), + })); + }; + return remote + .fetch(content) + .await + .map_err(|m| fail(StatusCode::BAD_GATEWAY, m)); + } + let b = tw_pack::backup::find(s.config_path(), id).map_err(backup_fail)?; + tw_pack::backup::open(&b).map_err(backup_fail) +} + +async fn backup_file( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let pass = passphrase(req.passphrase.as_deref())?; + let pack = backup_pack(&s, &req.id).await?; + // 另存出去的和导出的一样:本机设置、ChatGPT 的登录不跟着走 + let out = tw_pack::export::export( + &pack, + tw_pack::export::Options { + scope: None, + secrets: true, + client_prefs: None, + origin: PackOrigin::Export, + }, + ) + .map_err(|e| model_fail(e, Some(&pack)))?; + let mut exported = out.pack; + exported.manifest.created_at_ms = pack.manifest.created_at_ms; + let (file_name, data_b64) = file_of(&exported, pass)?; + Ok(Json(tw_api::ExportedFile { + file_name, + data_b64, + included: out.included.iter().map(item_ref).collect(), + omitted_logins: out.omitted_logins, + })) +} + +fn restore_row(r: tw_pack::restore::Row) -> tw_api::RestoreRow { + use tw_pack::restore::{Change, Kept}; + tw_api::RestoreRow { + item: item_ref(&r.item), + change: match r.change { + Change::Add => tw_api::RestoreChange::Add, + Change::Remove => tw_api::RestoreChange::Remove, + Change::Modify => tw_api::RestoreChange::Modify, + }, + kept: r.kept.map(|k| match k { + Kept::KeyInUse => tw_api::KeptReason::KeyInUse, + }), + } +} + +async fn restore_preview( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let backup = backup_pack(&s, &req.id).await?; + let (cur, local) = current(&s, PackOrigin::Restore)?; + let sync_devices = match s.cfg.remote_backups() { + Some(r) => r.other_devices().await, + None => 0, + }; + // 配置读不进来:整份换回那一版,每一项都是「新增」 + let rows = match tw_pack::restore::preview( + &local, + &backup, + &s.cfg.pinned_keys(), + core_keeps_prefs(), + ) { + Ok(rows) => rows.into_iter().map(restore_row).collect(), + Err(_) if model::snapshot(&local).is_err() => model::snapshot(&backup) + .map_err(|e| model_fail(e, Some(&backup)))? + .items + .keys() + .map(|id| tw_api::RestoreRow { + item: item_ref(id), + change: tw_api::RestoreChange::Add, + kept: None, + }) + .collect(), + Err(e) => return Err(model_fail(e, Some(&backup))), + }; + Ok(Json(tw_api::RestorePreview { + base_version: cur.version(), + rows, + sync_devices, + })) +} + +async fn restore( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let backup = backup_pack(&s, &req.id).await?; + let (cur, local) = current(&s, PackOrigin::Restore)?; + if cur.version() != req.base_version { + return Err(apply_fail(ApplyError::Stale { + base: req.base_version, + current: cur.version(), + })); + } + let picked: Option> = req + .items + .as_ref() + .map(|xs| xs.iter().map(item_id).collect()); + let (after, changed) = if model::snapshot(&local).is_err() { + // 安全模式:现在这份读不进来,整份换回去 + let mut whole = tw_pack::restore::whole(&backup, &cur.text); + if !core_keeps_prefs() { + whole.client_prefs = local.client_prefs.clone(); + } + (whole, Vec::new()) + } else { + let applied = tw_pack::restore::apply( + &local, + &backup, + picked.as_deref(), + &s.cfg.pinned_keys(), + core_keeps_prefs(), + ) + .map_err(|e| model_fail(e, Some(&backup)))?; + (applied.pack, applied.changed_keys) + }; + crate::plugins::guard_raw_write(&s, &cur.text, &after.config_yaml).await?; + let version = land(&s, &local, &after, &cur.version(), Origin::Restore, changed).await?; + Ok(Json(tw_api::ConfigWritten { version })) +} + +// ─────────────────────────────────────────────────────────── 通用偏好、固定密钥 + +async fn get_prefs(State(s): State) -> Json { + Json(tw_api::ClientPrefs { + prefs: tw_pack::disk::read_prefs(s.config_path()), + }) +} + +async fn put_prefs( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + if let Some(p) = &req.prefs + && p.to_string().len() > tw_api::CLIENT_PREFS_MAX + { + return Err(fail( + StatusCode::PAYLOAD_TOO_LARGE, + msg!( + "backup.prefs_too_large", limit = tw_api::CLIENT_PREFS_MAX / 1024 => + "The client preferences are larger than {limit} KiB." + ), + )); + } + let path = s.config_path().to_path_buf(); + if tw_pack::disk::read_prefs(&path) == req.prefs { + return Ok(Json(())); + } + tw_pack::disk::write_prefs(&path, req.prefs.as_ref()) + .map_err(|e| io_fail(&tw_pack::disk::prefs_path(&path), e))?; + // 偏好也是备份的一部分:只改了它也留一版 + if let Ok(cur) = s.cfg.current() + && let Err(e) = tw_pack::backup::record(&path, &cur.text, Origin::Ui) + { + tracing::warn!("a backup version could not be kept: {e}"); + } + s.bus().emit(tw_api::Event::ClientPrefsChanged { + id: s.bus().next_id(), + at_ms: crate::config::now_ms(), + }); + Ok(Json(())) +} + +async fn pin_keys( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let names: BTreeSet = req.names.into_iter().collect(); + s.cfg + .set_pinned_keys(names) + .map_err(|e| io_fail(&tw_pack::disk::pinned_path(s.config_path()), e))?; + Ok(Json(())) +} + +/// 给同步那一路用:固定密钥 +pub fn pinned_keys(s: &ControlState) -> BTreeSet { + s.cfg.pinned_keys() +} diff --git a/crates/tw-control/src/config.rs b/crates/tw-control/src/config.rs index d0879663..4f02ff61 100644 --- a/crates/tw-control/src/config.rs +++ b/crates/tw-control/src/config.rs @@ -27,6 +27,10 @@ pub struct ConfigManager { rejected: std::sync::Mutex>, /// 每换入一份配置响一次([`Self::applied`])。默认插件那一路等着它 applied: tokio::sync::Notify, + /// 固定密钥:本机接管的客户端在用的网关密钥,桌面端报上来(`PUT /pinned-keys`) + pinned: std::sync::Mutex>, + /// 同步位置上的备份版本。没开同步是 None + remote: std::sync::RwLock>>, } /// 一次配置改动没成的原因。 @@ -111,6 +115,9 @@ impl ConfigManager { let seen = store::read(&path).ok().map(|l| l.fingerprint); // 插件文件的路径相对配置文件所在的目录:**知道配置在哪儿的是这里**,告诉网关一声 gateway.set_config_dir(crate::plugins::dir_of(&path)); + // 配置包、备份按「代码」「数据」分开比较插件,要插件那一套来拆 + crate::backups::install_codec(); + let pinned = tw_pack::disk::read_pinned(&path); Self { path, gateway, @@ -118,6 +125,8 @@ impl ConfigManager { seen: Mutex::new(seen), rejected: std::sync::Mutex::new(None), applied: tokio::sync::Notify::new(), + pinned: std::sync::Mutex::new(pinned), + remote: std::sync::RwLock::new(None), } } @@ -243,7 +252,7 @@ impl ConfigManager { // 之后的效果是「每个存在过的版本各一条」,最新那条就是现在跑 // 着的 —— 于是「回到上一版」在列表上就是第二条,不用数。 self.set_rejected(None); - let _ = tw_config::history::snapshot(&self.path, text, origin); + self.backup(tw_pack::backup::record(&self.path, text, origin)); let version = store::version_of(text); tracing::info!(%version, origin = origin.slug(), "the configuration is in effect"); self.applied.notify_one(); @@ -299,9 +308,11 @@ impl ConfigManager { { return Err(ApplyError::RemoteControlLocked); } - // 改之前那一版进历史。**这一步在写盘之前** —— 写完再存的话, + // 改之前那一版留一个备份版本。**这一步在写盘之前** —— 写完再存的话, // 中间崩一次就永远丢了那一版,而那恰恰是最需要它的时刻。 - let _ = tw_config::history::snapshot(&self.path, &cur.text, origin); + self.backup(tw_pack::backup::record_before( + &self.path, &cur.text, origin, + )); // 写盘之前再确认一次磁盘还是我们读到的那份 —— 绝不静默覆盖手改 let fp = store::write_if_unchanged(&self.path, &cur.fingerprint, new_text)?; // **先记指纹再换入。**顺序反了的话,文件事件可能在记指纹之前就 @@ -338,34 +349,51 @@ impl ConfigManager { self.write(&text, Some(&cur.version()), origin).await } - /// 回到某一版。 - pub async fn rollback(&self, version: &str) -> Result { - let text = self.rollback_text(version)?; - let cur = self.current().ok(); - // 回滚也走同一条写入路径,所以它同样会:校验、存历史、防回环。 - self.write( - &text, - cur.as_ref().map(|c| c.version()).as_deref(), - Origin::Rollback, - ) - .await + /// 留备份版本没成:记一行,不挡这次写入 —— 配置本身已经写下去了 + fn backup(&self, r: Result, tw_pack::backup::BackupError>) { + if let Err(e) = r { + tracing::warn!("a backup version could not be kept: {e}"); + } + } + + /// 启动时:磁盘上此刻的样子和最新的备份版本不一样就留一版(第一次运行、升级之后、core + /// 没在跑时命令行改过)。来源记成外部改动 —— 说不清是谁改的 + pub fn record_startup(&self) { + if let Ok(cur) = self.current() { + self.backup(tw_pack::backup::record( + &self.path, + &cur.text, + Origin::External, + )); + } + } + + /// 本机接管的客户端在用的网关密钥(固定密钥)。恢复、导入、同步不删它们 + pub fn pinned_keys(&self) -> std::collections::BTreeSet { + self.pinned.lock().map(|g| g.clone()).unwrap_or_default() + } + + /// 换掉固定密钥,存进数据目录(`pinned-keys.json`),重启之后还在 + pub fn set_pinned_keys( + &self, + names: std::collections::BTreeSet, + ) -> std::io::Result<()> { + tw_pack::disk::write_pinned(&self.path, &names)?; + if let Ok(mut g) = self.pinned.lock() { + *g = names; + } + Ok(()) + } + + /// 同步位置上的版本(开着同步时由同步那一路装上,关掉时换成 None) + pub fn set_remote_backups(&self, r: Option>) { + if let Ok(mut g) = self.remote.write() { + *g = r; + } } - /// 回滚要写回去的那一版原文。**不写**:控制面先看过它(插件那一节,见 - /// `plugins::guard_raw_write`)再写 - pub fn rollback_text(&self, version: &str) -> Result { - let all = tw_config::history::list(&self.path)?; - let target = all - .iter() - .rev() - .find(|v| v.version == version || v.version.ends_with(version)) - .ok_or_else(|| { - ApplyError::BadPath(msg!( - "control.no_such_version", version = version => - "the version history has no {version}" - )) - })?; - Ok(tw_config::history::read(target)?) + pub fn remote_backups(&self) -> Option> { + self.remote.read().ok().and_then(|g| g.clone()) } /// 记下一个不是我们写的指纹。**首次运行生成配置之后要调它** —— diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index fbfb3d23..f6720f71 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -23,6 +23,7 @@ use tw_types::{Msg, msg}; use contract::RouterExt; pub mod aliases; +pub mod backups; pub mod chatgpt; pub mod clients; pub mod config; @@ -104,9 +105,8 @@ pub fn router(state: ControlState) -> Router { .at(ep::GetConfig, get_config) .at(ep::PatchConfig, patch_config) .at(ep::PutConfig, put_config) - .at(ep::ConfigHistory, config_history) .at(ep::ConfigAt, config::path_at) - .at(ep::ConfigRollback, config_rollback) + .merge(backups::router()) .at(ep::ConfigRepairPlan, config_repair_plan) .at(ep::RepairConfig, repair_config) .at(ep::Summary, summary) @@ -1515,50 +1515,6 @@ async fn put_config( Ok(Json(tw_api::ConfigWritten { version })) } -async fn config_history( - State(s): State, -) -> Result>, Fail> { - let all = tw_config::history::list(s.config_path()).map_err(unreadable_config)?; - let now = s.cfg.current().map(|c| c.version()).unwrap_or_default(); - // **新的在前。**用户找的几乎总是最近那几版。 - Ok(Json( - all.into_iter() - .rev() - .map(|v| tw_api::ConfigVersion { - current: v.version == now, - version: v.version, - at_ms: v.at_ms, - origin: v.origin.into(), - bytes: v.bytes, - }) - .collect(), - )) -} - -async fn config_rollback( - State(s): State, - Json(req): Json, -) -> Result, Fail> { - let cur = s - .cfg - .current() - .map_err(|e| apply_fail(ApplyError::Store(e)))?; - let text = s.cfg.rollback_text(&req.version).map_err(apply_fail)?; - // 回到一份打开着工具调用插件的旧配置,同样要点头 - plugins::guard_raw_write(&s, &cur.text, &text).await?; - // 回滚也走同一条写入路径:校验、存历史、防回环 - let version = s - .cfg - .write( - &text, - Some(&cur.version()), - tw_config::history::Origin::Rollback, - ) - .await - .map_err(apply_fail)?; - Ok(Json(tw_api::ConfigWritten { version })) -} - /// 一键修复会改哪几处。修不了、或者本来就读得进来,`fixes` 是空的 async fn config_repair_plan( State(s): State, @@ -1606,7 +1562,7 @@ async fn repair_config( .write( &r.text, Some(&cur.version()), - tw_config::history::Origin::Ui, + tw_config::history::Origin::Repair, ) .await .map_err(apply_fail)?; diff --git a/crates/tw-control/tests/backups.rs b/crates/tw-control/tests/backups.rs new file mode 100644 index 00000000..dfab9e05 --- /dev/null +++ b/crates/tw-control/tests/backups.rs @@ -0,0 +1,566 @@ +//! 配置包、导入导出、备份与恢复、通用偏好、固定密钥:经控制面走一遍,断言落在文件上。 + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use base64::Engine; +use tower::ServiceExt; +use tw_control::{ConfigManager, ControlState}; + +const KEY: &str = "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00"; + +fn mine() -> String { + format!( + "version: 1 +listen: + control: + key: {KEY} +clients: + - name: default + key: tw-aaaa + - name: claude-code + key: tw-cccc + client: claude-code +# 我的上游 +providers: + - name: 官方 + base_url: https://api.anthropic.com + key: sk-official +routes: + - name: default + rules: + - name: 兜底 + to: 官方 +" + ) +} + +fn theirs() -> String { + "version: 1 +listen: + control: + key: 1111111111111111111111111111111111111111111111111111111111111111 +clients: + - name: default + key: tw-theirs +providers: + - name: 官方 + base_url: https://api.anthropic.com/v2 + key: sk-theirs + - name: relay + base_url: https://relay.example + key: sk-relay +failover: + pause_secs: 30 +" + .to_string() +} + +struct Bed { + dir: tempfile::TempDir, + app: axum::Router, + state: ControlState, +} + +impl Bed { + fn path(&self) -> std::path::PathBuf { + self.dir.path().join("config.yaml") + } + fn file(&self) -> String { + std::fs::read_to_string(self.path()).unwrap() + } + fn version(&self) -> String { + tw_config::store::version_of(&self.file()) + } +} + +fn state_for( + p: std::path::PathBuf, + mgr: Arc, + gw: tw_gateway::AppState, +) -> ControlState { + let _ = p; + ControlState { + shutdown: Default::default(), + remote: Default::default(), + cfg: mgr, + gateway: gw, + store: None, + started: std::time::Instant::now(), + price_updater: Default::default(), + chatgpt: Default::default(), + zai: Default::default(), + } +} + +fn bed(yaml: &str) -> Bed { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("config.yaml"); + std::fs::write(&p, yaml).unwrap(); + let cfg = tw_config::try_parse(yaml).unwrap(); + let gw = tw_gateway::AppState::new(cfg).unwrap(); + let bus = gw.bus.clone(); + let mgr = Arc::new(ConfigManager::new(p.clone(), gw.clone(), bus)); + let state = state_for(p, mgr, gw); + Bed { + app: tw_control::router(state.clone()), + state, + dir: d, + } +} + +async fn call( + app: &axum::Router, + method: &str, + path: &str, + body: serde_json::Value, +) -> (StatusCode, serde_json::Value) { + let r = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let st = r.status(); + let b = axum::body::to_bytes(r.into_body(), 1 << 24).await.unwrap(); + let text = String::from_utf8_lossy(&b).to_string(); + let v = serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text)); + (st, v) +} + +async fn export_all(b: &Bed, secrets: bool) -> serde_json::Value { + let (st, v) = call( + &b.app, + "POST", + "/config/export", + serde_json::json!({ "scope": "all", "secrets": secrets }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + v +} + +#[tokio::test] +async fn an_export_carries_no_local_settings_and_imports_item_by_item() { + let src = bed(&theirs()); + let file = export_all(&src, true).await; + assert!(file["file_name"].as_str().unwrap().ends_with(".twconfig")); + let bytes = base64::engine::general_purpose::STANDARD + .decode(file["data_b64"].as_str().unwrap()) + .unwrap(); + let pack = tw_pack::read(&bytes, tw_pack::Unseal::None).unwrap(); + assert!( + !pack.config_yaml.contains("1111111111"), + "{}", + pack.config_yaml + ); + assert!(!pack.config_yaml.contains("listen"), "{}", pack.config_yaml); + + let dst = bed(&mine()); + let (st, preview) = call( + &dst.app, + "POST", + "/config/import/preview", + serde_json::json!({ "data_b64": file["data_b64"] }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{preview}"); + let status = |kind: &str, name: &str| -> String { + preview["rows"] + .as_array() + .unwrap() + .iter() + .find(|r| r["item"]["kind"] == kind && r["item"]["name"] == name) + .map(|r| r["status"].as_str().unwrap().to_string()) + .unwrap_or_else(|| panic!("no row {kind} {name}: {preview}")) + }; + assert_eq!(status("provider", "relay"), "new"); + assert_eq!(status("provider", "官方"), "conflict"); + assert_eq!(status("global", "failover"), "new"); + assert_eq!(preview["manifest"]["kind"], "full"); + assert_eq!(preview["base_version"], dst.version()); + + let (st, v) = call( + &dst.app, + "POST", + "/config/import", + serde_json::json!({ + "data_b64": file["data_b64"], + "base_version": preview["base_version"], + "decisions": [ + { "item": { "kind": "provider", "name": "relay" }, "action": "import" }, + { "item": { "kind": "provider", "name": "官方" }, "action": "rename", "new_name": "官方 (2)" }, + { "item": { "kind": "global", "name": "failover" }, "action": "import" }, + ], + }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let text = dst.file(); + let cfg = tw_config::try_parse(&text).unwrap(); + let names: Vec<&str> = cfg.providers.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["官方", "官方 (2)", "relay"]); + assert_eq!(cfg.failover.pause_secs, 30); + assert!( + text.contains("# 我的上游"), + "untouched lines keep their comments: {text}" + ); + assert!(text.contains(KEY)); + // 导入留了一个版本,来源是导入 + let (st, list) = call(&dst.app, "GET", "/backups", serde_json::json!(null)).await; + assert_eq!(st, StatusCode::OK, "{list}"); + assert_eq!(list[0]["origin"], "import"); + assert_eq!(list[0]["current"], true); + // 预览之后配置变了:409 + let (st, _) = call( + &dst.app, + "POST", + "/config/import", + serde_json::json!({ + "data_b64": file["data_b64"], + "base_version": preview["base_version"], + "decisions": [], + }), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn an_encrypted_export_needs_its_passphrase_to_preview() { + tw_pack::TEST_WORK_FACTOR.store(10, std::sync::atomic::Ordering::Relaxed); + let src = bed(&theirs()); + let (st, v) = call( + &src.app, + "POST", + "/config/export", + serde_json::json!({ "scope": "all", "secrets": false, "passphrase": "short" }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + assert_eq!(v["code"], "pack.passphrase_short"); + let (st, file) = call( + &src.app, + "POST", + "/config/export", + serde_json::json!({ "scope": "all", "secrets": false, "passphrase": "long enough" }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{file}"); + let dst = bed(&mine()); + for (pass, code) in [ + (serde_json::Value::Null, "pack.need_passphrase"), + (serde_json::json!("wrong one!"), "pack.wrong_passphrase"), + ] { + let (st, v) = call( + &dst.app, + "POST", + "/config/import/preview", + serde_json::json!({ "data_b64": file["data_b64"], "passphrase": pass }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + assert_eq!(v["code"], code); + } + let (st, preview) = call( + &dst.app, + "POST", + "/config/import/preview", + serde_json::json!({ "data_b64": file["data_b64"], "passphrase": "long enough" }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{preview}"); + assert_eq!(preview["manifest"]["secrets"], false); + assert_eq!(preview["manifest"]["encrypted"], true); + let relay = preview["rows"] + .as_array() + .unwrap() + .iter() + .find(|r| r["item"]["name"] == "relay") + .unwrap(); + assert_eq!(relay["flags"], serde_json::json!(["missing_key"])); +} + +#[tokio::test] +async fn exporting_chosen_items_brings_what_they_refer_to() { + let b = bed(&mine()); + let (st, v) = call( + &b.app, + "POST", + "/config/export", + serde_json::json!({ + "scope": { "items": [{ "kind": "route", "name": "default" }] }, + "secrets": true, + }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let included: Vec = v["included"] + .as_array() + .unwrap() + .iter() + .map(|i| { + format!( + "{}/{}", + i["kind"].as_str().unwrap(), + i["name"].as_str().unwrap() + ) + }) + .collect(); + assert_eq!(included, ["provider/官方", "route/default"]); + let (st, v) = call( + &b.app, + "POST", + "/config/export", + serde_json::json!({ + "scope": { "items": [{ "kind": "provider", "name": "nope" }] }, + "secrets": true, + }), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + assert_eq!(v["code"], "pack.no_such_item"); + // 项目清单 + let (st, items) = call(&b.app, "GET", "/config/items", serde_json::json!(null)).await; + assert_eq!(st, StatusCode::OK, "{items}"); + let route = items + .as_array() + .unwrap() + .iter() + .find(|n| n["item"]["kind"] == "route") + .unwrap(); + assert_eq!( + route["refs"], + serde_json::json!([{ "kind": "provider", "name": "官方" }]) + ); +} + +#[tokio::test] +async fn a_restore_brings_back_items_keeps_pinned_keys_and_is_itself_a_version() { + let b = bed(&mine()); + // 记下现在这一版,再改两处:删掉一把密钥、换掉上游地址 + b.state.cfg.record_startup(); + let changed = mine() + .replace( + " - name: claude-code\n key: tw-cccc\n client: claude-code\n", + "", + ) + .replace("https://api.anthropic.com\n", "https://changed.example\n"); + b.state + .cfg + .write(&changed, None, tw_config::history::Origin::Ui) + .await + .unwrap(); + // 接管着 claude-code 的那把在现在的配置里已经没了;固定的是 default + let (st, _) = call( + &b.app, + "PUT", + "/pinned-keys", + serde_json::json!({ "names": ["default"] }), + ) + .await; + assert_eq!(st, StatusCode::OK); + assert_eq!( + tw_pack::disk::read_pinned(&b.path()), + ["default".to_string()].into_iter().collect() + ); + let (_, list) = call(&b.app, "GET", "/backups", serde_json::json!(null)).await; + let oldest = list.as_array().unwrap().last().unwrap()["id"].clone(); + let (st, preview) = call( + &b.app, + "POST", + "/backups/preview", + serde_json::json!({ "id": oldest }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{preview}"); + let rows: Vec = preview["rows"] + .as_array() + .unwrap() + .iter() + .map(|r| { + format!( + "{} {}/{}", + r["change"].as_str().unwrap(), + r["item"]["kind"].as_str().unwrap(), + r["item"]["name"].as_str().unwrap() + ) + }) + .collect(); + assert_eq!(rows, ["modify provider/官方", "add key/claude-code"]); + // 只恢复上游 + let (st, v) = call( + &b.app, + "POST", + "/backups/restore", + serde_json::json!({ + "id": oldest, + "base_version": preview["base_version"], + "items": [{ "kind": "provider", "name": "官方" }], + }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let cfg = tw_config::try_parse(&b.file()).unwrap(); + assert_eq!(cfg.providers[0].base_url, "https://api.anthropic.com"); + assert!(!cfg.clients.iter().any(|c| c.name == "claude-code")); + let (_, list) = call(&b.app, "GET", "/backups", serde_json::json!(null)).await; + assert_eq!(list[0]["origin"], "restore"); + // 另存为配置包:本机设置不在里面 + let (st, file) = call( + &b.app, + "POST", + "/backups/file", + serde_json::json!({ "id": oldest }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{file}"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(file["data_b64"].as_str().unwrap()) + .unwrap(); + let pack = tw_pack::read(&bytes, tw_pack::Unseal::None).unwrap(); + assert!(!pack.config_yaml.contains("listen"), "{}", pack.config_yaml); + let (st, v) = call( + &b.app, + "POST", + "/backups/preview", + serde_json::json!({ "id": "123" }), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND, "{v}"); + assert_eq!(v["code"], "backup.not_found"); +} + +#[tokio::test] +async fn client_preferences_are_kept_announced_and_part_of_a_version() { + let b = bed(&mine()); + let mut events = b.state.bus().subscribe(); + let (st, v) = call(&b.app, "GET", "/client-prefs", serde_json::json!(null)).await; + assert_eq!(st, StatusCode::OK); + assert_eq!(v, serde_json::json!({ "prefs": null })); + let prefs = serde_json::json!({ "language": "en", "appearance": "dark" }); + let (st, _) = call( + &b.app, + "PUT", + "/client-prefs", + serde_json::json!({ "prefs": prefs }), + ) + .await; + assert_eq!(st, StatusCode::OK); + let (_, v) = call(&b.app, "GET", "/client-prefs", serde_json::json!(null)).await; + assert_eq!(v["prefs"], prefs); + let got = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if let Ok(tw_api::Event::ClientPrefsChanged { .. }) = events.recv().await { + return; + } + } + }) + .await; + assert!(got.is_ok(), "no client_prefs_changed"); + let last = tw_pack::backup::list(&b.path()).unwrap().pop().unwrap(); + assert_eq!( + tw_pack::backup::open(&last).unwrap().client_prefs, + Some(prefs) + ); + let big = "x".repeat(tw_api::CLIENT_PREFS_MAX + 1); + let (st, v) = call( + &b.app, + "PUT", + "/client-prefs", + serde_json::json!({ "prefs": { "big": big } }), + ) + .await; + assert_eq!(st, StatusCode::PAYLOAD_TOO_LARGE, "{v}"); +} + +#[tokio::test] +async fn overwriting_a_key_by_import_announces_it() { + let src = bed(&theirs()); + let file = export_all(&src, true).await; + let dst = bed(&mine()); + let mut events = dst.state.bus().subscribe(); + let (_, preview) = call( + &dst.app, + "POST", + "/config/import/preview", + serde_json::json!({ "data_b64": file["data_b64"] }), + ) + .await; + let (st, v) = call( + &dst.app, + "POST", + "/config/import", + serde_json::json!({ + "data_b64": file["data_b64"], + "base_version": preview["base_version"], + "decisions": [{ "item": { "kind": "key", "name": "default" }, "action": "overwrite" }], + }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let names = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if let Ok(tw_api::Event::GatewayKeysChanged { names, .. }) = events.recv().await { + return names; + } + } + }) + .await + .expect("no gateway_keys_changed"); + assert_eq!(names, ["default"]); +} + +/// 安全模式:配置读不进来,从备份恢复整份换回去,钥匙是现在这一把 +#[tokio::test] +async fn in_safe_mode_a_backup_comes_back_whole() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("config.yaml"); + std::fs::write(&p, mine()).unwrap(); + // 先有一版好的备份 + tw_pack::backup::record(&p, &mine(), tw_config::history::Origin::Ui).unwrap(); + let broken = format!( + "version: 1\nlisten:\n control:\n key: {KEY}\nclients:\n - name: default\n kye: x\n" + ); + std::fs::write(&p, &broken).unwrap(); + let (cfg, r) = tw_config::stand_in(&broken).unwrap(); + let gw = tw_gateway::AppState::new(cfg).unwrap(); + let bus = gw.bus.clone(); + let mgr = Arc::new(ConfigManager::new(p.clone(), gw.clone(), bus).standing_in(&r)); + let app = tw_control::router(state_for(p.clone(), mgr, gw)); + let (st, list) = call(&app, "GET", "/backups", serde_json::json!(null)).await; + assert_eq!(st, StatusCode::OK, "{list}"); + let id = list[0]["id"].clone(); + let (st, preview) = call( + &app, + "POST", + "/backups/preview", + serde_json::json!({ "id": id }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{preview}"); + assert!( + preview["rows"] + .as_array() + .unwrap() + .iter() + .all(|r| r["change"] == "add") + ); + let (st, v) = call( + &app, + "POST", + "/backups/restore", + serde_json::json!({ "id": id, "base_version": preview["base_version"] }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let text = std::fs::read_to_string(&p).unwrap(); + assert_eq!(text, mine()); +} diff --git a/crates/tw-control/tests/contract.rs b/crates/tw-control/tests/contract.rs index 7ada07bd..45a5228c 100644 --- a/crates/tw-control/tests/contract.rs +++ b/crates/tw-control/tests/contract.rs @@ -130,13 +130,7 @@ async fn failures_the_framework_answers_are_messages_too() { assert_eq!(error(&body.unwrap()).code, "control.method_not_allowed"); // 请求体读不成请求类型 - let (st, body) = send( - &app, - "POST", - ep::ConfigRollback::PATH, - Some("{\"version\":5}"), - ) - .await; + let (st, body) = send(&app, "POST", ep::RestorePreview::PATH, Some("{\"id\":5}")).await; assert!(st.is_client_error(), "{st}"); let m = error(&body.unwrap()); assert_eq!(m.code, "control.request_rejected"); diff --git a/crates/tw-control/tests/gate.rs b/crates/tw-control/tests/gate.rs index 8f30731f..71c45f50 100644 --- a/crates/tw-control/tests/gate.rs +++ b/crates/tw-control/tests/gate.rs @@ -373,27 +373,31 @@ async fn the_key_cannot_be_changed_or_removed_through_the_control_plane() { /// 历史里没有钥匙;回滚回去,钥匙是现在这一把。 #[tokio::test] -async fn a_rollback_through_the_control_plane_keeps_the_key() { +async fn a_restore_through_the_control_plane_keeps_the_key() { let b = bed(); let app = b.app(); let path = b.dir.path().join("config.yaml"); - let v0 = tw_config::store::version_of(&b.file()); b.state .cfg .write(&yaml(KEY).replace("tw-aaaa", "tw-bbbb"), None, Origin::Ui) .await .unwrap(); - for v in tw_config::history::list(&path).unwrap() { - let t = tw_config::history::read(&v).unwrap(); - assert!(!t.contains(KEY), "历史里有钥匙:{t}"); + let all = tw_pack::backup::list(&path).unwrap(); + for v in &all { + let t = tw_pack::backup::open(v).unwrap().config_yaml; + assert!(!t.contains(KEY), "备份里有钥匙:{t}"); } + let first = &all[0]; + let base = tw_config::store::version_of(&b.file()); let (st, body) = call( &app, "POST", - "/config/rollback", - serde_json::json!({ "version": v0 }), + "/backups/restore", + serde_json::json!({ "id": first.id, "base_version": base }), ) .await; assert_eq!(st, StatusCode::OK, "{body}"); - assert_eq!(b.file(), yaml(KEY)); + let now = b.file(); + assert!(now.contains(KEY), "{now}"); + assert!(now.contains("tw-aaaa"), "{now}"); } diff --git a/crates/tw-control/tests/m2_acceptance.rs b/crates/tw-control/tests/m2_acceptance.rs index 8182e1da..fdc4c6d4 100644 --- a/crates/tw-control/tests/m2_acceptance.rs +++ b/crates/tw-control/tests/m2_acceptance.rs @@ -127,6 +127,32 @@ async fn two_b_a_path_by_index_and_a_path_by_name_mean_the_same_thing() { assert_eq!(by_name, by_index); } +/// 回到备份里内容是 `version` 那一版:和界面上「恢复…」同一套(`tw_pack::restore`) +async fn restore(mgr: &tw_control::ConfigManager, version: &str) { + let path = mgr.path().to_path_buf(); + let b = tw_pack::backup::list(&path) + .unwrap() + .into_iter() + .rev() + .find(|b| { + let text = tw_pack::backup::open(b).unwrap().config_yaml; + let real = tw_config::control_key::unmask( + &text, + tw_config::control_key::raw_in(REAL).unwrap().as_deref(), + ) + .unwrap(); + tw_config::store::version_of(&real) == version + }) + .expect("这一版在备份里"); + let backup = tw_pack::backup::open(&b).unwrap(); + let cur = mgr.current().unwrap(); + let local = tw_pack::disk::current(&path, &cur.text, tw_pack::PackOrigin::Restore); + let out = tw_pack::restore::apply(&local, &backup, None, &Default::default(), true).unwrap(); + mgr.write(&out.pack.config_yaml, Some(&cur.version()), Origin::Restore) + .await + .unwrap(); +} + #[tokio::test] async fn three_a_bad_edit_can_be_undone_in_one_step() { let (_d, mgr) = setup(); @@ -145,17 +171,17 @@ async fn three_a_bad_edit_can_be_undone_in_one_step() { .contains("坏的") ); - mgr.rollback(&good).await.unwrap(); + restore(&mgr, &good).await; assert_eq!( std::fs::read_to_string(mgr.path()).unwrap(), REAL, - "回滚没有精确还原" + "恢复没有精确还原" ); } #[tokio::test] -async fn three_b_a_rollback_can_itself_be_rolled_back() { - // 「回滚之后发现回错了」是回滚最常见的用法之一。 +async fn three_b_a_restore_can_itself_be_undone() { + // 「恢复之后发现回错了」是恢复最常见的用法之一:恢复本身也留一个版本 let (_d, mgr) = setup(); let v0 = mgr.current().unwrap().version(); mgr.patch(&patch("/providers/官方/key", "sk-new"), None, Origin::Ui) @@ -163,14 +189,14 @@ async fn three_b_a_rollback_can_itself_be_rolled_back() { .unwrap(); let v1 = mgr.current().unwrap().version(); - mgr.rollback(&v0).await.unwrap(); + restore(&mgr, &v0).await; assert!( !std::fs::read_to_string(mgr.path()) .unwrap() .contains("sk-new") ); - mgr.rollback(&v1).await.unwrap(); + restore(&mgr, &v1).await; assert!( std::fs::read_to_string(mgr.path()) .unwrap() diff --git a/crates/tw-control/tests/plugin_defaults.rs b/crates/tw-control/tests/plugin_defaults.rs index d9b1edef..83e2bf37 100644 --- a/crates/tw-control/tests/plugin_defaults.rs +++ b/crates/tw-control/tests/plugin_defaults.rs @@ -314,8 +314,8 @@ async fn the_first_run_adds_every_default_turned_off() { .is_some() ); - // 写配置的这一版来源是 defaults,一版、一条历史 - let (_, history) = call(&b.app, "GET", "/config/history", None).await; + // 写配置的这一版来源是 defaults,一版、一个备份版本 + let (_, history) = call(&b.app, "GET", "/backups", None).await; let now = history .as_array() .unwrap() diff --git a/crates/tw-control/tests/plugins.rs b/crates/tw-control/tests/plugins.rs index 30a5d9ed..e9ea6a69 100644 --- a/crates/tw-control/tests/plugins.rs +++ b/crates/tw-control/tests/plugins.rs @@ -1588,22 +1588,28 @@ async fn raw_configuration_writes_cannot_bypass_the_confirmation() { assert_eq!(b.config(), before); std::fs::write(b.file(&id), rewrite_calls()).unwrap(); - // 回滚到它开着的那一版:拒绝 + // 恢复到它开着的那一版:恢复了,但插件停在待批准,跑不起来 —— 打开它要在这里点头 let (st, v) = b.save_confirmed(&id, &rewrite_calls(), true).await; assert_eq!(st, StatusCode::OK, "{v}"); - let on = b.version().await; + let on_backup = tw_pack::backup::list(&b.dir.join("config.yaml")) + .unwrap() + .last() + .unwrap() + .id + .clone(); let (st, v) = b.save(&id, &rewrite_calls(), false).await; assert_eq!(st, StatusCode::OK, "{v}"); - let before = b.config(); let (st, v) = call( &b.app, "POST", - "/config/rollback", - Some(json!({"version": on})), + "/backups/restore", + Some(json!({"id": on_backup, "base_version": b.version().await})), ) .await; - refused(st, &v, "rolling back to a version where it was on"); - assert_eq!(b.config(), before); + assert_eq!(st, StatusCode::OK, "{v}"); + let p = b.parsed().plugins.into_iter().find(|p| p.id == id).unwrap(); + assert!(p.enabled); + assert_ne!(p.sha256, sha(&rewrite_calls()), "恢复替人批准了"); // 和插件无关的改动照常 let (st, v) = put(shown().await.replace("# 默认那把", "# 改了一个注释")).await; diff --git a/crates/tw-control/tests/pricing.rs b/crates/tw-control/tests/pricing.rs index 838c150d..b8312987 100644 --- a/crates/tw-control/tests/pricing.rs +++ b/crates/tw-control/tests/pricing.rs @@ -211,7 +211,7 @@ async fn a_sheet_is_created_chosen_renamed_and_deleted_only_once_nobody_uses_it( async fn a_sheet_saved_with_its_upstreams_assigns_them_in_the_same_version() { let b = bed(BASE); let versions = || { - tw_config::history::list(&b.dir.path().join("config.yaml")) + tw_pack::backup::list(&b.dir.path().join("config.yaml")) .unwrap() .len() }; diff --git a/crates/tw-control/tests/resources.rs b/crates/tw-control/tests/resources.rs index 3fd30884..481eb3ce 100644 --- a/crates/tw-control/tests/resources.rs +++ b/crates/tw-control/tests/resources.rs @@ -443,14 +443,15 @@ async fn renaming_an_upstream_moves_its_references_in_the_same_version() { let cfg = b.parsed(); assert_eq!(cfg.providers[0].name, "anthropic"); assert_eq!(cfg.groups[0].names(), ["anthropic"]); - // 一次保存一个版本:历史里只多了改之前的那一份 - let history = tw_config::history::list(&b.dir.path().join("config.yaml")).unwrap(); + // 一次保存一个版本:备份里是改之前(来源说不清,记成外部改动)和改之后的那两份 + let history = tw_pack::backup::list(&b.dir.path().join("config.yaml")).unwrap(); + let origins: Vec<_> = history.iter().map(|v| v.origin).collect(); assert_eq!( - history - .iter() - .filter(|v| v.origin == tw_config::history::Origin::Ui) - .count(), - 2 + origins, + [ + tw_config::history::Origin::External, + tw_config::history::Origin::Ui + ] ); } diff --git a/crates/tw-control/tests/sync.rs b/crates/tw-control/tests/sync.rs index 88f4365f..12cfb1f9 100644 --- a/crates/tw-control/tests/sync.rs +++ b/crates/tw-control/tests/sync.rs @@ -158,26 +158,6 @@ async fn a_write_that_would_not_parse_never_reaches_the_disk() { assert_eq!(std::fs::read_to_string(mgr.path()).unwrap(), BASE); } -#[tokio::test] -async fn a_rollback_goes_through_the_same_door_as_everything_else() { - // 三条路各写一遍,就会有两条忘了存历史、一条忘了防回环。 - let (_d, mgr, _bus) = setup(); - let v0 = mgr.current().unwrap().version(); - mgr.write(&format!("{BASE}providers: []\n"), None, Origin::Ui) - .await - .unwrap(); - assert!( - std::fs::read_to_string(mgr.path()) - .unwrap() - .contains("providers") - ); - - mgr.rollback(&v0).await.unwrap(); - let back = std::fs::read_to_string(mgr.path()).unwrap(); - assert_eq!(back, BASE); - assert!(back.contains("别动我这句注释"), "注释没回来"); -} - #[tokio::test] async fn every_write_leaves_the_previous_version_in_history() { // 「回滚」这个动作要的是「回到我动它之前」。 @@ -185,15 +165,15 @@ async fn every_write_leaves_the_previous_version_in_history() { mgr.write(&format!("{BASE}providers: []\n"), None, Origin::Ui) .await .unwrap(); - let hist = tw_config::history::list(mgr.path()).unwrap(); + let hist = tw_pack::backup::list(mgr.path()).unwrap(); let texts: Vec = hist .iter() - .map(|v| tw_config::history::read(v).unwrap()) + .map(|v| tw_pack::backup::open(v).unwrap().config_yaml) .collect(); - // 历史里存的钥匙是打码的 + // 备份里存的钥匙是打码的 assert!( texts.contains(&tw_config::control_key::mask(BASE)), - "改之前那一版没进历史:{texts:?}" + "改之前那一版没进备份:{texts:?}" ); } @@ -208,10 +188,10 @@ async fn an_external_edit_also_lands_in_history_so_it_can_be_undone() { .await .unwrap() .expect("该被当成外部改动"); - let hist = tw_config::history::list(mgr.path()).unwrap(); + let hist = tw_pack::backup::list(mgr.path()).unwrap(); assert!( hist.iter().any(|v| v.origin == Origin::External), - "外部改动没进历史" + "外部改动没进备份" ); } diff --git a/crates/tw-gateway/src/plugin/source.rs b/crates/tw-gateway/src/plugin/source.rs index 56e2dc77..76bde169 100644 --- a/crates/tw-gateway/src/plugin/source.rs +++ b/crates/tw-gateway/src/plugin/source.rs @@ -184,6 +184,107 @@ pub fn with_values(m: &Manifest, source: &str) -> Option { Some(out) } +/// 把一份源码拆成**代码**和**数据**,给配置包、同步按这两样分开比较(`tw_pack::model`)。 +/// +/// 数据是出错时怎么办、范围、每个设置的值;代码是完整的源码,manifest 字面量里的数据去掉, +/// 字面量按固定的样子重写([`literal::write`])—— 只差数据的两份源码拆出来的代码一字不差, +/// 字面量原来怎么排版都一样。代码本身还是一份能读的源码:数据都是不写时的值。读不出 manifest +/// 是 None +pub fn split_data(source: &str) -> Option<(String, serde_json::Value)> { + let lit = literal::find(source).ok()?; + let values = lit.values()?; + let literal::Data::Object(fields) = &lit.data else { + return None; + }; + let code_fields = fields + .iter() + .filter(|(k, _)| k != "on_error" && k != "match") + .map(|(k, v)| { + let v = match (k.as_str(), v) { + ("settings", literal::Data::Object(specs)) => literal::Data::Object( + specs + .iter() + .map(|(name, spec)| { + let spec = match spec { + literal::Data::Object(f) => literal::Data::Object( + f.iter().filter(|(k, _)| k != "value").cloned().collect(), + ), + other => other.clone(), + }; + (name.clone(), spec) + }) + .collect(), + ), + _ => v.clone(), + }; + (k.clone(), v) + }) + .collect(); + let newline = if source.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let code = format!( + "{}{}{}", + &source[..lit.span.start], + literal::write(&literal::Data::Object(code_fields), "", newline), + &source[lit.span.end..] + ); + let data = serde_json::json!({ + "on_error": values.on_error.as_str(), + "match": { + "clients": values.scope.clients, + "models": values.scope.models, + "upstreams": values.scope.upstreams, + }, + "settings": values.settings.into_iter().collect::>(), + }); + Some((code, data)) +} + +/// [`split_data`] 反过来:把数据写进源码,**只换 manifest 那一段**。数据和源码声明的对不上 +/// (设置不在、类型不对)是 None +pub fn join_data(source: &str, data: &serde_json::Value) -> Option { + let strings = |v: Option<&serde_json::Value>| -> Option> { + match v { + None | Some(serde_json::Value::Null) => Some(Vec::new()), + Some(serde_json::Value::Array(a)) => { + a.iter().map(|x| x.as_str().map(str::to_string)).collect() + } + _ => None, + } + }; + let on_error = match data.get("on_error").and_then(serde_json::Value::as_str) { + Some(s) => tw_plugin::OnError::from_manifest(s)?, + None => tw_plugin::OnError::Reject, + }; + let m = data.get("match"); + let values = Values { + on_error, + scope: tw_plugin::Scope { + clients: strings(m.and_then(|m| m.get("clients")))?, + models: strings(m.and_then(|m| m.get("models")))?, + upstreams: strings(m.and_then(|m| m.get("upstreams")))?, + }, + settings: match data.get("settings") { + Some(serde_json::Value::Object(o)) => { + o.iter().map(|(k, v)| (k.clone(), v.clone())).collect() + } + None | Some(serde_json::Value::Null) => Vec::new(), + Some(_) => return None, + }, + }; + literal::rewrite(source, &values).ok() +} + +/// 这份源码的插件改不改得了回答里的工具调用(权限有 `reply_tool_calls`),**不编译**,照 +/// 文件里写着的 manifest 判断。读不出来的按改得了算 +pub fn steers_declared(source: &[u8]) -> bool { + display_manifest(source) + .is_none_or(|m| m.permissions.contains(&tw_api::Permission::ReplyToolCalls)) +} + #[cfg(test)] mod tests { use super::*; @@ -332,4 +433,38 @@ mod tests { assert_eq!(patched.on_error, OnError::Skip); assert_eq!(patched.settings[0].value, json!("b")); } + + /// 只差数据的两份源码拆出来代码一样;数据写回去就是原来的数据 + #[test] + fn code_and_data_split_apart_and_join_back() { + let a = SRC; + let b = rewrite( + SRC, + OnError::Skip, + &scope(&["claude-*"]), + &BTreeMap::from([("note".to_string(), SettingValue::String("b".into()))]), + ) + .unwrap(); + let (code_a, data_a) = split_data(a).unwrap(); + let (code_b, data_b) = split_data(&b).unwrap(); + assert_eq!(code_a, code_b, "只差数据,代码就该一样"); + assert_ne!(data_a, data_b); + assert_eq!(data_b["on_error"], "skip"); + assert_eq!(data_b["match"]["models"], json!(["claude-*"])); + assert_eq!(data_b["settings"]["note"], "b"); + // 代码本身读得出 manifest,数据都是不写时的值 + let (_, empty) = split_data(&code_a).unwrap(); + assert_eq!(empty["settings"]["note"], ""); + // 数据写回代码:和原来那一份数据一样 + let back = join_data(&code_a, &data_b).unwrap(); + assert_eq!(split_data(&back).unwrap().1, data_b); + assert!(back.contains("export function onRequest(req) { return req; }")); + // 数据写回原文件:只差数据时原样返回 + assert_eq!(join_data(a, &data_a).unwrap(), a); + // 数据和源码对不上 + assert!(join_data(a, &json!({"settings": {"unknown": 1}})).is_none()); + assert!(split_data("no manifest here").is_none()); + assert!(steers_declared(b"no manifest here")); + assert!(!steers_declared(SRC.as_bytes())); + } } diff --git a/crates/tw-observe/src/bus.rs b/crates/tw-observe/src/bus.rs index feff35b9..840b791d 100644 --- a/crates/tw-observe/src/bus.rs +++ b/crates/tw-observe/src/bus.rs @@ -125,6 +125,8 @@ fn about_the_request(ev: &tw_api::Event) -> bool { | E::ListenChanged { .. } | E::ConfigReloaded { .. } | E::ConfigRejected { .. } + | E::GatewayKeysChanged { .. } + | E::ClientPrefsChanged { .. } | E::EventsDropped { .. } => false, } } diff --git a/crates/tw-pack/Cargo.toml b/crates/tw-pack/Cargo.toml index a2a8625c..d842fd96 100644 --- a/crates/tw-pack/Cargo.toml +++ b/crates/tw-pack/Cargo.toml @@ -13,6 +13,7 @@ description = "Configuration packs (.twconfig): format, encryption, local backup [dependencies] # 配置的形状、校验、按名字改一项(保留注释)都在这里 tw-config = { workspace = true } +tw-api = { workspace = true } tw-engine = { workspace = true } tw-pricing = { workspace = true } tw-types = { workspace = true } @@ -21,6 +22,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml_ng = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } blake3 = { workspace = true } sha2 = { workspace = true } age = { workspace = true } diff --git a/crates/tw-pack/src/backup.rs b/crates/tw-pack/src/backup.rs new file mode 100644 index 00000000..c954f17e --- /dev/null +++ b/crates/tw-pack/src/backup.rs @@ -0,0 +1,445 @@ +//! 本机备份:每次配置变更留一个版本,最近 [`KEEP`] 个。取代以前的修改记录。 +//! +//! # 一版是什么 +//! +//! 一个**完整的配置包**,不加密:配置原文(本机设置、ChatGPT 的登录都在里面,和 config.yaml +//! 本身的保护级别一样),配置里列着的插件的文件,Lite 的通用偏好。**控制面的钥匙不在里面**: +//! 存的是打码的原文 —— 恢复时本机设置本来就留着现在的。 +//! +//! 文件在数据目录的 `history/` 里,名字是 `<毫秒时间戳>-<来源>.twconfig`,时间戳就是这一版 +//! 的标识。目录 0700、文件 0600。 +//! +//! # 什么时候留 +//! +//! 每个写入配置的地方在写之前([`record_before`])和生效之后([`record`])各问一次。和最新 +//! 那一版内容一样就不留:效果是「每个存在过的版本各一条」,最新那条就是现在跑着的。 +//! +//! - **写之前那一次只看配置原文**:插件的写法是先写文件、再写配置,写之前那一刻磁盘上的插件 +//! 已经是新的了 —— 拿旧的原文配新的插件存一版,那一版从来没有存在过 +//! - **凭据轮换不留**(`Origin::Rotation`):恢复到一次轮换之前,拿到的是一个已经作废的 +//! token。比较时 OAuth 的 token 也不算:轮换之后的下一次修改不会因此多出一版 +//! - 以前的修改记录(`history/*.yaml`)在第一次留版本时删掉 + +use std::path::{Path, PathBuf}; + +use tw_config::history::Origin; +use tw_types::{Msg, msg}; + +use crate::{Pack, PackError, PackOrigin, Seal, Unseal, disk}; + +/// 本机留多少版 +pub const KEEP: usize = 10; + +/// 备份目录,相对配置文件所在的目录 +pub const DIR: &str = "history"; + +/// 一个备份版本。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Backup { + /// 标识:毫秒时间戳,严格递增 + pub id: String, + pub at_ms: u64, + pub origin: Origin, + pub path: PathBuf, +} + +/// 备份读写失败。**英文只写一遍**:`Display` 就是 [`BackupError::msg`] 的原句。 +#[derive(Debug, thiserror::Error)] +pub enum BackupError { + #[error("{}", self.msg())] + Io { + path: PathBuf, + source: std::io::Error, + }, + #[error("{}", self.msg())] + NotFound { id: String }, + #[error("{}", self.msg())] + Pack(PackError), +} + +impl BackupError { + pub fn msg(&self) -> Msg { + match self { + BackupError::Io { path, source } => msg!( + "backup.io", path = path.display(), detail = source => + "{path} could not be read or written: {detail}" + ), + BackupError::NotFound { id } => msg!( + "backup.not_found", id = id => + "There is no backup version {id}." + ), + BackupError::Pack(e) => e.msg(), + } + } +} + +fn io(path: &Path) -> impl FnOnce(std::io::Error) -> BackupError + '_ { + move |source| BackupError::Io { + path: path.to_path_buf(), + source, + } +} + +pub fn dir(config_path: &Path) -> PathBuf { + disk::dir_of(config_path).join(DIR) +} + +/// 全部版本,**从旧到新**。目录还不在是空的 +pub fn list(config_path: &Path) -> Result, BackupError> { + let d = dir(config_path); + let rd = match std::fs::read_dir(&d) { + Ok(r) => r, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(io(&d)(e)), + }; + let mut out: Vec = rd + .flatten() + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + let stem = name.strip_suffix(&format!(".{}", crate::EXTENSION))?; + let (at, origin) = stem.split_once('-')?; + let at_ms: u64 = at.parse().ok()?; + Some(Backup { + id: at_ms.to_string(), + at_ms, + origin: Origin::parse(origin), + path: e.path(), + }) + }) + .collect(); + // 按时间戳排,不按名字的字典序:位数会变 + out.sort_by_key(|b| b.at_ms); + Ok(out) +} + +/// 按标识找一版 +pub fn find(config_path: &Path, id: &str) -> Result { + list(config_path)? + .into_iter() + .find(|b| b.id == id) + .ok_or_else(|| BackupError::NotFound { id: id.to_string() }) +} + +/// 读出一版 +pub fn open(b: &Backup) -> Result { + let bytes = std::fs::read(&b.path).map_err(io(&b.path))?; + crate::read(&bytes, Unseal::None).map_err(BackupError::Pack) +} + +/// 生效之后:此刻磁盘上的样子(`text` 加上它列着的插件、通用偏好)和最新那一版不一样就 +/// 留一版。返回留下的那一版 +pub fn record( + config_path: &Path, + text: &str, + origin: Origin, +) -> Result, BackupError> { + if origin == Origin::Rotation { + return Ok(None); + } + let pack = disk::current(config_path, text, PackOrigin::from(origin)); + let all = list(config_path)?; + if let Some(last) = all.last() + && let Ok(prev) = open(last) + && fingerprint(&prev) == fingerprint(&masked(&pack)) + { + return Ok(None); + } + save(config_path, &pack, origin, all.last()).map(Some) +} + +/// 写之前:`text`(磁盘上此刻的配置原文)和最新那一版的原文不一样才留一版(外部改动没来得 +/// 及留、第一次运行)。**只比原文**,理由见模块说明。 +/// +/// 留下的这一版**来源记成外部改动**,不是 `origin`:`origin` 说的是接下来这次写,而写之前的 +/// 这个样子不是它造成的 —— 说不清是谁改的,最可能就是有人直接改了文件 +pub fn record_before( + config_path: &Path, + text: &str, + origin: Origin, +) -> Result, BackupError> { + if origin == Origin::Rotation { + return Ok(None); + } + let all = list(config_path)?; + if let Some(last) = all.last() + && let Ok(prev) = open(last) + && text_fingerprint(&prev.config_yaml) + == text_fingerprint(&tw_config::control_key::mask(text)) + { + return Ok(None); + } + let pack = disk::current(config_path, text, PackOrigin::External); + save(config_path, &pack, Origin::External, all.last()).map(Some) +} + +fn masked(pack: &Pack) -> Pack { + Pack { + config_yaml: tw_config::control_key::mask(&pack.config_yaml), + ..pack.clone() + } +} + +fn save( + config_path: &Path, + pack: &Pack, + origin: Origin, + last: Option<&Backup>, +) -> Result { + let d = dir(config_path); + tw_config::private_dir::create(&d).map_err(io(&d))?; + // **时间戳严格递增**:同一毫秒里连着留两版时,「哪一版更新」不能听 read_dir 的 + let now = crate::now_ms(); + let at_ms = match last { + Some(l) if now <= l.at_ms => l.at_ms + 1, + _ => now, + }; + let mut pack = masked(pack); + pack.manifest.created_at_ms = at_ms; + let bytes = crate::write(&pack, Seal::None).map_err(BackupError::Pack)?; + let path = d.join(format!("{at_ms}-{}.{}", origin.slug(), crate::EXTENSION)); + disk::write_private(&path, &bytes).map_err(io(&path))?; + prune(config_path)?; + Ok(Backup { + id: at_ms.to_string(), + at_ms, + origin, + path, + }) +} + +/// 超出 [`KEEP`] 的老版本删掉,以前的修改记录(`*.yaml`)也删掉 +fn prune(config_path: &Path) -> Result<(), BackupError> { + let all = list(config_path)?; + if all.len() > KEEP { + for b in &all[..all.len() - KEEP] { + let _ = std::fs::remove_file(&b.path); + } + } + let d = dir(config_path); + if let Ok(rd) = std::fs::read_dir(&d) { + for e in rd.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + let legacy = name.ends_with(".yaml") + && name + .split('-') + .next() + .is_some_and(|t| t.parse::().is_ok()); + if legacy { + let _ = std::fs::remove_file(e.path()); + } + } + } + Ok(()) +} + +/// 比较两版用的:原文里 OAuth 的 token 和过期时间去掉(轮换不算改动),加上插件、偏好 +fn fingerprint(pack: &Pack) -> String { + let mut h = blake3::Hasher::new(); + h.update(text_fingerprint(&pack.config_yaml).as_bytes()); + for (id, f) in &pack.plugins { + h.update(id.as_bytes()); + h.update(&[0]); + h.update(&f.script); + h.update(&[1]); + if let Some(a) = &f.approved { + h.update(a); + } + h.update(&[2]); + } + if let Some(p) = &pack.client_prefs { + h.update(p.to_string().as_bytes()); + } + h.finalize().to_hex().to_string() +} + +fn text_fingerprint(text: &str) -> String { + let Ok(mut doc) = serde_yaml_ng::from_str::(text) else { + return blake3::hash(text.as_bytes()).to_hex().to_string(); + }; + if let Some(list) = doc + .get_mut("providers") + .and_then(serde_yaml_ng::Value::as_sequence_mut) + { + for p in list { + if let Some(serde_yaml_ng::Value::Mapping(o)) = p.get_mut("oauth") { + for k in ["access", "refresh", "expires_at"] { + o.remove(k); + } + } + } + } + // 只比内容:注释和排版变了也算变了(那是用户改的),所以原文也要算进去 —— 去掉 token + // 那几行之外的原文 + let mut lines = String::new(); + for l in text.lines() { + let t = l.trim_start(); + if !(t.starts_with("access:") || t.starts_with("refresh:") || t.starts_with("expires_at:")) + { + lines.push_str(l); + lines.push('\n'); + } + } + let canon = serde_json::to_string(&doc).unwrap_or_default(); + blake3::hash(format!("{canon}\n{lines}").as_bytes()) + .to_hex() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &str = "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00"; + + fn cfg(port: u16) -> String { + format!( + "version: 1\nlisten:\n control:\n key: {KEY}\n gateway:\n port: {port}\nclients:\n - name: default\n key: tw-a\n" + ) + } + + fn setup() -> (tempfile::TempDir, PathBuf) { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("config.yaml"); + (d, p) + } + + #[test] + fn each_distinct_version_is_kept_once_and_reads_back_whole() { + let (_d, p) = setup(); + let a = record(&p, &cfg(1), Origin::Ui).unwrap().unwrap(); + assert!( + record(&p, &cfg(1), Origin::Ui).unwrap().is_none(), + "same content twice" + ); + assert!(record_before(&p, &cfg(1), Origin::Ui).unwrap().is_none()); + let b = record(&p, &cfg(2), Origin::Cli).unwrap().unwrap(); + assert!(b.at_ms > a.at_ms); + let all = list(&p).unwrap(); + assert_eq!(all, vec![a.clone(), b.clone()]); + let back = open(&a).unwrap(); + assert!(back.config_yaml.contains("port: 1")); + // 钥匙不进备份 + assert!(!back.config_yaml.contains(KEY), "{}", back.config_yaml); + assert_eq!(back.manifest.origin, PackOrigin::Ui); + assert_eq!(find(&p, &b.id).unwrap(), b); + assert!(matches!( + find(&p, "nope"), + Err(BackupError::NotFound { .. }) + )); + } + + #[test] + fn plugins_and_preferences_are_part_of_a_version() { + let (_d, p) = setup(); + let text = format!( + "{}plugins:\n - id: a\n file: plugins/a.js\n sha256: {}\n", + cfg(1), + "0".repeat(64) + ); + let files = crate::PluginFiles { + script: b"v1".to_vec(), + approved: None, + }; + disk::write_plugins( + &p, + &Default::default(), + &[("a".to_string(), files)].into_iter().collect(), + ) + .unwrap(); + record(&p, &text, Origin::Ui).unwrap().unwrap(); + // 只改了插件文件:又一版 + disk::write_plugins( + &p, + &Default::default(), + &[( + "a".to_string(), + crate::PluginFiles { + script: b"v2".to_vec(), + approved: None, + }, + )] + .into_iter() + .collect(), + ) + .unwrap(); + // 写之前那一次只看原文:原文没变,不留 + assert!(record_before(&p, &text, Origin::Ui).unwrap().is_none()); + let v2 = record(&p, &text, Origin::Ui).unwrap().unwrap(); + assert_eq!(open(&v2).unwrap().plugins["a"].script, b"v2"); + // 偏好变了:又一版 + disk::write_prefs(&p, Some(&serde_json::json!({"language": "en"}))).unwrap(); + let v3 = record(&p, &text, Origin::Ui).unwrap().unwrap(); + assert_eq!( + open(&v3).unwrap().client_prefs, + Some(serde_json::json!({"language": "en"})) + ); + } + + #[test] + fn a_rotation_leaves_no_version_and_does_not_count_as_a_change() { + let (_d, p) = setup(); + let with = |rt: &str| { + format!( + "{}providers:\n - name: c\n base_url: https://x\n oauth:\n refresh: {rt}\n endpoint: https://t\n", + cfg(1) + ) + }; + record(&p, &with("rt-1"), Origin::Ui).unwrap().unwrap(); + assert!( + record(&p, &with("rt-2"), Origin::Rotation) + .unwrap() + .is_none() + ); + // 轮换之后的下一次写:写之前那一次不因为 token 变了多留一版 + assert!( + record_before(&p, &with("rt-2"), Origin::Ui) + .unwrap() + .is_none() + ); + assert_eq!(list(&p).unwrap().len(), 1); + } + + #[test] + fn only_the_latest_versions_stay_and_the_old_history_goes() { + let (_d, p) = setup(); + let d = dir(&p); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("1700000000000-ui-abcdef012345.yaml"), "a: 1\n").unwrap(); + std::fs::write(d.join("README.txt"), "hi").unwrap(); + for i in 0..KEEP + 5 { + record(&p, &cfg(i as u16 + 1), Origin::Ui).unwrap().unwrap(); + } + let all = list(&p).unwrap(); + assert_eq!(all.len(), KEEP); + assert!( + open(all.last().unwrap()) + .unwrap() + .config_yaml + .contains(&format!("port: {}", KEEP + 5)) + ); + assert!(!d.join("1700000000000-ui-abcdef012345.yaml").exists()); + assert!( + d.join("README.txt").exists(), + "files that are not ours stay" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&all[0].path) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + } + + #[test] + fn versions_made_in_the_same_millisecond_keep_their_order() { + let (_d, p) = setup(); + let stamps: Vec = (0..30) + .map(|i| record(&p, &cfg(i + 1), Origin::Ui).unwrap().unwrap().at_ms) + .collect(); + assert!(stamps.windows(2).all(|w| w[0] < w[1]), "{stamps:?}"); + } +} diff --git a/crates/tw-pack/src/disk.rs b/crates/tw-pack/src/disk.rs new file mode 100644 index 00000000..6dbc74a9 --- /dev/null +++ b/crates/tw-pack/src/disk.rs @@ -0,0 +1,303 @@ +//! 数据目录里和配置包有关的几样文件:插件、Lite 的通用偏好、固定密钥。 +//! +//! 路径都相对配置文件所在的目录,和插件文件一样(`tw_config::plugins`):远程 core 也是, +//! 文件在 core 那台机器上。**只给自己看**:目录 0700、文件 0600(unix;Windows 上数据目录 +//! 的 ACL 由 `tw_config::private_dir` 管)。 + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use crate::{Manifest, Pack, PackKind, PackOrigin, PluginFiles}; + +/// Lite 的通用偏好存在哪个文件里 +pub const PREFS_FILE: &str = "client-prefs.json"; + +/// 固定密钥(本机接管的客户端在用的网关密钥)存在哪个文件里 +pub const PINNED_FILE: &str = "pinned-keys.json"; + +/// 配置文件所在的目录 +pub fn dir_of(config_path: &Path) -> PathBuf { + match config_path.parent() { + Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), + _ => PathBuf::from("."), + } +} + +pub fn prefs_path(config_path: &Path) -> PathBuf { + dir_of(config_path).join(PREFS_FILE) +} + +pub fn pinned_path(config_path: &Path) -> PathBuf { + dir_of(config_path).join(PINNED_FILE) +} + +/// 存着的通用偏好。没有、读不成 JSON 都是 None:偏好丢了不该挡住别的 +pub fn read_prefs(config_path: &Path) -> Option { + let bytes = std::fs::read(prefs_path(config_path)).ok()?; + serde_json::from_slice(&bytes).ok() +} + +/// 存下通用偏好。None 是删掉 +pub fn write_prefs(config_path: &Path, prefs: Option<&serde_json::Value>) -> std::io::Result<()> { + let path = prefs_path(config_path); + match prefs { + Some(p) => { + let bytes = serde_json::to_vec_pretty(p).map_err(std::io::Error::other)?; + write_private(&path, &bytes) + } + None => match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + }, + } +} + +/// 存着的固定密钥。没有、读不懂都是空的 +pub fn read_pinned(config_path: &Path) -> BTreeSet { + #[derive(serde::Deserialize)] + struct File { + names: BTreeSet, + } + std::fs::read(pinned_path(config_path)) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .map(|f| f.names) + .unwrap_or_default() +} + +pub fn write_pinned(config_path: &Path, names: &BTreeSet) -> std::io::Result<()> { + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ "names": names })) + .map_err(std::io::Error::other)?; + write_private(&pinned_path(config_path), &bytes) +} + +/// 配置原文里列着的插件 id,按顺序。读不成 YAML 是空的 +pub fn plugin_ids(text: &str) -> Vec { + let Ok(doc) = serde_yaml_ng::from_str::(text) else { + return Vec::new(); + }; + doc.get(tw_config::plugins::DIR) + .and_then(serde_yaml_ng::Value::as_sequence) + .into_iter() + .flatten() + .filter_map(|p| p.get("id").and_then(serde_yaml_ng::Value::as_str)) + .filter(|id| tw_config::plugins::valid_id(id)) + .map(str::to_string) + .collect() +} + +/// 一个插件在磁盘上的两份文件。脚本不在就是 None +pub fn read_plugin(config_path: &Path, id: &str) -> Option { + let dir = dir_of(config_path); + let script = std::fs::read(tw_config::plugins::file_path(&dir, id)).ok()?; + let approved = std::fs::read(tw_config::plugins::approved_path(&dir, id)).ok(); + Some(PluginFiles { script, approved }) +} + +/// 此刻的样子配成一个包:`text` 是配置原文,插件是它列着的那几个在磁盘上的文件,加上 +/// 存着的通用偏好。**原文原样放进去**:要拿去哪儿(备份、导出)由调用方决定剔掉什么 +pub fn current(config_path: &Path, text: &str, origin: PackOrigin) -> Pack { + let plugins: BTreeMap = plugin_ids(text) + .into_iter() + .filter_map(|id| read_plugin(config_path, &id).map(|f| (id, f))) + .collect(); + Pack { + manifest: Manifest::new(PackKind::Full, origin), + config_yaml: text.to_string(), + plugins, + client_prefs: read_prefs(config_path), + } +} + +/// 写之前的样子。配置没写成时照它还原 +#[derive(Debug, Default)] +pub struct Undo(Vec<(PathBuf, Option>)>); + +impl Undo { + /// 还原。哪个文件还原不了只记一行:手里已经有一个更要紧的错误 + pub fn restore(self) { + for (path, before) in self.0.into_iter().rev() { + let r = match before { + Some(b) => write_private(&path, &b), + None => match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + }, + }; + if let Err(e) = r { + tracing::warn!(path = %path.display(), "a file could not be put back: {e}"); + } + } + } +} + +/// 插件文件换成 `after` 的样子:有变化的写下去,`before` 里有、`after` 里没有的删掉 +/// (连同底稿)。`after` 里没有底稿的,原来的底稿删掉。返回还原用的 +pub fn write_plugins( + config_path: &Path, + before: &BTreeMap, + after: &BTreeMap, +) -> std::io::Result { + let dir = dir_of(config_path); + let plugins = tw_config::plugins::dir_in(&dir); + let approved = plugins.join(tw_config::plugins::APPROVED_DIR); + let mut undo = Undo::default(); + let put = |path: PathBuf, bytes: Option<&[u8]>, undo: &mut Undo| -> std::io::Result<()> { + let old = match std::fs::read(&path) { + Ok(b) => Some(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; + if old.as_deref() == bytes { + return Ok(()); + } + match bytes { + Some(b) => { + for d in [&plugins, &approved] { + tw_config::private_dir::create(d)?; + } + write_private(&path, b)?; + } + None => std::fs::remove_file(&path)?, + } + undo.0.push((path, old)); + Ok(()) + }; + let result = (|| { + for (id, files) in after { + put( + tw_config::plugins::file_path(&dir, id), + Some(&files.script), + &mut undo, + )?; + put( + tw_config::plugins::approved_path(&dir, id), + files.approved.as_deref(), + &mut undo, + )?; + } + for id in before.keys().filter(|id| !after.contains_key(*id)) { + put(tw_config::plugins::file_path(&dir, id), None, &mut undo)?; + put(tw_config::plugins::approved_path(&dir, id), None, &mut undo)?; + } + Ok(()) + })(); + match result { + Ok(()) => Ok(undo), + Err(e) => { + undo.restore(); + Err(e) + } + } +} + +/// 原子地写一个只给自己看的文件:**建的那一刻就是 0600**,写完再改名过去 +pub fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + if let Some(dir) = path.parent() + && !dir.as_os_str().is_empty() + { + tw_config::private_dir::create(dir)?; + } + let tmp = path.with_extension(format!("tmp{}", std::process::id())); + let _ = std::fs::remove_file(&tmp); + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let written = opts.open(&tmp).and_then(|mut f| { + f.write_all(bytes)?; + f.sync_all() + }); + if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, path)) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn files(s: &str) -> PluginFiles { + PluginFiles { + script: s.as_bytes().to_vec(), + approved: Some(s.as_bytes().to_vec()), + } + } + + #[test] + fn plugin_files_are_swapped_in_and_put_back_on_undo() { + let d = tempfile::tempdir().unwrap(); + let cfg = d.path().join("config.yaml"); + let before = BTreeMap::from([ + ("a".to_string(), files("a1")), + ("b".to_string(), files("b1")), + ]); + write_plugins(&cfg, &BTreeMap::new(), &before).unwrap(); + assert_eq!(read_plugin(&cfg, "a").unwrap(), files("a1")); + let after = BTreeMap::from([ + ("a".to_string(), files("a2")), + ("c".to_string(), files("c1")), + ]); + let undo = write_plugins(&cfg, &before, &after).unwrap(); + assert_eq!(read_plugin(&cfg, "a").unwrap(), files("a2")); + assert!(read_plugin(&cfg, "b").is_none(), "removed plugins go"); + assert_eq!(read_plugin(&cfg, "c").unwrap(), files("c1")); + undo.restore(); + assert_eq!(read_plugin(&cfg, "a").unwrap(), files("a1")); + assert_eq!(read_plugin(&cfg, "b").unwrap(), files("b1")); + assert!(read_plugin(&cfg, "c").is_none()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let p = tw_config::plugins::file_path(d.path(), "a"); + assert_eq!( + std::fs::metadata(p).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn prefs_and_pinned_keys_round_trip_and_a_bad_file_reads_as_none() { + let d = tempfile::tempdir().unwrap(); + let cfg = d.path().join("config.yaml"); + assert_eq!(read_prefs(&cfg), None); + let p = serde_json::json!({"language": "zh"}); + write_prefs(&cfg, Some(&p)).unwrap(); + assert_eq!(read_prefs(&cfg), Some(p)); + write_prefs(&cfg, None).unwrap(); + assert_eq!(read_prefs(&cfg), None); + write_prefs(&cfg, None).unwrap(); + std::fs::write(prefs_path(&cfg), b"{not json").unwrap(); + assert_eq!(read_prefs(&cfg), None); + + let names: BTreeSet = ["codex".to_string()].into_iter().collect(); + write_pinned(&cfg, &names).unwrap(); + assert_eq!(read_pinned(&cfg), names); + } + + #[test] + fn the_current_state_is_the_config_and_the_plugins_it_lists() { + let d = tempfile::tempdir().unwrap(); + let cfg = d.path().join("config.yaml"); + write_plugins( + &cfg, + &BTreeMap::new(), + &BTreeMap::from([ + ("a".to_string(), files("a")), + ("stray".to_string(), files("s")), + ]), + ) + .unwrap(); + let text = "version: 1\nplugins:\n - { id: a, file: plugins/a.js, sha256: x }\n - { id: gone, file: plugins/gone.js, sha256: x }\n"; + let p = current(&cfg, text, PackOrigin::Ui); + assert_eq!(p.plugins.keys().collect::>(), ["a"]); + assert_eq!(p.config_yaml, text); + } +} diff --git a/crates/tw-pack/src/export.rs b/crates/tw-pack/src/export.rs new file mode 100644 index 00000000..17d50c47 --- /dev/null +++ b/crates/tw-pack/src/export.rs @@ -0,0 +1,227 @@ +//! 导出:把本机的配置(全部,或者选的几项连同它们引用的)做成一个配置包。 +//! +//! 包里**没有本机设置**([`crate::model::LOCAL_SECTIONS`]、ChatGPT 的登录),配置按项目 +//! 重新生成([`crate::model::materialize`] 落在一份空配置上)。不带密钥时,密钥类的值换成 +//! 空字符串:写成 `${变量名}` 的不是密钥,照样带着。 + +use std::collections::BTreeSet; + +use serde_json::Value as Json; + +use crate::model::{self, ItemId, ItemKind, ModelError, PluginCodec, Snapshot, unit}; +use crate::{Manifest, Pack, PackKind, PackOrigin}; + +/// 导出什么、怎么导出。 +#[derive(Debug, Clone)] +pub struct Options { + /// None 是全部配置;否则是这几项,连同它们引用的 + pub scope: Option>, + /// 带上密钥 + pub secrets: bool, + /// 换掉通用偏好(经远程连接导出时 Lite 给的那一份)。None 是用包里本来的 + pub client_prefs: Option, + pub origin: PackOrigin, +} + +/// 导出的结果。 +#[derive(Debug, Clone)] +pub struct Exported { + pub pack: Pack, + /// 包里有哪些项目(随附的也在),顺序项目不算 + pub included: Vec, + /// 登录没有随包带走的上游(ChatGPT 账号) + pub omitted_logins: Vec, +} + +/// 导出。`local` 是本机此刻的样子([`crate::disk::current`]) +pub fn export(local: &Pack, opts: Options) -> Result { + export_with(local, opts, model::codec()) +} + +pub fn export_with( + local: &Pack, + opts: Options, + codec: &dyn PluginCodec, +) -> Result { + let mut snap = model::snapshot_with(local, codec)?; + let prefs_id = ItemId::new(ItemKind::ClientPrefs, ""); + if let Some(p) = &opts.client_prefs { + let mut units = Default::default(); + model::json_flatten(p, &mut units); + snap.items.remove(&prefs_id); + if !units.is_empty() { + snap.items.insert( + prefs_id, + model::Item { + units, + ..Default::default() + }, + ); + } + } + if let Some(roots) = &opts.scope { + let keep = snap.closure(roots); + snap.retain(&keep); + } + if !opts.secrets { + strip_secrets(&mut snap); + } + let cfg = model::parse(&local.config_yaml)?; + let included: Vec = snap + .items + .keys() + .filter(|id| !id.kind.is_order()) + .cloned() + .collect(); + let picked: BTreeSet<&str> = included + .iter() + .filter(|id| id.kind == ItemKind::Provider) + .map(|id| id.name.as_str()) + .collect(); + let omitted_logins = cfg + .providers + .iter() + .filter(|p| picked.contains(p.name.as_str()) && model::is_chatgpt(p)) + .map(|p| p.name.clone()) + .collect(); + let kind = if opts.scope.is_some() { + PackKind::Partial + } else { + PackKind::Full + }; + let mut manifest = Manifest::new(kind, opts.origin); + manifest.secrets = opts.secrets; + let base = Pack { + manifest, + config_yaml: format!("version: {}\n", tw_config::SCHEMA_VERSION), + plugins: Default::default(), + client_prefs: None, + }; + let pack = model::materialize_for_export(&base, &snap, codec)?; + Ok(Exported { + pack, + included, + omitted_logins, + }) +} + +/// 不是 `${变量名}` 的就是密钥 +fn is_literal(v: &Json) -> bool { + v.as_str() + .is_some_and(|s| !s.is_empty() && !s.contains("${")) +} + +fn blank(at: Option<&mut Json>) { + if let Some(v) = at + && is_literal(v) + { + *v = Json::String(String::new()); + } +} + +/// 去掉密钥:上游的密钥、请求头的值、AWS 访问密钥、OAuth 的 refresh token 和客户端密钥, +/// 代理的密码,网关密钥的值。换成空字符串,导入的一方据此知道这里本来有一个 +pub fn strip_secrets(snap: &mut Snapshot) { + for (id, item) in snap.items.iter_mut() { + match id.kind { + ItemKind::Provider => { + let Some(conn) = item.units.get_mut(unit::CONN) else { + continue; + }; + blank(conn.get_mut("key")); + if let Some(Json::Array(headers)) = conn.get_mut("headers") { + for h in headers { + blank(h.get_mut(1)); + } + } + if let Some(aws) = conn.get_mut("aws") { + for k in ["access_key_id", "secret_access_key", "session_token"] { + blank(aws.get_mut(k)); + } + } + if let Some(Json::Object(o)) = conn.get_mut("oauth") { + o.remove("access"); + o.remove("expires_at"); + blank(o.get_mut("refresh")); + blank(o.get_mut("client_secret")); + } + } + ItemKind::Proxy => { + if let Some(auth) = item + .units + .get_mut(unit::ALL) + .and_then(|a| a.get_mut("auth")) + { + blank(auth.get_mut("pass")); + } + } + ItemKind::Key => { + blank(item.units.get_mut(unit::ALL).and_then(|a| a.get_mut("key"))); + } + _ => {} + } + } +} + +/// 一项里有没有被去掉的密钥(空字符串) +pub fn missing_secret(id: &ItemId, item: &model::Item) -> bool { + let empty = |v: Option<&Json>| v.and_then(Json::as_str) == Some(""); + match id.kind { + ItemKind::Provider => { + let Some(conn) = item.units.get(unit::CONN) else { + return false; + }; + empty(conn.get("key")) + || conn + .get("headers") + .and_then(Json::as_array) + .is_some_and(|hs| hs.iter().any(|h| empty(h.get(1)))) + || conn.get("aws").is_some_and(|aws| { + ["access_key_id", "secret_access_key", "session_token"] + .iter() + .any(|k| empty(aws.get(*k))) + }) + || conn + .get("oauth") + .is_some_and(|o| empty(o.get("refresh")) || empty(o.get("client_secret"))) + } + ItemKind::Key => empty(item.units.get(unit::ALL).and_then(|a| a.get("key"))), + _ => false, + } +} + +/// 导出文件的建议名字:`ThinkWatch-2026-10-18.twconfig`(UTC 的日期) +pub fn file_name(created_at_ms: u64) -> String { + let (y, m, d) = civil(created_at_ms / 86_400_000); + format!("ThinkWatch-{y:04}-{m:02}-{d:02}.{}", crate::EXTENSION) +} + +/// 1970-01-01 起的第几天 → 年月日(公历) +fn civil(days: u64) -> (i64, u32, u32) { + let z = days as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = yoe + era * 400 + i64::from(m <= 2); + (y, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_file_name_carries_the_date() { + assert_eq!(file_name(0), "ThinkWatch-1970-01-01.twconfig"); + // 2026-10-18 09:30 UTC + assert_eq!( + file_name(1_792_315_800_000), + "ThinkWatch-2026-10-18.twconfig" + ); + assert_eq!(file_name(951_782_400_000), "ThinkWatch-2000-02-29.twconfig"); + } +} diff --git a/crates/tw-pack/src/import.rs b/crates/tw-pack/src/import.rs new file mode 100644 index 00000000..646f8280 --- /dev/null +++ b/crates/tw-pack/src/import.rs @@ -0,0 +1,589 @@ +//! 导入:把配置包里的项目逐项加进本机的配置。 +//! +//! # 规则 +//! +//! - 本机没有的直接导入;同名且内容不同的是冲突,由人选覆盖、改名导入或跳过。全局设置 +//! 的每一节、通用偏好是一项:本机还是默认值(没有这一项)时直接采用,否则不同就是冲突, +//! 只能覆盖或跳过 +//! - 本机有、包里没有的不动。整份替换不是导入,是恢复 +//! - 导入进来的插件停用着;包里不带密钥的上游停用着(等填上密钥),网关密钥重新生成; +//! ChatGPT 账号上游停用着(等登录)—— 本机同名的那一家已经登录着、选了覆盖的除外,登录 +//! 留着本机的 +//! - 改名导入:包里引用它的路由、别名、分组跟着改用新名字([`crate::model::rename`]) +//! - 不能导入(错误):引用了本机和包里都没有的东西、本机没有它要的 AWS profile、插件文件 +//! 和配置里批准的哈希对不上、网关密钥的值和本机另一把相同、或者导进来配置就过不了校验 + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::Value as Json; +use tw_types::{Msg, msg}; + +use crate::model::{ + self, Item, ItemId, ItemKind, ModelError, PluginCodec, Snapshot, free_name, global, rename, + unit, +}; +use crate::{Pack, export}; + +/// 包里的一项和本机比。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + New, + Same, + Conflict, + Error, +} + +/// 导入时要知道的一件事。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Flag { + /// 包里不带密钥 + MissingKey, + /// ChatGPT 账号上游,登录不随包带 + NeedsLogin, + /// 插件导入为停用 + PluginDisabled, +} + +/// 预览里的一行。 +#[derive(Debug, Clone, PartialEq)] +pub struct Row { + pub item: ItemId, + pub status: Status, + /// 全局设置、通用偏好:冲突只能覆盖或跳过 + pub global: bool, + pub flags: Vec, + /// 不同的合并单位 + pub differs: Vec, + pub suggested_name: Option, + pub error: Option, + /// 它引用的、包里也有的项目 + pub refs: Vec, +} + +/// 对一项怎么处理。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Import, + Overwrite, + Rename(String), + Skip, +} + +#[derive(Debug, Clone)] +pub struct Decision { + pub item: ItemId, + pub action: Action, +} + +/// 本机的情况:外面才知道的几件事。 +pub struct Context<'a> { + /// 这台机器的 AWS 凭证文件里有没有这个 profile + pub has_aws_profile: &'a dyn Fn(&str) -> bool, + /// 新的网关密钥(包里不带密钥时重新生成) + pub new_key: &'a dyn Fn() -> String, + /// 通用偏好要不要导入(经远程连接导入时偏好由 Lite 自己应用,core 不写) + pub with_prefs: bool, +} + +/// 导入没成。**英文只写一遍**:`Display` 就是 [`ImportError::msg`] 的原句。 +#[derive(Debug, Clone, thiserror::Error)] +pub enum ImportError { + #[error("{}", self.msg())] + Model(ModelError), + /// 这一项导入不了(预览里的错误那一行) + #[error("{}", self.msg())] + Item(ItemId, Box), + /// 本机已经有同名的:导入要选覆盖或改名 + #[error("{}", self.msg())] + Exists(ItemId), + #[error("{}", self.msg())] + NameTaken(ItemId, String), + #[error("{}", self.msg())] + NotInPack(ItemId), + /// 导进来之后配置过不了校验 + #[error("{}", self.msg())] + Rejected(Box), +} + +impl From for ImportError { + fn from(e: ModelError) -> Self { + ImportError::Model(e) + } +} + +impl ImportError { + pub fn msg(&self) -> Msg { + match self { + ImportError::Model(e) => e.msg(), + ImportError::Item(_, m) => (**m).clone(), + ImportError::Exists(id) => msg!( + "import.exists", kind = id.kind.slug(), name = &id.name => + "There is already a {kind} named `{name}` here; choose to overwrite it, import it \ + under another name, or skip it." + ), + ImportError::NameTaken(id, name) => msg!( + "import.name_taken", kind = id.kind.slug(), name = name => + "There is already a {kind} named `{name}`." + ), + ImportError::NotInPack(id) => msg!( + "import.not_in_pack", kind = id.kind.slug(), name = &id.name => + "The configuration pack has no {kind} named `{name}`." + ), + ImportError::Rejected(r) => r.msg(), + } + } +} + +fn missing_ref(id: &ItemId, target: &ItemId) -> Msg { + msg!( + "import.missing_reference", kind = id.kind.slug(), name = &id.name, + target_kind = target.kind.slug(), target = &target.name => + "{kind} `{name}` refers to {target_kind} `{target}`, which is neither here nor in the \ + configuration pack." + ) +} + +fn skipped_ref(id: &ItemId, target: &ItemId) -> Msg { + msg!( + "import.skipped_reference", kind = id.kind.slug(), name = &id.name, + target_kind = target.kind.slug(), target = &target.name => + "{kind} `{name}` refers to {target_kind} `{target}`, which is skipped." + ) +} + +/// 全局设置和通用偏好:冲突只能覆盖或跳过 +fn is_global(kind: ItemKind) -> bool { + matches!(kind, ItemKind::Global | ItemKind::ClientPrefs) +} + +/// 预览。**什么都不写** +pub fn preview(local: &Pack, incoming: &Pack, ctx: &Context) -> Result, ImportError> { + preview_with(local, incoming, ctx, model::codec()) +} + +pub fn preview_with( + local: &Pack, + incoming: &Pack, + ctx: &Context, + codec: &dyn PluginCodec, +) -> Result, ImportError> { + let l = model::snapshot_with(local, codec)?; + let i = model::snapshot_with(incoming, codec)?; + let both = union(&l, &i); + let mut rows = Vec::new(); + for (id, item) in &i.items { + if id.kind.is_order() || (id.kind == ItemKind::ClientPrefs && !ctx.with_prefs) { + continue; + } + let global = is_global(id.kind); + let mut row = Row { + item: id.clone(), + status: Status::New, + global, + flags: flags(id, item, incoming, &l), + differs: Vec::new(), + suggested_name: None, + error: None, + refs: both.items[id] + .refs + .iter() + .filter(|r| i.items.contains_key(*r)) + .cloned() + .collect(), + }; + if let Some(mine) = l.items.get(id) { + if mine.units == item.units { + row.status = Status::Same; + } else { + row.status = Status::Conflict; + row.differs = differing(&mine.units, &item.units); + if !global { + row.suggested_name = Some(free_name(&both, id.kind, &suggest_base(id))); + } + } + } + if row.status != Status::Same { + row.error = problem(id, item, local, incoming, &l, &i, &both, ctx, codec); + if row.error.is_some() { + row.status = Status::Error; + } + } + rows.push(row); + } + Ok(rows) +} + +/// 改名导入时缺省名字的底:自定义安全规则是完整的项目名(`content/规则`) +fn suggest_base(id: &ItemId) -> String { + id.name.clone() +} + +fn differing(a: &BTreeMap, b: &BTreeMap) -> Vec { + let keys: BTreeSet<&String> = a.keys().chain(b.keys()).collect(); + keys.into_iter() + .filter(|k| a.get(*k) != b.get(*k)) + .cloned() + .collect() +} + +fn union(l: &Snapshot, i: &Snapshot) -> Snapshot { + let mut both = l.clone(); + for (id, item) in &i.items { + both.items.insert(id.clone(), item.clone()); + } + both.relink(); + both +} + +fn flags(id: &ItemId, item: &Item, incoming: &Pack, l: &Snapshot) -> Vec { + let mut out = Vec::new(); + if !incoming.manifest.secrets && export::missing_secret(id, item) { + out.push(Flag::MissingKey); + } + if id.kind == ItemKind::Provider && provider_is_chatgpt(id, item) { + // 本机同名的那一家也是 ChatGPT 账号:覆盖时登录留着本机的 + let here = l + .items + .get(id) + .is_some_and(|mine| provider_is_chatgpt(id, mine)); + if !here { + out.push(Flag::NeedsLogin); + } + } + if id.kind == ItemKind::Plugin { + out.push(Flag::PluginDisabled); + } + out +} + +fn provider_is_chatgpt(id: &ItemId, item: &Item) -> bool { + model::provider_of(id, item).is_ok_and(|p| model::is_chatgpt(&p)) +} + +/// 这一项导入不了的原因。没有是 None +#[allow(clippy::too_many_arguments)] +fn problem( + id: &ItemId, + item: &Item, + local: &Pack, + incoming: &Pack, + l: &Snapshot, + i: &Snapshot, + both: &Snapshot, + ctx: &Context, + codec: &dyn PluginCodec, +) -> Option { + // 引用了本机和包里都没有的 + for r in &both.items[id].refs { + if !both.items.contains_key(r) && !builtin(r) { + return Some(missing_ref(id, r)); + } + } + match id.kind { + ItemKind::Provider => { + if let Some(profile) = item + .units + .get(unit::CONN) + .and_then(|c| c.get("aws")) + .and_then(|a| a.get("profile")) + .and_then(Json::as_str) + && !(ctx.has_aws_profile)(profile) + { + return Some(msg!( + "import.no_aws_profile", name = &id.name, profile = profile => + "Upstream `{name}` uses the AWS profile `{profile}`, which this machine does \ + not have." + )); + } + } + ItemKind::Plugin => { + let entry = model::parse(&incoming.config_yaml) + .ok() + .and_then(|c| c.plugins.into_iter().find(|p| p.id == id.name)); + match (entry, incoming.plugins.get(&id.name)) { + (_, None) => { + return Some(msg!( + "import.plugin_missing", plugin = &id.name => + "The configuration pack has no script for plugin `{plugin}`." + )); + } + (Some(e), Some(f)) => { + let ok = model::sha256_hex(&f.script) == e.sha256 + || f.approved + .as_ref() + .is_some_and(|a| model::sha256_hex(a) == e.sha256); + if !ok { + return Some(msg!( + "import.plugin_mismatch", plugin = &id.name => + "The script of plugin `{plugin}` in the configuration pack is not \ + the one that was approved." + )); + } + } + (None, Some(_)) => {} + } + } + ItemKind::Key => { + if let Some(v) = key_value(item) + && !v.is_empty() + && let Some((other, _)) = l + .of_kind(ItemKind::Key) + .find(|(o, it)| o.name != id.name && key_value(it).as_deref() == Some(&v)) + { + return Some(msg!( + "import.key_value_taken", key = &id.name, other = &other.name => + "Gateway key `{key}` has the same value as gateway key `{other}` here." + )); + } + } + _ => {} + } + // 连同它引用的、本机没有的那些一起导进来,配置过得了校验吗 + let mut target = l.clone(); + let mut todo = vec![id.clone()]; + let mut seen = BTreeSet::new(); + while let Some(next) = todo.pop() { + if !seen.insert(next.clone()) { + continue; + } + if next != *id && l.items.contains_key(&next) { + continue; + } + if let Some(it) = i.items.get(&next) { + target + .items + .insert(next.clone(), prepared(&next, it, incoming, l, ctx)); + todo.extend(both.items[&next].refs.iter().cloned()); + } + } + target.relink(); + match land(local, &target, codec) { + Ok(_) => None, + Err(e) => Some(e.msg()), + } +} + +/// 引擎自己有的东西:叫「默认」的那条路由(没写出来时由引擎合成) +fn builtin(r: &ItemId) -> bool { + r.kind == ItemKind::Route && r.name == tw_engine::DEFAULT_ROUTE +} + +fn key_value(item: &Item) -> Option { + item.units + .get(unit::ALL) + .and_then(|a| a.get("key")) + .and_then(Json::as_str) + .map(str::to_string) +} + +/// 包里的一项按导入的规矩调整:插件停用,缺密钥、没登录的上游停用,空的网关密钥重新生成 +fn prepared(id: &ItemId, item: &Item, incoming: &Pack, l: &Snapshot, ctx: &Context) -> Item { + let mut item = item.clone(); + match id.kind { + ItemKind::Plugin => { + item.units + .insert(unit::ENABLED.to_string(), Json::Bool(false)); + } + ItemKind::Provider => { + let f = flags(id, &item, incoming, l); + if (f.contains(&Flag::MissingKey) || f.contains(&Flag::NeedsLogin)) + && let Some(Json::Object(other)) = item.units.get_mut(unit::OTHER) + { + other.insert("disabled".into(), Json::Bool(true)); + } + } + ItemKind::Key => { + if key_value(&item).as_deref() == Some("") + && let Some(Json::Object(all)) = item.units.get_mut(unit::ALL) + { + all.insert("key".into(), Json::String((ctx.new_key)())); + } + } + _ => {} + } + item +} + +/// 落下去并校验。校验和换入配置时是同一套(`tw_config::try_parse`) +fn land(local: &Pack, target: &Snapshot, codec: &dyn PluginCodec) -> Result { + let pack = model::materialize_with(local, target, codec)?; + tw_config::try_parse(&pack.config_yaml).map_err(|r| ImportError::Rejected(Box::new(r)))?; + Ok(pack) +} + +/// 导入的结果。 +#[derive(Debug, Clone)] +pub struct Applied { + pub pack: Pack, + /// 值变了的网关密钥(覆盖进来的),名字是本机原来的 + pub changed_keys: Vec, +} + +/// 照逐项的处理导入。没有写进 `decisions` 的项目不导入 +pub fn apply( + local: &Pack, + incoming: &Pack, + decisions: &[Decision], + ctx: &Context, +) -> Result { + apply_with(local, incoming, decisions, ctx, model::codec()) +} + +pub fn apply_with( + local: &Pack, + incoming: &Pack, + decisions: &[Decision], + ctx: &Context, + codec: &dyn PluginCodec, +) -> Result { + let l = model::snapshot_with(local, codec)?; + let mut i = model::snapshot_with(incoming, codec)?; + let rows = preview_with(local, incoming, ctx, codec)?; + let errors: BTreeMap<&ItemId, &Msg> = rows + .iter() + .filter_map(|r| r.error.as_ref().map(|e| (&r.item, e))) + .collect(); + + // 先改名(包里引用它的跟着改),再逐项落 + let mut names: BTreeMap = BTreeMap::new(); + for d in decisions { + if let Action::Rename(new) = &d.action { + if !d.item.kind.is_named() { + return Err(ImportError::NotInPack(d.item.clone())); + } + let new_id = match d.item.kind { + ItemKind::SecurityRule if !new.contains('/') => { + let guard = d.item.name.split('/').next().unwrap_or_default(); + ItemId::new(ItemKind::SecurityRule, format!("{guard}/{new}")) + } + k => ItemId::new(k, new.clone()), + }; + if new.trim().is_empty() || new.trim() != new { + return Err(ImportError::NameTaken(d.item.clone(), new.clone())); + } + if l.items.contains_key(&new_id) || i.items.contains_key(&new_id) { + return Err(ImportError::NameTaken(d.item.clone(), new.clone())); + } + rename(&mut i, &d.item, &new_id.name); + names.insert(d.item.clone(), new_id); + } + } + + let mut target = l.clone(); + let mut taken: Vec = Vec::new(); + for d in decisions { + if d.item.kind.is_order() || (d.item.kind == ItemKind::ClientPrefs && !ctx.with_prefs) { + continue; + } + let id = names + .get(&d.item) + .cloned() + .unwrap_or_else(|| d.item.clone()); + if d.action != Action::Skip + && let Some(e) = errors.get(&d.item) + { + return Err(ImportError::Item(d.item.clone(), Box::new((*e).clone()))); + } + let Some(item) = i.items.get(&id) else { + if d.action == Action::Skip { + continue; + } + return Err(ImportError::NotInPack(d.item.clone())); + }; + match &d.action { + Action::Skip => continue, + Action::Import if l.items.contains_key(&id) && !is_global(id.kind) => { + // 内容一样的「导入」什么都不做;不一样的要选 + if l.items[&id].units != item.units { + return Err(ImportError::Exists(id)); + } + continue; + } + Action::Import | Action::Overwrite | Action::Rename(_) => {} + } + let item = prepared(&id, item, incoming, &l, ctx); + target.items.insert(id.clone(), item); + taken.push(id); + } + + // 新加的上游、路由、插件接在后面,按包里的先后 + for kind in [ItemKind::Provider, ItemKind::Route, ItemKind::Plugin] { + let order_id = ItemId::new(kind.order().expect("has an order"), ""); + let pack_order: Vec = i + .get(&order_id) + .map(|o| names_of(&o.units)) + .unwrap_or_default(); + let mut list: Vec = target + .get(&order_id) + .map(|o| names_of(&o.units)) + .unwrap_or_default(); + let new: Vec<&ItemId> = taken + .iter() + .filter(|id| id.kind == kind && !list.contains(&id.name)) + .collect(); + if new.is_empty() { + continue; + } + let mut added: Vec = new.iter().map(|id| id.name.clone()).collect(); + added.sort_by_key(|n| pack_order.iter().position(|p| p == n).unwrap_or(usize::MAX)); + list.extend(added); + target.items.insert( + order_id, + Item { + units: BTreeMap::from([( + unit::ALL.to_string(), + Json::Array(list.into_iter().map(Json::String).collect()), + )]), + ..Default::default() + }, + ); + } + target.relink(); + + // 导进来的项目引用的东西都得在:被跳过的那些不在 + for id in &taken { + for r in &target.items[id].refs { + if !target.items.contains_key(r) && !builtin(r) { + return Err(ImportError::Item(id.clone(), Box::new(skipped_ref(id, r)))); + } + } + } + + let pack = land(local, &target, codec)?; + Ok(Applied { + changed_keys: changed_keys(local, &pack), + pack, + }) +} + +fn names_of(units: &BTreeMap) -> Vec { + match units.get(unit::ALL) { + Some(Json::Array(a)) => a + .iter() + .filter_map(|n| n.as_str().map(str::to_string)) + .collect(), + _ => Vec::new(), + } +} + +/// 值变了或者没了的网关密钥:名字是 `before` 里的 +pub fn changed_keys(before: &Pack, after: &Pack) -> Vec { + let keys = |p: &Pack| -> BTreeMap { + model::parse(&p.config_yaml) + .map(|c| c.clients.into_iter().map(|c| (c.name, c.key)).collect()) + .unwrap_or_default() + }; + let (b, a) = (keys(before), keys(after)); + b.into_iter() + .filter(|(name, v)| a.get(name) != Some(v)) + .map(|(name, _)| name) + .collect() +} + +/// 全局设置的一节在导入时叫什么(给界面列在「全局设置」下) +pub fn is_global_section(id: &ItemId) -> bool { + id.kind == ItemKind::Global && global::ALL.contains(&id.name.as_str()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/tw-pack/src/import/tests.rs b/crates/tw-pack/src/import/tests.rs new file mode 100644 index 00000000..f66a9396 --- /dev/null +++ b/crates/tw-pack/src/import/tests.rs @@ -0,0 +1,524 @@ +//! 导入:5.4 节的每一种状态,和改名、跳过、覆盖之后的样子。 + +use std::collections::BTreeMap; + +use serde_json::json; + +use super::*; +use crate::model::PluginCodec; + +/// 整份比较、只有带 `steer` 的要批准 +struct WholeFile; + +impl PluginCodec for WholeFile { + fn split(&self, _: &str) -> Option<(String, serde_json::Value)> { + None + } + fn join(&self, source: &str, data: &serde_json::Value) -> Option { + data.is_null().then(|| source.to_string()) + } + fn shipped(&self, _: &str) -> Option { + None + } + fn needs_approval(&self, source: &str) -> bool { + source.contains("steer") + } +} +use crate::{Manifest, PackKind, PackOrigin, PluginFiles}; + +const KEY: &str = "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00"; + +fn local_text(extra: &str) -> String { + format!( + "version: 1\nlisten:\n control:\n key: {KEY}\nclients:\n - name: default\n key: tw-local\n - name: codex\n key: tw-codex\nproviders:\n - name: 公司中转\n base_url: https://relay.example\n key: sk-old\n - name: chatgpt\n base_url: https://chatgpt.com/backend-api/codex\n oauth:\n refresh: rt-mine\n endpoint: https://auth.openai.com/oauth/token\n headers:\n ChatGPT-Account-Id: acct-mine\n{extra}" + ) +} + +fn pack_of(text: &str, secrets: bool) -> Pack { + let mut manifest = Manifest::new(PackKind::Full, PackOrigin::Export); + manifest.secrets = secrets; + Pack { + manifest, + config_yaml: text.to_string(), + plugins: BTreeMap::new(), + client_prefs: None, + } +} + +const INCOMING: &str = r#"version: 1 +clients: + - name: default + key: tw-local + - name: shared + key: tw-shared +providers: + - name: 公司中转 + base_url: https://relay.example/v2 + key: sk-new + - name: OpenRouter + base_url: https://openrouter.ai/api/v1 + key: sk-or + - name: chatgpt + base_url: https://chatgpt.com/backend-api/codex + - name: 新的 ChatGPT + base_url: https://chatgpt.com/backend-api/codex + - name: Bedrock 东京 + base_url: https://bedrock-runtime.ap-northeast-1.amazonaws.com + aws: { profile: tokyo } +routes: + - name: 走中转 + rules: + - { name: all, to: 公司中转 } + - name: 缺上游 + rules: + - { name: all, to: 不存在 } +failover: + pause_secs: 30 +"#; + +fn ctx() -> Context<'static> { + Context { + has_aws_profile: &|p| p == "default", + new_key: &|| "tw-regenerated".to_string(), + with_prefs: true, + } +} + +fn rows(local: &Pack, incoming: &Pack) -> BTreeMap { + preview_with(local, incoming, &ctx(), &WholeFile) + .unwrap() + .into_iter() + .map(|r| (r.item.to_string(), r)) + .collect() +} + +#[test] +fn every_item_gets_its_status() { + let local = pack_of(&local_text(""), true); + let incoming = pack_of(INCOMING, true); + let r = rows(&local, &incoming); + let status = |k: &str| { + r.get(k) + .unwrap_or_else(|| panic!("no row {k}: {:?}", r.keys())) + .status + }; + assert_eq!(status("provider `OpenRouter`"), Status::New); + assert_eq!(status("key `shared`"), Status::New); + assert_eq!(status("key `default`"), Status::Same); + assert_eq!(status("provider `公司中转`"), Status::Conflict); + assert_eq!(r["provider `公司中转`"].differs, ["conn"]); + assert_eq!( + r["provider `公司中转`"].suggested_name.as_deref(), + Some("公司中转 (2)") + ); + // 本机的全局设置还是默认值:直接采用 + assert_eq!(status("global `failover`"), Status::New); + assert!(r["global `failover`"].global); + // 顺序项目不是一行 + assert!(!r.keys().any(|k| k.contains("order")), "{:?}", r.keys()); + // 引用了两边都没有的上游 + assert_eq!(status("route `缺上游`"), Status::Error); + let e = r["route `缺上游`"].error.as_ref().unwrap(); + assert_eq!(e.code, "import.missing_reference"); + assert_eq!(e.arg("target"), "不存在"); + // 本机没有它要的 AWS profile + assert_eq!(status("provider `Bedrock 东京`"), Status::Error); + assert_eq!( + r["provider `Bedrock 东京`"].error.as_ref().unwrap().code, + "import.no_aws_profile" + ); + // ChatGPT:本机没有同名的要登录;有同名、登录着的不用 + assert_eq!(r["provider `新的 ChatGPT`"].flags, [Flag::NeedsLogin]); + assert_eq!(r["provider `chatgpt`"].status, Status::Same); + // 引用:路由引用的、包里也有的上游 + assert_eq!( + r["route `走中转`"] + .refs + .iter() + .map(|r| r.to_string()) + .collect::>(), + ["provider `公司中转`"] + ); +} + +#[test] +fn a_changed_global_section_is_a_conflict_that_only_overwrites_or_skips() { + let local = pack_of(&local_text("failover:\n pause_secs: 120\n"), true); + let r = rows(&local, &pack_of(INCOMING, true)); + let f = &r["global `failover`"]; + assert_eq!(f.status, Status::Conflict); + assert!(f.global); + assert_eq!(f.differs, ["failover.pause_secs"]); + assert_eq!(f.suggested_name, None); +} + +#[test] +fn a_pack_without_secrets_imports_disabled_upstreams_and_new_keys() { + let local = pack_of(&local_text(""), true); + let mut full = pack_of(INCOMING, true); + full.config_yaml = full.config_yaml.replace(" - name: Bedrock 东京\n base_url: https://bedrock-runtime.ap-northeast-1.amazonaws.com\n aws: { profile: tokyo }\n", ""); + let exported = export::export_with( + &full, + export::Options { + scope: None, + secrets: false, + client_prefs: None, + origin: PackOrigin::Export, + }, + &WholeFile, + ) + .unwrap(); + let incoming = crate::read( + &crate::write(&exported.pack, crate::Seal::None).unwrap(), + crate::Unseal::None, + ) + .unwrap(); + assert!(!incoming.manifest.secrets); + assert!( + !incoming.config_yaml.contains("sk-or"), + "{}", + incoming.config_yaml + ); + assert!( + !incoming.config_yaml.contains("tw-shared"), + "{}", + incoming.config_yaml + ); + let r = rows(&local, &incoming); + assert_eq!(r["provider `OpenRouter`"].flags, [Flag::MissingKey]); + assert_eq!(r["provider `OpenRouter`"].status, Status::New); + assert_eq!(r["key `shared`"].flags, [Flag::MissingKey]); + let applied = apply_with( + &local, + &incoming, + &[ + Decision { + item: ItemId::new(ItemKind::Provider, "OpenRouter"), + action: Action::Import, + }, + Decision { + item: ItemId::new(ItemKind::Key, "shared"), + action: Action::Import, + }, + ], + &ctx(), + &WholeFile, + ) + .unwrap(); + let cfg = tw_config::try_parse(&applied.pack.config_yaml).unwrap(); + let or = cfg + .providers + .iter() + .find(|p| p.name == "OpenRouter") + .unwrap(); + assert!(or.disabled, "缺密钥的上游导入为停用"); + assert_eq!(or.key.as_ref().map(|k| k.raw()), Some("")); + let shared = cfg.clients.iter().find(|c| c.name == "shared").unwrap(); + assert_eq!(shared.key, "tw-regenerated"); +} + +#[test] +fn import_overwrite_rename_and_skip_land_as_chosen() { + let local = pack_of(&local_text(""), true); + let incoming = pack_of(INCOMING, true); + let d = |kind, name: &str, action| Decision { + item: ItemId::new(kind, name), + action, + }; + let applied = apply_with( + &local, + &incoming, + &[ + d(ItemKind::Provider, "OpenRouter", Action::Import), + d( + ItemKind::Provider, + "公司中转", + Action::Rename("公司中转 (2)".into()), + ), + d(ItemKind::Route, "走中转", Action::Import), + d(ItemKind::Provider, "新的 ChatGPT", Action::Import), + d(ItemKind::Global, "failover", Action::Import), + d(ItemKind::Key, "shared", Action::Skip), + ], + &ctx(), + &WholeFile, + ) + .unwrap(); + let cfg = tw_config::try_parse(&applied.pack.config_yaml) + .unwrap_or_else(|e| panic!("{e}\n{}", applied.pack.config_yaml)); + let names: Vec<&str> = cfg.providers.iter().map(|p| p.name.as_str()).collect(); + // 本机的在前,新加的按包里的先后接在后面 + assert_eq!( + names, + [ + "公司中转", + "chatgpt", + "公司中转 (2)", + "OpenRouter", + "新的 ChatGPT" + ] + ); + // 本机那一家没动 + let mine = &cfg.providers[0]; + assert_eq!(mine.base_url, "https://relay.example"); + // 改名导入的那一家,包里引用它的路由跟着改 + let route = cfg.routes.iter().find(|r| r.name == "走中转").unwrap(); + assert_eq!( + route.rules[0].to.as_ref().and_then(|t| t.name()), + Some("公司中转 (2)") + ); + // 没登录的 ChatGPT 账号上游停用着 + let gpt = cfg + .providers + .iter() + .find(|p| p.name == "新的 ChatGPT") + .unwrap(); + assert!(gpt.disabled && gpt.oauth.is_none()); + assert_eq!(cfg.failover.pause_secs, 30); + assert!(!cfg.clients.iter().any(|c| c.name == "shared")); + assert!(applied.changed_keys.is_empty()); + // 本机设置和登录都在 + assert!(applied.pack.config_yaml.contains(KEY)); + assert!(applied.pack.config_yaml.contains("rt-mine")); +} + +#[test] +fn overwriting_keeps_the_local_login_and_reports_changed_keys() { + let local = pack_of(&local_text(""), true); + let incoming = pack_of( + &INCOMING + .replace(" key: tw-local\n", " key: tw-local-new\n") + .replace( + " - name: chatgpt\n base_url: https://chatgpt.com/backend-api/codex\n", + " - name: chatgpt\n base_url: https://chatgpt.com/backend-api/codex\n max_concurrent: 3\n", + ), + true, + ); + let r = rows(&local, &incoming); + assert_eq!(r["provider `chatgpt`"].status, Status::Conflict); + assert_eq!(r["provider `chatgpt`"].flags, Vec::::new()); + let applied = apply_with( + &local, + &incoming, + &[ + Decision { + item: ItemId::new(ItemKind::Provider, "chatgpt"), + action: Action::Overwrite, + }, + Decision { + item: ItemId::new(ItemKind::Key, "default"), + action: Action::Overwrite, + }, + ], + &ctx(), + &WholeFile, + ) + .unwrap(); + let cfg = tw_config::try_parse(&applied.pack.config_yaml).unwrap(); + let gpt = cfg.providers.iter().find(|p| p.name == "chatgpt").unwrap(); + assert_eq!(gpt.max_concurrent, Some(3)); + assert!(!gpt.disabled); + assert_eq!(gpt.oauth.as_ref().unwrap().refresh, "rt-mine"); + assert_eq!(applied.changed_keys, ["default"]); +} + +#[test] +fn a_reference_to_a_skipped_item_is_refused() { + let local = pack_of(&local_text(""), true); + let incoming = pack_of(&INCOMING.replace("to: 公司中转", "to: OpenRouter"), true); + let e = apply_with( + &local, + &incoming, + &[ + Decision { + item: ItemId::new(ItemKind::Route, "走中转"), + action: Action::Import, + }, + Decision { + item: ItemId::new(ItemKind::Provider, "OpenRouter"), + action: Action::Skip, + }, + ], + &ctx(), + &WholeFile, + ) + .unwrap_err(); + assert_eq!(e.msg().code, "import.skipped_reference"); + // 选「导入」一个本机已有、内容不同的:要选覆盖或改名 + let e = apply_with( + &local, + &incoming, + &[Decision { + item: ItemId::new(ItemKind::Provider, "公司中转"), + action: Action::Import, + }], + &ctx(), + &WholeFile, + ) + .unwrap_err(); + assert_eq!(e.msg().code, "import.exists"); + // 改名改成一个被占的名字 + let e = apply_with( + &local, + &incoming, + &[Decision { + item: ItemId::new(ItemKind::Provider, "公司中转"), + action: Action::Rename("chatgpt".into()), + }], + &ctx(), + &WholeFile, + ) + .unwrap_err(); + assert_eq!(e.msg().code, "import.name_taken"); + // 导入一个出错的那一行 + let e = apply_with( + &local, + &incoming, + &[Decision { + item: ItemId::new(ItemKind::Route, "缺上游"), + action: Action::Import, + }], + &ctx(), + &WholeFile, + ) + .unwrap_err(); + assert_eq!(e.msg().code, "import.missing_reference"); +} + +#[test] +fn plugins_are_checked_against_their_approved_hash_and_arrive_disabled() { + let script = b"export const manifest = {};\n".to_vec(); + let sha = model::sha256_hex(&script); + let text = format!( + "version: 1\nplugins:\n - id: good\n file: plugins/good.js\n sha256: {sha}\n - id: tampered\n file: plugins/tampered.js\n sha256: {sha}\n - id: absent\n file: plugins/absent.js\n sha256: {sha}\n" + ); + let mut incoming = pack_of(&text, true); + incoming.plugins.insert( + "good".into(), + PluginFiles { + script: script.clone(), + approved: Some(script.clone()), + }, + ); + incoming.plugins.insert( + "tampered".into(), + PluginFiles { + script: b"something else".to_vec(), + approved: None, + }, + ); + let local = pack_of(&local_text(""), true); + let r = rows(&local, &incoming); + assert_eq!(r["plugin `good`"].status, Status::New); + assert_eq!(r["plugin `good`"].flags, [Flag::PluginDisabled]); + assert_eq!( + r["plugin `tampered`"].error.as_ref().unwrap().code, + "import.plugin_mismatch" + ); + assert_eq!( + r["plugin `absent`"].error.as_ref().unwrap().code, + "import.plugin_missing" + ); + let applied = apply_with( + &local, + &incoming, + &[Decision { + item: ItemId::new(ItemKind::Plugin, "good"), + action: Action::Import, + }], + &ctx(), + &WholeFile, + ) + .unwrap(); + let cfg = tw_config::try_parse(&applied.pack.config_yaml).unwrap(); + assert_eq!(cfg.plugins.len(), 1); + assert!(!cfg.plugins[0].enabled); + assert_eq!(cfg.plugins[0].sha256, sha); + assert_eq!(applied.pack.plugins["good"].script, script); + + // 改得了工具调用的插件:停用着进来,也不批准 —— 要在插件页上点头 + let steer = b"export const manifest = {}; // steer\n".to_vec(); + let sha = model::sha256_hex(&steer); + let mut incoming = pack_of( + &format!("version: 1\nplugins:\n - id: s\n file: plugins/s.js\n sha256: {sha}\n"), + true, + ); + incoming.plugins.insert( + "s".into(), + PluginFiles { + script: steer.clone(), + approved: Some(steer), + }, + ); + let applied = apply_with( + &local, + &incoming, + &[Decision { + item: ItemId::new(ItemKind::Plugin, "s"), + action: Action::Import, + }], + &ctx(), + &WholeFile, + ) + .unwrap(); + let cfg = tw_config::try_parse(&applied.pack.config_yaml).unwrap(); + assert_ne!(cfg.plugins[0].sha256, sha); + assert!(!cfg.plugins[0].enabled); +} + +#[test] +fn a_key_with_the_value_of_another_key_here_is_an_error() { + let local = pack_of(&local_text(""), true); + let incoming = pack_of( + "version: 1\nclients:\n - name: copy\n key: tw-codex\n", + true, + ); + let r = rows(&local, &incoming); + assert_eq!( + r["key `copy`"].error.as_ref().unwrap().code, + "import.key_value_taken" + ); +} + +/// 导进来过不了校验的:那一行就是错误,带着校验的那句话 +#[test] +fn an_item_that_would_not_validate_is_an_error_with_the_reason() { + let local = pack_of(&local_text(""), true); + let incoming = pack_of( + "version: 1\nproviders:\n - name: bad\n base_url: ftp://nope\n", + true, + ); + let r = rows(&local, &incoming); + assert_eq!(r["provider `bad`"].status, Status::Error); + assert_eq!( + r["provider `bad`"].error.as_ref().unwrap().code, + "config.bad_base_url" + ); +} + +#[test] +fn client_preferences_import_only_when_core_keeps_them() { + let local = pack_of(&local_text(""), true); + let mut incoming = pack_of("version: 1\n", true); + incoming.client_prefs = Some(json!({"language": "en"})); + let r = rows(&local, &incoming); + assert_eq!(r["client_prefs"].status, Status::New); + let remote = Context { + with_prefs: false, + ..ctx() + }; + let r = preview_with(&local, &incoming, &remote, &WholeFile).unwrap(); + assert!(r.is_empty(), "{r:?}"); + let applied = apply_with( + &local, + &incoming, + &[Decision { + item: ItemId::new(ItemKind::ClientPrefs, ""), + action: Action::Import, + }], + &ctx(), + &WholeFile, + ) + .unwrap(); + assert_eq!(applied.pack.client_prefs, Some(json!({"language": "en"}))); +} diff --git a/crates/tw-pack/src/lib.rs b/crates/tw-pack/src/lib.rs index 99c1e4c6..789b1400 100644 --- a/crates/tw-pack/src/lib.rs +++ b/crates/tw-pack/src/lib.rs @@ -33,7 +33,12 @@ use std::io::{Read, Write}; use serde::{Deserialize, Serialize}; use tw_types::{Msg, msg}; +pub mod backup; +pub mod disk; +pub mod export; +pub mod import; pub mod model; +pub mod restore; /// 配置包格式的版本。**读到更大的就拒绝**([`PackError::TooNew`]):新版本的格式旧版本 /// 猜不准,猜错了会悄悄丢东西 @@ -42,6 +47,9 @@ pub const FORMAT: u32 = 1; /// 一个配置包(连同解压之后的内容)最大多少字节 pub const MAX_BYTES: usize = 16 * 1024 * 1024; +/// 口令最少几个字符 +pub const MIN_PASSPHRASE: usize = 8; + /// 扩展名,不带点 pub const EXTENSION: &str = "twconfig"; @@ -194,7 +202,10 @@ impl From for PackOrigin { O::Cli => PackOrigin::Cli, O::External => PackOrigin::External, O::Defaults => PackOrigin::Defaults, - O::Rollback => PackOrigin::Restore, + O::Sync => PackOrigin::Sync, + O::Import => PackOrigin::Import, + O::Restore => PackOrigin::Restore, + O::Repair => PackOrigin::Repair, // 轮换不留版本,走到这里也只是一个说不出来源的版本 O::Rotation => PackOrigin::Other, } diff --git a/crates/tw-pack/src/model/items.rs b/crates/tw-pack/src/model/items.rs index 08615a64..3f200741 100644 --- a/crates/tw-pack/src/model/items.rs +++ b/crates/tw-pack/src/model/items.rs @@ -14,7 +14,7 @@ use crate::{Pack, PluginFiles}; pub(crate) const ACCOUNT_HEADER: &str = tw_config::chatgpt::ACCOUNT_HEADER; /// 一个上游是 ChatGPT 账号。它的登录(`oauth`)和账户 ID 请求头属于这台设备 -pub(crate) fn is_chatgpt(p: &Provider) -> bool { +pub fn is_chatgpt(p: &Provider) -> bool { p.effective_protocol() == Some(Protocol::Chatgpt) } @@ -412,7 +412,12 @@ fn plugin_units( if let Some(d) = data { units.insert(unit::SETTINGS.to_string(), d); } - (units, Some(files.clone())) + // 带着走的是批准过的那一份:和单位说的是同一份代码 + let carried = PluginFiles { + script: source.to_vec(), + approved: Some(source.to_vec()), + }; + (units, Some(carried)) } /// 批准过的那一份:哈希对得上配置的脚本或底稿。都对不上是脚本 @@ -426,7 +431,7 @@ pub(crate) fn approved_source<'a>(entry: &tw_config::Plugin, files: &'a PluginFi } } -pub(crate) fn sha256_hex(bytes: &[u8]) -> String { +pub fn sha256_hex(bytes: &[u8]) -> String { use sha2::Digest; let d = sha2::Sha256::digest(bytes); d.iter().map(|b| format!("{b:02x}")).collect() diff --git a/crates/tw-pack/src/model/mod.rs b/crates/tw-pack/src/model/mod.rs index ce9efe29..28974ee1 100644 --- a/crates/tw-pack/src/model/mod.rs +++ b/crates/tw-pack/src/model/mod.rs @@ -45,7 +45,9 @@ mod rename; mod write; pub use codec::{PluginCodec, WholeFile}; +pub use items::{is_chatgpt, sha256_hex}; pub use rename::{free_name, rename}; +pub(crate) use write::materialize_for_export; pub use write::{materialize, materialize_with}; /// 配置里属于这台设备、不随导出和同步带走的几节 @@ -487,6 +489,16 @@ pub(crate) fn parse(text: &str) -> Result { Ok(cfg) } +/// 一个上游项目的单位读成配置里的上游(没有本机的登录) +pub fn provider_of(id: &ItemId, item: &Item) -> Result { + items::provider_mapping(id, &item.units, None).map(|(p, _)| p) +} + +/// 一棵 JSON 摊平成通用偏好那样的单位(`a.b` → 值) +pub(crate) fn json_flatten(v: &Json, out: &mut BTreeMap) { + json::flatten("", v, out); +} + /// [`Pack::content_id`] pub(crate) fn content_id(pack: &Pack) -> String { let hash = match snapshot(pack) { diff --git a/crates/tw-pack/src/model/tests.rs b/crates/tw-pack/src/model/tests.rs index a09ce6c1..3bbedfde 100644 --- a/crates/tw-pack/src/model/tests.rs +++ b/crates/tw-pack/src/model/tests.rs @@ -825,7 +825,7 @@ fn a_steering_plugin_from_elsewhere_waits_for_approval_here() { "批准了别处来的代码" ); assert_eq!(out.plugins["steerer"].approved, None); - // 停用着来的照常批准:打开时才问 + // 停用着来的也不批准:装上这种插件本身就要点头 let mut off = t.clone(); off.items .get_mut(&id(ItemKind::Plugin, "steerer")) @@ -834,6 +834,11 @@ fn a_steering_plugin_from_elsewhere_waits_for_approval_here() { .insert("enabled".into(), json!(false)); let out = materialize_with(&with_plugins(&[]), &off, &codec).unwrap(); let cfg = tw_config::try_parse(&out.config_yaml).unwrap(); + assert!(!cfg.plugins[0].enabled); + assert_ne!(cfg.plugins[0].sha256, items::sha256_hex(src.as_bytes())); + // 导出不管这些:包里的是那一边批准过的样子 + let exported = super::materialize_for_export(&with_plugins(&[]), &t, &codec).unwrap(); + let cfg = tw_config::try_parse(&exported.config_yaml).unwrap(); assert_eq!(cfg.plugins[0].sha256, items::sha256_hex(src.as_bytes())); // 本机停用着、批准过同一份代码,别处把它打开了:这里照样要点头 let off_here = with_plugins(&[("steerer", src, false)]); diff --git a/crates/tw-pack/src/model/write.rs b/crates/tw-pack/src/model/write.rs index ff3355bc..793ef2d6 100644 --- a/crates/tw-pack/src/model/write.rs +++ b/crates/tw-pack/src/model/write.rs @@ -66,18 +66,38 @@ pub fn materialize(local: &Pack, target: &Snapshot) -> Result /// - 代码没变的(包括默认插件)在本机的文件上改数据,字节尽量不动;代码变了的、新来的用 /// 目标里带着的原文件([`Item::files`]),没有就按单位拼出源码;默认插件在本机没有时 /// 用这一份 Core 自带的那一份,也没有就不加 -/// - **改得了工具调用的插件,开着、而代码在本机没批准过(新来的、代码变了的、本机原来 -/// 停用着的)时不批准**:文件写下去,配置里的哈希留着本机原来批准的那一份(没有就是 -/// 一个谁都对不上的),插件停在「待批准」,要在本机点过头才跑 +/// - **改得了工具调用的插件,代码在本机没批准过(新来的、代码变了的),或者在本机原来停用着、 +/// 落下去是开着的,就不批准**:文件写下去,配置里的哈希是一个谁都对不上的,插件停在 +/// 「待批准」,要在本机点过头(确认过的那条路)才跑。和插件页上一样:装上、打开、改代码都要 +/// 点头 /// - 别的照目标批准:哈希就是写下去的文件的 pub fn materialize_with( local: &Pack, target: &Snapshot, codec: &dyn PluginCodec, +) -> Result { + land(local, target, codec, true) +} + +/// 同 [`materialize_with`],**插件照目标批准,不管它改不改得了工具调用**。只给生成配置包用 +/// (导出):落的是包里的内容,不是这台设备要跑的东西 +pub(crate) fn materialize_for_export( + local: &Pack, + target: &Snapshot, + codec: &dyn PluginCodec, +) -> Result { + land(local, target, codec, false) +} + +fn land( + local: &Pack, + target: &Snapshot, + codec: &dyn PluginCodec, + gate: bool, ) -> Result { let cfg = parse(&local.config_yaml)?; let current = snapshot_with(local, codec)?; - let plugins = plugin_outcomes(local, &cfg, ¤t, target, codec)?; + let plugins = plugin_outcomes(local, &cfg, ¤t, target, codec, gate)?; let want = complete_orders(normalized(target), &cfg, &plugins); let prefs = target .get(&ItemId::new(ItemKind::ClientPrefs, "")) @@ -224,6 +244,7 @@ fn plugin_outcomes( current: &Snapshot, target: &Snapshot, codec: &dyn PluginCodec, + gate: bool, ) -> Result, ModelError> { let mut out = BTreeMap::new(); for (id, t) in target.of_kind(ItemKind::Plugin) { @@ -264,7 +285,8 @@ fn plugin_outcomes( } else if let Some(f) = t.files.as_ref().filter(|f| { let src = text(&f.script); let code = codec.split(&src).map_or(src, |(c, _)| c); - t_script.and_then(Json::as_str) == Some(code.as_str()) + // 默认插件没有脚本这个单位:带着的就是它在那一边的文件 + t_script.is_none_or(|s| s.as_str() == Some(code.as_str())) }) { Some(text(&f.script)) } else if let Some(s) = t_script.and_then(Json::as_str) { @@ -286,26 +308,23 @@ fn plugin_outcomes( })?, None => base, }; - let approved_here = - le.is_some_and(|le| le.enabled) && same_code && local_approved.is_some(); - let gated = enabled && codec.needs_approval(&source) && !approved_here; + // 本机批准过这份代码;打开它也是本机的人打开的 + let approved_here = same_code && local_approved.is_some(); + let turned_on = enabled && !le.is_some_and(|le| le.enabled); + let gated = gate && codec.needs_approval(&source) && (!approved_here || turned_on); let pending_edit = match (le, lf) { (Some(le), Some(lf)) => sha256_hex(&lf.script) != le.sha256, _ => false, }; let (files, sha256) = if gated { - // 哈希留着本机原来批准的那一份(界面拿它和底稿比出改了什么);它恰好就是要写下去 - // 的这一份(本机停用着、批准过同一份代码)时也不能留:留着就等于替人点了头 - let sha = match le { - Some(le) if le.sha256 != sha256_hex(source.as_bytes()) => le.sha256.clone(), - _ => NOT_APPROVED.to_string(), - }; + // 哈希写成一个谁都对不上的:插件停在「文件变了、待批准」,批准要走确认过的那条路。 + // 本机原来批准的底稿留着,界面拿它比出改了什么 ( PluginFiles { script: source.into_bytes(), approved: local_approved.clone(), }, - sha, + NOT_APPROVED.to_string(), ) } else if same_code && pending_edit { // 本机有一处还没批准的改动:留着它,底稿和哈希换成新的 diff --git a/crates/tw-pack/src/restore.rs b/crates/tw-pack/src/restore.rs new file mode 100644 index 00000000..9ae33160 --- /dev/null +++ b/crates/tw-pack/src/restore.rs @@ -0,0 +1,187 @@ +//! 恢复:把配置换回某一个备份版本,全部或者其中几项。 +//! +//! 按项目列出那一版和现在的差异(新增、删除、修改),缺省全选,也就是整体换回那一版; +//! 没选的保持现在的样子。 +//! +//! - **本机设置留着现在的**(监听、保留期、同步设置、控制面的钥匙),ChatGPT 上游的登录也是 +//! 现在的 —— 那一版里的旧 token 可能早就因为刷新作废了。那一版有、现在没有的 ChatGPT 上游 +//! 恢复成停用:它的登录随删除吊销了,要重新登录 +//! - **本机接管的客户端在用的网关密钥不删**(固定密钥):那一行标成留着 +//! - 恢复本身也留一个版本,所以可以撤销 + +use std::collections::BTreeSet; + +use serde_json::Value as Json; + +use crate::model::{self, ItemId, ItemKind, ModelError, PluginCodec, Snapshot, unit}; +use crate::{Pack, import}; + +/// 恢复对一项做什么。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Change { + Add, + Remove, + Modify, +} + +/// 为什么留着它。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kept { + /// 接管的客户端在用这把网关密钥 + KeyInUse, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Row { + pub item: ItemId, + pub change: Change, + pub kept: Option, +} + +/// 那一版和现在的差异。`pinned` 是固定密钥的名字。`with_prefs` 为 false 时通用偏好不算 +/// (经远程连接恢复:偏好在 Lite 那边) +pub fn preview( + local: &Pack, + backup: &Pack, + pinned: &BTreeSet, + with_prefs: bool, +) -> Result, ModelError> { + preview_with(local, backup, pinned, with_prefs, model::codec()) +} + +pub fn preview_with( + local: &Pack, + backup: &Pack, + pinned: &BTreeSet, + with_prefs: bool, + codec: &dyn PluginCodec, +) -> Result, ModelError> { + let l = model::snapshot_with(local, codec)?; + let b = model::snapshot_with(backup, codec)?; + Ok(rows(&l, &b, pinned, with_prefs)) +} + +fn rows(l: &Snapshot, b: &Snapshot, pinned: &BTreeSet, with_prefs: bool) -> Vec { + let (l, b) = (model::normalized(l), model::normalized(b)); + let ids: BTreeSet<&ItemId> = l.items.keys().chain(b.items.keys()).collect(); + ids.into_iter() + .filter(|id| with_prefs || id.kind != ItemKind::ClientPrefs) + .filter_map(|id| { + let change = match (l.items.get(id), b.items.get(id)) { + (None, Some(_)) => Change::Add, + (Some(_), None) => Change::Remove, + (Some(x), Some(y)) if x.units != y.units => Change::Modify, + _ => return None, + }; + let kept = + (change == Change::Remove && id.kind == ItemKind::Key && pinned.contains(&id.name)) + .then_some(Kept::KeyInUse); + Some(Row { + item: id.clone(), + change, + kept, + }) + }) + .collect() +} + +/// 恢复的结果。 +#[derive(Debug, Clone)] +pub struct Applied { + pub pack: Pack, + /// 值变了或者没了的网关密钥 + pub changed_keys: Vec, + /// 因为固定密钥留着没删的 + pub kept: Vec, +} + +/// 恢复。`items` 是只恢复这几项,None 是全部 +pub fn apply( + local: &Pack, + backup: &Pack, + items: Option<&[ItemId]>, + pinned: &BTreeSet, + with_prefs: bool, +) -> Result { + apply_with(local, backup, items, pinned, with_prefs, model::codec()) +} + +pub fn apply_with( + local: &Pack, + backup: &Pack, + items: Option<&[ItemId]>, + pinned: &BTreeSet, + with_prefs: bool, + codec: &dyn PluginCodec, +) -> Result { + let l = model::snapshot_with(local, codec)?; + let b = model::snapshot_with(backup, codec)?; + let picked: Option> = items.map(|xs| xs.iter().collect()); + let mut target = l.clone(); + let mut kept = Vec::new(); + for row in rows(&l, &b, pinned, with_prefs) { + if picked.as_ref().is_some_and(|p| !p.contains(&row.item)) { + continue; + } + if row.kept.is_some() { + kept.push(row.item.clone()); + continue; + } + match b.items.get(&row.item) { + Some(it) => { + let mut it = it.clone(); + // 那一版有、现在没有的 ChatGPT 账号上游:没有登录,停用着回来 + if row.item.kind == ItemKind::Provider + && !l.items.contains_key(&row.item) + && model::provider_of(&row.item, &it).is_ok_and(|p| model::is_chatgpt(&p)) + && let Some(Json::Object(other)) = it.units.get_mut(unit::OTHER) + { + other.insert("disabled".into(), Json::Bool(true)); + } + target.items.insert(row.item.clone(), it); + } + None => { + target.items.remove(&row.item); + } + } + } + target.relink(); + let pack = model::materialize_with(local, &target, codec)?; + tw_config::try_parse(&pack.config_yaml).map_err(|r| ModelError::Config(Box::new(r)))?; + Ok(Applied { + changed_keys: import::changed_keys(local, &pack), + pack, + kept, + }) +} + +/// 配置读不进来(安全模式)时的恢复:整份换成那一版,**只有控制面的钥匙换成现在这一把** +/// (`current_text` 是磁盘上现在那份读不进来的原文)。插件和通用偏好照那一版 +pub fn whole(backup: &Pack, current_text: &str) -> Pack { + let real = tw_config::control_key::raw_in(current_text) + .ok() + .flatten() + .or_else(|| raw_key_line(current_text)); + let text = tw_config::control_key::unmask(&backup.config_yaml, real.as_deref()) + .unwrap_or_else(|_| backup.config_yaml.clone()); + Pack { + config_yaml: text, + ..backup.clone() + } +} + +/// YAML 坏到按路径找不到时,退一步在原文里找 `key: <64 个十六进制字符>` 那一行 +fn raw_key_line(text: &str) -> Option { + text.lines().find_map(|l| { + let v = l + .trim() + .strip_prefix("key:")? + .trim() + .trim_matches(['"', '\'']); + (v.len() == tw_api::control::KEY_HEX_LEN && v.bytes().all(|b| b.is_ascii_hexdigit())) + .then(|| v.to_string()) + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/tw-pack/src/restore/tests.rs b/crates/tw-pack/src/restore/tests.rs new file mode 100644 index 00000000..3ecaeb0d --- /dev/null +++ b/crates/tw-pack/src/restore/tests.rs @@ -0,0 +1,105 @@ +//! 恢复:按项目列差异、挑几项恢复、本机设置和登录留着、固定密钥不删。 + +use std::collections::{BTreeMap, BTreeSet}; + +use super::*; +use crate::model::WholeFile; +use crate::{Manifest, PackKind, PackOrigin}; + +const KEY: &str = "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00"; +const OLD_KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn pack(text: &str) -> Pack { + Pack { + manifest: Manifest::new(PackKind::Full, PackOrigin::Ui), + config_yaml: text.to_string(), + plugins: BTreeMap::new(), + client_prefs: None, + } +} + +fn now() -> Pack { + pack(&format!( + "version: 1\nlisten:\n control:\n key: {KEY}\n gateway:\n port: 9000\nclients:\n - name: default\n key: tw-a\n - name: claude-code\n key: tw-cc\n client: claude-code\nproviders:\n - name: kept\n base_url: https://kept.example\n - name: chatgpt\n base_url: https://chatgpt.com/backend-api/codex\n oauth:\n refresh: rt-now\n endpoint: https://auth.openai.com/oauth/token\n" + )) +} + +/// 一个旧版本:密钥 claude-code 那时还没有,有一个后来删掉的上游,chatgpt 的 token 是旧的 +fn then() -> Pack { + pack(&format!( + "version: 1\nlisten:\n control:\n key: {}\n gateway:\n port: 8000\nclients:\n - name: default\n key: tw-old\nproviders:\n - name: kept\n base_url: https://kept.example/v0\n - name: deleted\n base_url: https://deleted.example\n - name: chatgpt\n base_url: https://chatgpt.com/backend-api/codex\n oauth:\n refresh: rt-then\n endpoint: https://auth.openai.com/oauth/token\n - name: old-gpt\n base_url: https://chatgpt.com/backend-api/codex\n oauth:\n refresh: rt-gone\n endpoint: https://auth.openai.com/oauth/token\n", + tw_api::control::KEY_MASK + )) +} + +fn pinned() -> BTreeSet { + ["claude-code".to_string()].into_iter().collect() +} + +#[test] +fn the_preview_lists_what_differs_by_item() { + let rows = preview_with(&now(), &then(), &pinned(), true, &WholeFile).unwrap(); + let got: Vec<(String, Change, Option)> = rows + .iter() + .map(|r| (r.item.to_string(), r.change, r.kept)) + .collect(); + assert_eq!( + got, + vec![ + ("provider `deleted`".into(), Change::Add, None), + ("provider `kept`".into(), Change::Modify, None), + ("provider `old-gpt`".into(), Change::Add, None), + ( + "key `claude-code`".into(), + Change::Remove, + Some(Kept::KeyInUse) + ), + ("key `default`".into(), Change::Modify, None), + ("provider_order".into(), Change::Modify, None), + ] + ); +} + +#[test] +fn restoring_everything_keeps_local_settings_logins_and_keys_in_use() { + let out = apply_with(&now(), &then(), None, &pinned(), true, &WholeFile).unwrap(); + let cfg = tw_config::try_parse(&out.pack.config_yaml) + .unwrap_or_else(|e| panic!("{e}\n{}", out.pack.config_yaml)); + assert_eq!(cfg.listen.gateway.port, 9000, "本机设置留着现在的"); + assert!(out.pack.config_yaml.contains(KEY)); + let p = |n: &str| cfg.providers.iter().find(|p| p.name == n); + assert_eq!(p("kept").unwrap().base_url, "https://kept.example/v0"); + assert!(p("deleted").is_some()); + // 登录是现在的;回来的那一家停用着,旧 token 不写回 + assert_eq!( + p("chatgpt").unwrap().oauth.as_ref().unwrap().refresh, + "rt-now" + ); + let old = p("old-gpt").unwrap(); + assert!(old.disabled && old.oauth.is_none()); + assert!(!out.pack.config_yaml.contains("rt-gone")); + // 接管在用的密钥没删 + assert!(cfg.clients.iter().any(|c| c.name == "claude-code")); + assert_eq!(out.kept, [ItemId::new(ItemKind::Key, "claude-code")]); + assert_eq!(out.changed_keys, ["default"]); +} + +#[test] +fn restoring_a_few_items_leaves_the_rest_as_it_is() { + let pick = [ItemId::new(ItemKind::Provider, "deleted")]; + let out = apply_with(&now(), &then(), Some(&pick), &pinned(), true, &WholeFile).unwrap(); + let cfg = tw_config::try_parse(&out.pack.config_yaml).unwrap(); + let names: Vec<&str> = cfg.providers.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["kept", "chatgpt", "deleted"]); + assert_eq!(cfg.providers[0].base_url, "https://kept.example"); + assert!(out.changed_keys.is_empty()); +} + +#[test] +fn in_safe_mode_the_whole_version_comes_back_with_todays_key() { + let broken = format!("version: 1\nlisten:\n control:\n key: {OLD_KEY}\nproviders: [\n"); + let out = whole(&then(), &broken); + assert!(out.config_yaml.contains(OLD_KEY), "{}", out.config_yaml); + assert!(!out.config_yaml.contains(tw_api::control::KEY_MASK)); + assert!(out.config_yaml.contains("port: 8000")); +} diff --git a/crates/tw-store/src/recorder.rs b/crates/tw-store/src/recorder.rs index b292a279..daa14eda 100644 --- a/crates/tw-store/src/recorder.rs +++ b/crates/tw-store/src/recorder.rs @@ -695,6 +695,9 @@ impl Recorder { | Event::PluginFailed { .. } // 自己刚报出去的那条。**不能再处理一遍** —— 那是一个回路 | Event::RequestPriced { .. } + // 配置那一边的事:哪几把密钥换了、界面的偏好变了 + | Event::GatewayKeysChanged { .. } + | Event::ClientPrefsChanged { .. } // 只发给事件流上掉队的那一个订阅者,从不进总线 | Event::EventsDropped { .. } => {} } diff --git a/docs/config.md b/docs/config.md index 7ef1c664..75a91421 100644 --- a/docs/config.md +++ b/docs/config.md @@ -19,7 +19,7 @@ when they disagree, so a field listed here is a field the binary reads. `THINKWATCH_HOME` replaces the directory, and `--config ` names the file for a single command. The directory holds everything else core keeps -as well: the request database (`data.db`), the configuration history +as well: the request database (`data.db`), the backups of the configuration (`history/`), the downloaded price table (`model_prices.json`) and the local control socket (`twcore.sock`; on Windows a loopback port recorded in `control.port`). The directory is private to its owner (`0700`), the file is @@ -116,23 +116,38 @@ reports why. Retention changes are applied on the next hourly clean-up. When two writers edit at once (the app and a hand edit), the second write is refused with a version mismatch instead of overwriting the first. -### History and rollback +### Backups, export and import -Every version that was in effect is kept in `history/` beside the file, -with where it came from (the app, the command line, an outside edit, a -rollback, a credential rotation). The last 50 are kept. +Every change to the configuration leaves a version in `history/` beside the +file: a configuration pack (`.twconfig`) with the plugins and the app's +preferences, recording where the change came from (the app, the command +line, an outside edit, an import, a restore, sync). The last 10 are kept; a +credential rotation does not make one. The control key is masked in them. ```sh twcore check # validate the file without starting anything twcore config show # print it, with its version -twcore config history # list the versions, newest first -twcore config rollback 3f9a2c # go back to a version (a prefix is enough) +twcore backup list # list the versions, newest first +twcore backup restore 1792315800000 --dry-run # what going back would change +twcore backup restore 1792315800000 # go back to a version +twcore export -o all.twconfig # the whole configuration as a pack +twcore export --providers relay --no-secrets --encrypt -o relay.twconfig +twcore import all.twconfig --dry-run # what importing would do twcore config set /listen/gateway/port 8790 --int ``` -These commands work on the file directly, so they work when core is not -running, which is when a rollback is most needed. A running core picks -their changes up like any other save. +A restore keeps the settings that belong to this machine (listening, the +control key, retention, sync), the ChatGPT sign-ins and the gateway keys that +clients set up on this machine use. An import adds what is not here and asks, +item by item, what to do with what differs (`--on-conflict` answers for all). +A pack carries no settings of this machine and no ChatGPT sign-in; without +`--no-secrets` it holds keys in plain text unless `--encrypt` is given (the +passphrase is asked for, or read from `TWCORE_PASSPHRASE`). + +These commands work on the files directly, so they work when core is not +running, which is when a restore is most needed; when the file does not +load at all, a restore brings the whole version back with the current +control key. A running core picks their changes up like any other save. `twcore config set ` changes one value that is already written in the file. The path names list items by their `name` @@ -244,7 +259,7 @@ complete the first message. `key` is written by `twcore serve` before the control channel starts listening, if it is missing, and by `twcore init`. It is masked wherever the -configuration is shown or kept in the history; saving a masked value back +configuration is shown or kept in a backup; saving a masked value back keeps the real one. A missing or malformed key (not 64 hexadecimal characters) makes the whole file invalid, so it cannot be replaced by a short, guessable one. diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index 1940b245..e6ba2936 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -13,7 +13,7 @@ ThinkWatch Core 只读一个文件:`config.yaml`。本文逐项说明其中每 | macOS、Linux | `~/.thinkwatch/config.yaml` | | Windows | `%APPDATA%\ThinkWatch\config.yaml` | -`THINKWATCH_HOME` 替换整个目录;`--config <路径>` 为单条命令指定文件。core 的其余数据也在这个目录里:请求数据库(`data.db`)、配置历史(`history/`)、下载的价目表(`model_prices.json`),以及本地控制通道的 socket 文件(`twcore.sock`;Windows 上是回环端口,记录在 `control.port` 中)。目录只有所有者可访问(`0700`),配置文件权限为 `0600`:其中以明文保存密钥。 +`THINKWATCH_HOME` 替换整个目录;`--config <路径>` 为单条命令指定文件。core 的其余数据也在这个目录里:请求数据库(`data.db`)、配置的备份(`history/`)、下载的价目表(`model_prices.json`),以及本地控制通道的 socket 文件(`twcore.sock`;Windows 上是回环端口,记录在 `control.port` 中)。目录只有所有者可访问(`0700`),配置文件权限为 `0600`:其中以明文保存密钥。 这两项权限就是这份文件在本机上的全部保护:挡得住其他用户,挡不住以同一用户身份运行的程序。这样的程序能读到文件里的每一把密钥,也能用其中的控制通道密钥经控制通道修改配置。出站脱敏不改变这一点:它保护的是请求带出本机的内容,不是磁盘上的文件。显示配置时给控制通道密钥打码,是为了让经控制通道的写入改不了它(见 [`listen.control`](#cfg-listen-control)),而不是对本机程序隐藏它;上游密钥和网关密钥按原样显示。 @@ -65,19 +65,25 @@ providers: 两方同时修改时(应用和手工编辑),后写入的一方因版本不一致被拒绝,不会覆盖先写入的内容。 -### 历史与回滚 +### 备份、导出与导入 -每个生效过的版本都保存在配置文件旁边的 `history/` 目录中,并记录来源(应用、命令行、外部编辑、回滚、凭据轮换)。保留最近 50 个版本。 +配置每改动一次,配置文件旁边的 `history/` 目录中就留下一个版本:一个配置包(`.twconfig`),连同插件和应用的通用偏好,并记录来源(应用、命令行、外部编辑、导入、恢复、同步)。保留最近 10 个版本;凭据轮换不生成版本。版本中的控制面密钥一律打码。 ```sh twcore check # 只校验配置,不启动任何服务 twcore config show # 打印当前配置及其版本 -twcore config history # 列出历史版本,最新的在前 -twcore config rollback 3f9a2c # 回滚到某个版本(写前几位即可) +twcore backup list # 列出备份版本,最新的在前 +twcore backup restore 1792315800000 --dry-run # 恢复会改动什么 +twcore backup restore 1792315800000 # 恢复到某个版本 +twcore export -o all.twconfig # 把全部配置导出为配置包 +twcore export --providers relay --no-secrets --encrypt -o relay.twconfig +twcore import all.twconfig --dry-run # 导入会做什么 twcore config set /listen/gateway/port 8790 --int ``` -这些命令直接操作文件,因此 core 没有运行时也能使用,而那往往正是最需要回滚的时候。正在运行的 core 会像对待其他保存一样接收这些改动。 +恢复时保留本机设置(监听、控制面密钥、保留期限、同步设置)、ChatGPT 的登录,以及本机已接管的客户端正在使用的网关密钥。导入时加入本机没有的项目,与本机不同的项目逐项选择处理方式(`--on-conflict` 一次指定全部)。配置包不含本机设置与 ChatGPT 登录;未指定 `--no-secrets` 时其中以明文保存密钥,除非指定 `--encrypt`(口令在终端输入,或取自环境变量 `TWCORE_PASSPHRASE`)。 + +这些命令直接操作文件,因此 core 没有运行时也能使用,而那往往正是最需要恢复的时候;配置完全无法读取时,恢复把整个版本写回,控制面密钥保持现在的值。正在运行的 core 会像对待其他保存一样接收这些改动。 `twcore config set <路径> <值>` 修改文件中已经写出的一个值。路径中的列表项按其 `name` 定位(`/providers/anthropic/base_url`);数字表示下标(`/routes/0/rules/1/to`)。值默认按字符串写入,`--int`、`--bool`、`--null` 另作指定。写入前先校验结果。要添加文件中还没有的字段,请直接编辑文件。 @@ -164,7 +170,7 @@ listen: | `remote` | 对象,见 [`listen.control.remote`](#cfg-listen-control-remote) | — | 供另一台机器上的桌面应用连接的网络端口。它是本地通道之外额外开的,不取代本地通道。 | -`key` 缺失时,由 `twcore serve` 在控制通道开始监听之前写入;`twcore init` 生成的配置也带有它。凡是显示配置或写入配置历史的地方,这个字段一律打码;把打码值原样存回时保留原值。密钥缺失或格式不对(不是 64 个十六进制字符)时整份配置无效,因此无法把它换成一把容易猜到的短密钥。 +`key` 缺失时,由 `twcore serve` 在控制通道开始监听之前写入;`twcore init` 生成的配置也带有它。凡是显示配置或写入备份的地方,这个字段一律打码;把打码值原样存回时保留原值。密钥缺失或格式不对(不是 64 个十六进制字符)时整份配置无效,因此无法把它换成一把容易猜到的短密钥。 ```sh twcore control-key # 显示密钥,用于粘贴到桌面应用 From 6a64d155d54e17fc023f0b0a724af56837207a7e Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:33:34 +0800 Subject: [PATCH 2/2] smoke: the backup endpoints instead of the removed history one Co-Authored-By: Claude Opus 5.5 --- scripts/smoke.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 2f18b3fa..b2d3768e 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -524,7 +524,7 @@ for ep in "/summary?from_ms=$DAY" "/summary/buckets?from_ms=$DAY&bucket_ms=36000 done for ep in /status /overview /summary /history /latency /latency/provider /latency/client /storage /quota /security \ - /security/events /sessions /diagnostics /config /config/history /models /in-flight /live \ + /security/events /sessions /diagnostics /config /config/items /backups /client-prefs /models /in-flight /live \ /upstreams/health /plugins; do C=$(get "$ep") [ "$C" = "200" ] && ok "GET $ep" || bad "GET $ep 返回 $C"