From 39682ed3a86b692b3e13f72de3985035560f375d Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:05:12 +0800 Subject: [PATCH] Packs: compare destinations the way the gateway reads them; say what a rename brings - destination() parses with the url crate, the parser reqwest uses: a backslash separates the path as it does when the request goes out, and hosts are normalized (case, punycode, IPv6/IPv4 forms) with default ports filled in. A trailing dot stays a different name. - Upstream addresses with a backslash or a user name/password before the host are refused (config.base_url_backslash, config.base_url_userinfo). - The outbound proxy an upstream goes through is part of its destination: direct, system, or the proxy's type and address. A different route keeps this machine's keys out. - ImportRow.rename_flags tells what an item brings when imported under another name (it never takes the keys or sign-ins here); the CLI says so. - A whole restore of a version from the sync location brings back the upstreams this machine does not sync, and what follows them, from the configuration here or the newest readable version. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + bin/twcore/src/pack_cli.rs | 46 ++++++- crates/tw-api/msg-codes.txt | 2 + crates/tw-api/src/lib.rs | 6 +- crates/tw-api/src/pack.rs | 4 + crates/tw-config/src/validate.rs | 76 ++++++++++++ crates/tw-config/tests/manual/schema.rs | 4 +- crates/tw-control/src/backups.rs | 22 ++-- crates/tw-control/tests/backups.rs | 28 +++++ crates/tw-pack/Cargo.toml | 2 + crates/tw-pack/src/export.rs | 151 ++++++++++++++--------- crates/tw-pack/src/import.rs | 26 +++- crates/tw-pack/src/restore.rs | 113 +++++++++++++++++ crates/tw-pack/tests/regressions.rs | 156 ++++++++++++++++++++++++ docs/config.md | 8 +- docs/config.zh-CN.md | 4 +- 16 files changed, 567 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 35d51c94..64b3fc25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3914,6 +3914,7 @@ dependencies = [ "tw-secret", "tw-types", "tw-yaml", + "url", "zip", ] diff --git a/bin/twcore/src/pack_cli.rs b/bin/twcore/src/pack_cli.rs index be68ffc8..4e80aa26 100644 --- a/bin/twcore/src/pack_cli.rs +++ b/bin/twcore/src/pack_cli.rs @@ -287,11 +287,19 @@ pub fn import( continue; } Status::New => { - println!(" new {what}"); + println!(" new {what}{}", flag_note(&r.item, &r.flags)); Action::Import } Status::Conflict => { - println!(" differs {what} ({})", r.differs.join(", ")); + println!( + " differs {what} ({}){}", + r.differs.join(", "), + flag_note(&r.item, &r.flags) + ); + let renamed = flag_note(&r.item, &r.rename_flags); + if !r.global && !renamed.is_empty() { + println!(" under another name{renamed}"); + } let choice = match on_conflict { Some(c) => c, None if dry_run => continue, @@ -311,7 +319,10 @@ pub fn import( }; match (choice, &r.suggested_name) { (OnConflict::Overwrite, _) => Action::Overwrite, - (OnConflict::Rename, Some(n)) => Action::Rename(n.clone()), + (OnConflict::Rename, Some(n)) => { + println!(" → as `{n}`{renamed}"); + Action::Rename(n.clone()) + } _ => Action::Skip, } } @@ -336,6 +347,35 @@ pub fn import( Ok(()) } +/// 一行导入带的标记说成一句:缺密钥、要登录的上游停用着进来,空的网关密钥换一个新值 +fn flag_note(id: &ItemId, flags: &[tw_pack::import::Flag]) -> String { + use tw_pack::import::Flag; + let mut parts: Vec<&str> = Vec::new(); + if flags.contains(&Flag::MissingKey) { + parts.push(match id.kind { + ItemKind::Key => "a new key value is generated", + ItemKind::Proxy => "its password is missing", + _ => "its key is missing", + }); + } + if flags.contains(&Flag::NeedsLogin) { + parts.push("it needs signing in"); + } + if id.kind == ItemKind::Provider + && (flags.contains(&Flag::MissingKey) || flags.contains(&Flag::NeedsLogin)) + { + parts.push("it comes in disabled"); + } + if flags.contains(&Flag::PluginDisabled) { + parts.push("it comes in turned off"); + } + if parts.is_empty() { + String::new() + } else { + format!(": {}", parts.join("; ")) + } +} + // ─────────────────────────────────────────────────────────── backup pub fn list(path: &Path) -> Result<()> { diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index 1b7a7fb4..bbd8d2d5 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -26,6 +26,8 @@ config.aws_profile.unsupported config.bad_allow_from config.bad_base_url config.bad_remote_allow_from +config.base_url_backslash +config.base_url_userinfo config.blank_models_only config.control_key_invalid config.control_key_missing diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index b59e6d4e..f3b459ab 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -976,8 +976,10 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// 超出上限是 `backup.pinned.*`)报告接管的客户端在用哪几把密钥。恢复时留下的项目还引用着的、 /// 同步设置走的出站代理不删([`KeptReason`] 的 `referenced`),所选项目引用的、本机已没有的一起 /// 恢复;恢复同步位置上的版本时本机不同步的上游不列成删除。不带密钥的包导入时比较和覆盖都沿用 -/// 本机的密钥,**只在目的地不变时**(协议、主机、端口、AWS 区域、OAuth 令牌端点、代理地址 -/// 一样);改名导入的不沿用,缺着的标 `missing_key`(代理的密码也算)。导入覆盖、恢复改了或删了网关密钥时发 +/// 本机的密钥,**只在目的地不变时**(协议、主机、端口按 URL 库规范化后一样,AWS 区域、OAuth +/// 令牌端点、所走的出站代理、代理地址一样);改名导入的不沿用,缺着的标 `missing_key`(代理的 +/// 密码也算),[`ImportRow`] 的 `rename_flags` 说改名导入时会带的标记。上游地址里有反斜杠、 +/// 主机前写了用户名或密码的不收(`config.base_url_backslash`、`config.base_url_userinfo`)。导入覆盖、恢复改了或删了网关密钥时发 /// [`Event::GatewayKeysChanged`]。[`ConfigOrigin`] 删了 `rollback`,多了 `sync`、`import`、 /// `restore`、`repair`(一键修复以前记成 `ui`)。备份相关的端点在安全模式下照样可用。配置包的 /// 码是 `pack.*`,导入的是 `import.*`,备份与恢复的是 `backup.*`。停用着的上游可以缺凭据 diff --git a/crates/tw-api/src/pack.rs b/crates/tw-api/src/pack.rs index a86c9119..c3e181b3 100644 --- a/crates/tw-api/src/pack.rs +++ b/crates/tw-api/src/pack.rs @@ -226,6 +226,10 @@ pub struct ImportRow { pub differs: Vec, /// 改名导入时的缺省名字(`公司中转 (2)`)。冲突的、有名字的项目才有 pub suggested_name: Option, + /// 改名导入时这一项会带的标记(`flags` 说的是导入、覆盖)。改名导入的是另一个项目,不沿用 + /// 本机的密钥和登录:不带密钥的包里,改名导入的上游缺着密钥、停用着进来(`missing_key`), + /// ChatGPT 账号要重新登录(`needs_login`)。有 `suggested_name` 的行才有,别的行是空的 + pub rename_flags: Vec, pub error: Option, /// 它引用的、包里也有的项目。**被跳过的项目被谁引用着,谁就导入不了**(除非引用的那一项 /// 是改名导入):界面按它把那几行标成错误 diff --git a/crates/tw-config/src/validate.rs b/crates/tw-config/src/validate.rs index 5c53b263..c8117b48 100644 --- a/crates/tw-config/src/validate.rs +++ b/crates/tw-config/src/validate.rs @@ -28,6 +28,14 @@ pub enum ValidationError { DuplicateClientKey(String, String, String), #[error("{}", self.msg())] BadBaseUrl { name: String, url: String }, + /// 地址里有反斜杠:URL 库把它当成路径的分隔(`https://a\\@b/` 连的是 a),人读起来 + /// 像是 b —— 两种读法对不上的地址一律不收 + #[error("{}", self.msg())] + BaseUrlBackslash { name: String, url: String }, + /// 地址的主机前面写了用户名或密码(`https://user:pass@host`):凭据写在 `key` 或 + /// `headers` 里。消息里不带地址:那一段就是密码 + #[error("{}", self.msg())] + BaseUrlUserinfo { name: String }, #[error("{}", self.msg())] EmptyKey { name: String }, #[error("{}", self.msg())] @@ -208,6 +216,16 @@ impl ValidationError { "config.bad_base_url", upstream = name, url = url => "the endpoint of upstream `{upstream}` is neither http nor https: {url}" ), + BaseUrlBackslash { name, url } => msg!( + "config.base_url_backslash", upstream = name, url = url => + "the endpoint of upstream `{upstream}` contains a backslash: {url}. Write the \ + address with forward slashes only" + ), + BaseUrlUserinfo { name } => msg!( + "config.base_url_userinfo", upstream = name => + "the endpoint of upstream `{upstream}` has a user name or password before the \ + host. Put the credential in `key` or `headers` instead" + ), EmptyKey { name } => msg!( "config.empty_key", key = name => "the value of gateway key `{key}` is empty" @@ -485,6 +503,19 @@ pub fn validate(cfg: &Config) -> Result<(), ValidationError> { url: p.base_url.clone(), }); } + if p.base_url.contains('\\') { + return Err(ValidationError::BaseUrlBackslash { + name: p.name.clone(), + url: p.base_url.clone(), + }); + } + if let Ok(u) = url::Url::parse(p.base_url.trim()) + && (!u.username().is_empty() || u.password().is_some()) + { + return Err(ValidationError::BaseUrlUserinfo { + name: p.name.clone(), + }); + } p.check_credential() .map_err(|source| ValidationError::Credential { name: p.name.clone(), @@ -1222,6 +1253,46 @@ mod tests { )); } + /// 两种读法对不上的地址不收:反斜杠(URL 库当成路径分隔,人读成主机前的一段)、主机前 + /// 的用户名和密码 + #[test] + fn a_base_url_that_reads_two_ways_is_refused() { + for url in [ + "https://evil.example\\@relay.example/v1", + "https://relay.example\\v1", + ] { + let e = validate(&cfg(vec![c("d", "tw-1")], vec![p("r", url)])).unwrap_err(); + assert!( + matches!(e, ValidationError::BaseUrlBackslash { .. }), + "{url}: {e}" + ); + assert_eq!(e.msg().code, "config.base_url_backslash"); + } + for url in [ + "https://user:pass@relay.example/v1", + "https://user@relay.example", + "http://:secret@10.0.0.1:3000", + ] { + let e = validate(&cfg(vec![c("d", "tw-1")], vec![p("r", url)])).unwrap_err(); + assert!( + matches!(e, ValidationError::BaseUrlUserinfo { .. }), + "{url}: {e}" + ); + assert!( + !e.to_string().contains("user:pass") && !e.to_string().contains("secret"), + "{e}" + ); + } + // `@` 在路径里没关系 + assert!( + validate(&cfg( + vec![c("d", "tw-1")], + vec![p("r", "https://relay.example/v1/@team")] + )) + .is_ok() + ); + } + #[test] fn names_that_start_with_two_underscores_are_reserved() { // 「全部上游」在配置里叫 `__all__`。一个同名的上游或策略组会让 @@ -1863,6 +1934,11 @@ mod msg_codes { name: "a".into(), url: "ftp://x".into(), }, + BaseUrlBackslash { + name: "a".into(), + url: "https://a\\@b".into(), + }, + BaseUrlUserinfo { name: "a".into() }, EmptyKey { name: "k".into() }, ZeroConcurrency { name: "k".into() }, ProviderConcurrency { diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs index 7d01198d..bd25378c 100644 --- a/crates/tw-config/tests/manual/schema.rs +++ b/crates/tw-config/tests/manual/schema.rs @@ -587,8 +587,8 @@ pub fn sections() -> Vec
{ Kind::Str, Def::Required, t( - "Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`). For Bedrock, the region's runtime endpoint: `https://bedrock-runtime..amazonaws.com`.", - "接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。Bedrock 写所在区域的推理地址:`https://bedrock-runtime.<区域>.amazonaws.com`。", + "Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`). For Bedrock, the region's runtime endpoint: `https://bedrock-runtime..amazonaws.com`. No backslashes, and no user name or password before the host (credentials go in `key` or `headers`).", + "接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。Bedrock 写所在区域的推理地址:`https://bedrock-runtime.<区域>.amazonaws.com`。地址中不能有反斜杠,主机前不能写用户名或密码(凭据写在 `key` 或 `headers` 中)。", ), ), row( diff --git a/crates/tw-control/src/backups.rs b/crates/tw-control/src/backups.rs index 6aa48518..715e9698 100644 --- a/crates/tw-control/src/backups.rs +++ b/crates/tw-control/src/backups.rs @@ -496,8 +496,17 @@ fn import_fail(e: tw_pack::import::ImportError, pack: &Pack) -> Fail { } } +fn flag_view(f: tw_pack::import::Flag) -> tw_api::ImportFlag { + use tw_pack::import::Flag; + match f { + Flag::MissingKey => tw_api::ImportFlag::MissingKey, + Flag::NeedsLogin => tw_api::ImportFlag::NeedsLogin, + Flag::PluginDisabled => tw_api::ImportFlag::PluginDisabled, + } +} + pub(crate) fn row_view(r: tw_pack::import::Row) -> tw_api::ImportRow { - use tw_pack::import::{Flag, Status}; + use tw_pack::import::Status; tw_api::ImportRow { item: item_ref(&r.item), status: match r.status { @@ -507,17 +516,10 @@ pub(crate) fn row_view(r: tw_pack::import::Row) -> tw_api::ImportRow { 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(), + flags: r.flags.into_iter().map(flag_view).collect(), differs: r.differs, suggested_name: r.suggested_name, + rename_flags: r.rename_flags.into_iter().map(flag_view).collect(), error: r.error, refs: r.refs.iter().map(item_ref).collect(), } diff --git a/crates/tw-control/tests/backups.rs b/crates/tw-control/tests/backups.rs index 837da99e..39f480f4 100644 --- a/crates/tw-control/tests/backups.rs +++ b/crates/tw-control/tests/backups.rs @@ -1068,3 +1068,31 @@ async fn a_plugin_put_back_at_startup_runs_again() { mgr.record_startup(); assert_eq!(tw_pack::backup::list(&p).unwrap().len(), before); } + +/// 导入预览的每一行带着改名导入时的标记:不带密钥的包里,改名导入的上游缺着密钥进来 +#[tokio::test] +async fn an_import_row_says_what_a_rename_would_bring() { + let src = bed(&theirs()); + let file = export_all(&src, false).await; + 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 rows = preview["rows"].as_array().unwrap(); + let official = rows + .iter() + .find(|r| r["item"]["kind"] == "provider" && r["item"]["name"] == "官方") + .unwrap(); + assert_eq!(official["status"], "conflict", "{official}"); + // 只换了路径:覆盖沿用本机的密钥,改名导入不沿用 + assert_eq!(official["flags"], serde_json::json!([])); + assert_eq!(official["rename_flags"], serde_json::json!(["missing_key"])); + let relay = rows.iter().find(|r| r["item"]["name"] == "relay").unwrap(); + assert_eq!(relay["status"], "new"); + assert_eq!(relay["rename_flags"], serde_json::json!([])); +} diff --git a/crates/tw-pack/Cargo.toml b/crates/tw-pack/Cargo.toml index ba3b604b..3e649fe4 100644 --- a/crates/tw-pack/Cargo.toml +++ b/crates/tw-pack/Cargo.toml @@ -29,6 +29,8 @@ blake3 = { workspace = true } sha2 = { workspace = true } age = { workspace = true } zip = { workspace = true } +# 比较上游地址发去哪儿:和 reqwest 同一个解析器,两边对同一个地址的读法一样 +url = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/tw-pack/src/export.rs b/crates/tw-pack/src/export.rs index fffad476..0f973cf7 100644 --- a/crates/tw-pack/src/export.rs +++ b/crates/tw-pack/src/export.rs @@ -167,11 +167,11 @@ pub fn strip_secrets(snap: &mut Snapshot) { /// AWS 访问密钥、OAuth 的客户端密钥,代理的密码,网关密钥的值。比较和覆盖都照这个样子:除了 /// 密钥都一样的就是「相同」,覆盖不会把本机的密钥抹成空的。 /// -/// **只在目的地不变时**([`same_destination`]):包里的上游换了主机、端口、AWS 区域或 OAuth -/// 令牌端点,代理换了地址,本机的密钥就不跟过去 —— 否则一份不带密钥的包改个地址,覆盖之后 -/// 本机的密钥就发给了包里写的那个地方 -pub fn fill_secrets(id: &ItemId, item: &mut model::Item, mine: &model::Item) { - if !same_destination(id, item, mine) { +/// **只在目的地不变时**([`same_destination`]):包里的上游换了主机、端口、AWS 区域、OAuth +/// 令牌端点或者走的出站代理,代理换了地址,本机的密钥就不跟过去 —— 否则一份不带密钥的包改个 +/// 地址,覆盖之后本机的密钥就发给了包里写的那个地方 +pub fn fill_secrets(id: &ItemId, item: &mut model::Item, mine: &model::Item, sides: Sides<'_>) { + if !same_destination(id, item, mine, sides) { return; } let empty = |v: Option<&Json>| v.and_then(Json::as_str) == Some(""); @@ -244,16 +244,24 @@ pub fn fill_secrets(id: &ItemId, item: &mut model::Item, mine: &model::Item) { } } -/// 两份同名的项目,密钥是发去同一个地方的吗:上游的协议、主机、端口一样,AWS 区域一样, -/// OAuth 的令牌端点一样;代理的类型和地址一样。网关密钥是发给这台网关自己的,总是 -pub fn same_destination(id: &ItemId, theirs: &model::Item, mine: &model::Item) -> bool { - let at = |it: &model::Item, u: &str, path: &[&str]| -> Option { - let mut cur = it.units.get(u)?; - for p in path { - cur = cur.get(*p)?; - } - Some(cur.clone()) - }; +/// 比较目的地时两边的全部项目:上游走的出站代理按名字在这里找。 +#[derive(Debug, Clone, Copy)] +pub struct Sides<'a> { + /// 包里的(导入时可能已经改过名)。包里没有的代理按本机的找:导入之后用的就是本机那个 + pub theirs: &'a Snapshot, + /// 本机的 + pub mine: &'a Snapshot, +} + +/// 两份同名的项目,密钥是发去同一个地方的吗:上游的协议、主机、端口一样([`destination`]), +/// AWS 区域一样,OAuth 的令牌端点一样,走的出站代理一样(直连、系统代理,或者类型和地址一样 +/// 的代理);代理的类型和地址一样。网关密钥是发给这台网关自己的,总是 +pub fn same_destination( + id: &ItemId, + theirs: &model::Item, + mine: &model::Item, + sides: Sides<'_>, +) -> bool { match id.kind { ItemKind::Provider => { let url = |it: &model::Item| { @@ -265,44 +273,57 @@ pub fn same_destination(id: &ItemId, theirs: &model::Item, mine: &model::Item) - == at(mine, unit::CONN, &["aws", "region"]) && at(theirs, unit::CONN, &["oauth", "endpoint"]) == at(mine, unit::CONN, &["oauth", "endpoint"]) + && route_of(theirs, &[sides.theirs, sides.mine]).is_some() + && route_of(theirs, &[sides.theirs, sides.mine]) == route_of(mine, &[sides.mine]) } - ItemKind::Proxy => { - let addr = |it: &model::Item| { - at(it, unit::ALL, &["addr"]) - .and_then(|v| v.as_str().map(|a| a.trim().to_ascii_lowercase())) - }; - addr(theirs).is_some() - && addr(theirs) == addr(mine) - && at(theirs, unit::ALL, &["type"]) == at(mine, unit::ALL, &["type"]) - } + ItemKind::Proxy => proxy_at(theirs).is_some() && proxy_at(theirs) == proxy_at(mine), _ => true, } } -/// 一个地址发去哪儿:协议、主机(小写)、端口(没写时按协议的缺省)。读不懂是 None +fn at(it: &model::Item, u: &str, path: &[&str]) -> Option { + let mut cur = it.units.get(u)?; + for p in path { + cur = cur.get(*p)?; + } + Some(cur.clone()) +} + +/// 一个代理连去哪儿:类型和地址(地址去掉空白、小写) +fn proxy_at(it: &model::Item) -> Option<(String, String)> { + let kind = at(it, unit::ALL, &["type"])?.as_str()?.to_string(); + let addr = at(it, unit::ALL, &["addr"])? + .as_str()? + .trim() + .to_ascii_lowercase(); + Some((kind, addr)) +} + +/// 一个上游发出去走哪条路:直连、系统代理,或者一个出站代理的类型和地址(按名字依次在 +/// `snaps` 里找)。找不到那个代理是 None +fn route_of(it: &model::Item, snaps: &[&Snapshot]) -> Option<(String, String)> { + let name = at(it, unit::CONN, &["proxy"]) + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_else(|| tw_config::DIRECT.to_string()); + if name == tw_config::DIRECT || name == tw_config::SYSTEM { + return Some((name, String::new())); + } + let id = ItemId::new(ItemKind::Proxy, name); + snaps + .iter() + .find_map(|s| s.items.get(&id)) + .and_then(proxy_at) +} + +/// 一个地址发去哪儿:协议、主机、端口(没写时按协议的缺省)。**和 reqwest 用同一个解析器** +/// (`url`,WHATWG 的规矩):主机是规范化之后的(小写、国际化域名转成 punycode、IPv6 和 IPv4 +/// 写成标准形式),反斜杠和网关发请求时一样当成路径的分隔。末尾的点不去掉:单个名字加不加点 +/// 解析到的可能不是同一台。读不懂是 None pub fn destination(url: &str) -> Option<(String, String, u16)> { - let (scheme, rest) = url.trim().split_once("://")?; - let scheme = scheme.to_ascii_lowercase(); - let authority = rest.split(['/', '?', '#']).next()?; - let hostport = authority.rsplit('@').next()?; - let (host, port) = if let Some(v6) = hostport.strip_prefix('[') { - let (h, after) = v6.split_once(']')?; - (h.to_string(), after.strip_prefix(':')) - } else { - match hostport.rsplit_once(':') { - Some((h, p)) => (h.to_string(), Some(p)), - None => (hostport.to_string(), None), - } - }; - let port = match port { - Some(p) => p.parse().ok()?, - None => match scheme.as_str() { - "https" => 443, - "http" => 80, - _ => return None, - }, - }; - (!host.is_empty()).then(|| (scheme, host.to_ascii_lowercase(), port)) + let u = url::Url::parse(url.trim()).ok()?; + let host = u.host_str()?.to_string(); + let port = u.port_or_known_default()?; + Some((u.scheme().to_string(), host, port)) } /// 一项里有没有被去掉的密钥(空字符串) @@ -362,26 +383,44 @@ fn civil(days: u64) -> (i64, u32, u32) { mod tests { use super::*; + /// 和 reqwest 读法一样:反斜杠是路径的分隔,主机规范化,缺省端口补上 #[test] fn a_destination_is_scheme_host_and_port() { let d = |u| destination(u); + let relay = Some(("https".to_string(), "relay.example".to_string(), 443)); + assert_eq!(d("https://Relay.EXAMPLE/v1"), relay); + assert_eq!(d("https://relay.example:443/x"), relay); + assert_eq!(d(" https://relay.example "), relay); + assert_ne!(d("https://relay.example:8443"), relay); + assert_ne!(d("http://relay.example"), relay); + assert_eq!(d("http://relay.example:80"), d("http://relay.example")); + // 反斜杠:连的是前面那一段,不是 @ 后面的 assert_eq!( - d("https://Relay.example/v1"), - Some(("https".into(), "relay.example".into(), 443)) + d("https://evil.example\\@relay.example/v1"), + Some(("https".into(), "evil.example".into(), 443)) ); + // 主机前的用户名和密码不算进主机 + assert_eq!(d("https://user:pw@relay.example/v1"), relay); + // 末尾的点是另一个名字 + assert_ne!(d("https://relay.example./v1"), relay); + // 国际化域名:写成 Unicode 和 punycode 是同一个 assert_eq!( - d("https://relay.example:443/x"), - d("https://relay.example/y") + d("https://bücher.example"), + d("https://xn--bcher-kva.example") ); - assert_ne!(d("https://relay.example:8443"), d("https://relay.example")); - assert_ne!(d("http://relay.example"), d("https://relay.example")); assert_eq!( - d("http://user:pw@[::1]:8080/p"), - Some(("http".into(), "::1".into(), 8080)) + d("https://BÜCHER.example").unwrap().1, + "xn--bcher-kva.example" ); + // IPv6、IPv4 写成标准形式 + assert_eq!( + d("http://[0:0::1]:8080/p"), + Some(("http".into(), "[::1]".into(), 8080)) + ); + assert_eq!(d("http://0x7f.1/"), d("http://127.0.0.1")); assert_eq!(d("not a url"), None); + assert_eq!(d("https://"), None); } - #[test] fn the_file_name_carries_the_date() { assert_eq!(file_name(0), "ThinkWatch-1970-01-01.twconfig"); diff --git a/crates/tw-pack/src/import.rs b/crates/tw-pack/src/import.rs index d61b6ea8..2ced8fb3 100644 --- a/crates/tw-pack/src/import.rs +++ b/crates/tw-pack/src/import.rs @@ -55,6 +55,9 @@ pub struct Row { /// 不同的合并单位 pub differs: Vec, pub suggested_name: Option, + /// 改名导入时这一项带的标记(`flags` 说的是导入、覆盖):改名导入的是另一个项目,**不沿用 + /// 本机的密钥和登录**,不带密钥的包里缺着的就缺着、上游停用。有 `suggested_name` 的行才有 + pub rename_flags: Vec, pub error: Option, /// 它引用的、包里也有的项目 pub refs: Vec, @@ -187,9 +190,14 @@ fn with_local_secrets(l: &Snapshot, mut i: Snapshot, incoming: &Pack) -> Snapsho if incoming.manifest.secrets { return i; } + let theirs = i.clone(); + let sides = export::Sides { + theirs: &theirs, + mine: l, + }; for (id, item) in i.items.iter_mut() { if let Some(mine) = l.items.get(id) { - export::fill_secrets(id, item, mine); + export::fill_secrets(id, item, mine, sides); } } i @@ -212,7 +220,8 @@ pub fn preview_with( codec: &dyn PluginCodec, ) -> Result, ImportError> { let l = model::snapshot_with(local, codec)?; - let i = with_local_secrets(&l, model::snapshot_with(incoming, codec)?, incoming); + let raw = model::snapshot_with(incoming, codec)?; + let i = with_local_secrets(&l, raw.clone(), incoming); let both = union(&l, &i); let mut rows = Vec::new(); for (id, item) in &i.items { @@ -227,6 +236,7 @@ pub fn preview_with( flags: flags(id, item, incoming, &l), differs: Vec::new(), suggested_name: None, + rename_flags: Vec::new(), error: None, refs: both.items[id] .refs @@ -243,6 +253,8 @@ pub fn preview_with( row.differs = differing(&mine.units, &item.units); if !global { row.suggested_name = Some(free_name(&both, id.kind, &suggest_base(id))); + // 改名导入时本机没有同名的那一项可沿用:照包里原样(没填回本机的密钥)算 + row.rename_flags = flags(id, &raw.items[id], incoming, &Snapshot::default()); } } } @@ -562,7 +574,15 @@ pub fn apply_with( && !matches!(d.action, Action::Rename(_)) && let Some(mine) = l.items.get(&id) { - export::fill_secrets(&id, &mut item, mine); + export::fill_secrets( + &id, + &mut item, + mine, + export::Sides { + theirs: &i, + mine: &l, + }, + ); } let item = &item; match &d.action { diff --git a/crates/tw-pack/src/restore.rs b/crates/tw-pack/src/restore.rs index 281002f6..288876c0 100644 --- a/crates/tw-pack/src/restore.rs +++ b/crates/tw-pack/src/restore.rs @@ -486,6 +486,18 @@ pub fn whole(backup: &Pack, current_text: &str, last_local: Option<&Pack>) -> Pa }; let source = doc(current_text).or_else(|| last_local.and_then(|p| doc(&p.config_yaml))); let mut text = backup.config_yaml.clone(); + // 那一版是同步位置上的(没有本机那几节):本机不同步的上游本来就不在里面 + let from_sync = backup.manifest.origin == crate::PackOrigin::Sync + || doc(&backup.config_yaml) + .is_some_and(|d| model::LOCAL_SECTIONS.iter().all(|k| d.get(*k).is_none())); + if from_sync { + let current = Pack { + config_yaml: current_text.to_string(), + ..backup.clone() + }; + let from: Vec<&Pack> = std::iter::once(¤t).chain(last_local).collect(); + text = bring_not_synced(&text, &from); + } // 两处都取不到(原文坏到读不成 YAML、也没有本机备份):留着那一版自己的 for section in source.iter().flat_map(|_| model::LOCAL_SECTIONS) { let v = source.as_ref().and_then(|d| d.get(*section)).cloned(); @@ -540,6 +552,107 @@ pub fn whole(backup: &Pack, current_text: &str, last_local: Option<&Pack>) -> Pa } } +/// 本机不同步的上游(`sync.exclude`),连同直接或间接引用它们的项目(路由、策略组、别名、 +/// 网关密钥)。和同步那一路的算法一样 +fn not_synced(names: &BTreeSet, snap: &Snapshot) -> BTreeSet { + let mut out: BTreeSet = names + .iter() + .map(|n| ItemId::new(ItemKind::Provider, n.clone())) + .collect(); + loop { + let before = out.len(); + for (id, it) in &snap.items { + if !id.kind.is_order() && it.refs.iter().any(|r| out.contains(r)) { + out.insert(id.clone()); + } + } + if out.len() == before { + return out; + } + } +} + +/// 整份恢复同步位置上的一个版本:本机不同步的上游和随之不同步的项目,那一版里没有的从 `from` +/// 里第一份读得成一份配置的(现在的原文,或者最近一个读得成的本机版本)原样取来,连同它们的 +/// 登录,接在各段的末尾。插件不取:它的文件和批准不在配置里 +fn bring_not_synced(text: &str, from: &[&Pack]) -> String { + use tw_config::edit::{self, Section}; + const CLIENTS: Section = Section { + path: &["clients"], + what: "gateway key", + key: "name", + }; + let Some((src, snap, raw)) = from.iter().find_map(|p| { + Some(( + tw_config::try_parse(&p.config_yaml).ok()?, + model::snapshot(p).ok()?, + edit::parse(&p.config_yaml).ok()?, + )) + }) else { + return text.to_string(); + }; + let names: BTreeSet = src + .sync + .as_ref() + .map(|s| s.exclude.iter().cloned().collect()) + .unwrap_or_default(); + if names.is_empty() { + return text.to_string(); + } + let mut out = text.to_string(); + // 先上游,再引用它们的 + let mut ids: Vec = not_synced(&names, &snap) + .into_iter() + .filter(|id| snap.items.contains_key(id)) + .collect(); + ids.sort_by_key(|id| match id.kind { + ItemKind::Provider => 0, + ItemKind::Group => 1, + ItemKind::Route => 2, + ItemKind::Alias => 3, + _ => 4, + }); + for id in ids { + let Ok(now) = edit::parse(&out) else { break }; + let section = match id.kind { + ItemKind::Provider => edit::PROVIDERS, + ItemKind::Group => edit::GROUPS, + ItemKind::Route => edit::ROUTES, + ItemKind::Key => CLIENTS, + ItemKind::Alias => { + let there = now + .get("aliases") + .and_then(|a| a.get(id.name.as_str())) + .is_some(); + if !there + && let Some(a) = src.aliases.find(&id.name) + && let Ok(t) = edit::upsert_alias(&out, None, a) + { + out = t; + } + continue; + } + _ => continue, + }; + if section.index_of(&now, &id.name).is_some() { + continue; + } + let entry = section.index_of(&raw, &id.name).and_then(|i| { + let mut cur = &raw; + for k in section.path { + cur = cur.get(*k)?; + } + cur.get(i)?.as_mapping().cloned() + }); + if let Some(m) = entry + && let Ok(t) = edit::upsert(&out, section, None, &m) + { + out = t; + } + } + out +} + /// YAML 坏到按路径找不到时,退一步在原文里找 `key: <64 个十六进制字符>` 那一行 fn raw_key_line(text: &str) -> Option { text.lines().find_map(|l| { diff --git a/crates/tw-pack/tests/regressions.rs b/crates/tw-pack/tests/regressions.rs index 44544fe3..f8e20166 100644 --- a/crates/tw-pack/tests/regressions.rs +++ b/crates/tw-pack/tests/regressions.rs @@ -513,3 +513,159 @@ fn a_partial_preview_is_what_the_restore_writes() { let out = tw_pack::restore::apply_opts(&now, &then, &opts).unwrap(); assert_eq!(out.rows, rows); } + +/// 一个两种读法对不上的地址:`https://evil.example\@relay.example/v1`,人读成 relay.example, +/// 网关用的 URL 库连的是 evil.example。比较目的地照网关的读法;配置本来就不收这样的地址 +#[test] +fn an_address_that_reads_two_ways_does_not_take_the_key_along() { + let tricky = "https://evil.example\\@relay.example/v1"; + assert_eq!( + tw_pack::export::destination(tricky).map(|d| d.1), + Some("evil.example".to_string()) + ); + let l = local(); + let relay = ItemId::new(ItemKind::Provider, "relay"); + for (url, code) in [ + (tricky, "config.base_url_backslash"), + ("https://u:p@relay.example/v1", "config.base_url_userinfo"), + ] { + let mut incoming = export(&l, false, PackOrigin::Export); + incoming.config_yaml = format!( + "version: 1\nproviders:\n - name: relay\n base_url: '{url}'\n key: ''\n proxy: px\n" + ); + let rows = tw_pack::import::preview(&l, &incoming, &ctx()).unwrap(); + let row = rows.iter().find(|r| r.item == relay).unwrap(); + assert_eq!(row.status, Status::Error, "{url}"); + assert_eq!(row.error.as_ref().unwrap().code, code, "{url}"); + assert!( + tw_pack::import::apply(&l, &incoming, &[decide(&relay, Action::Overwrite)], &ctx()) + .is_err() + ); + } +} + +/// 地址不变、换了出站代理:走的是另一条路,本机的密钥不跟过去。走的代理类型和地址一样 +/// (哪怕换了个名字)照样沿用 +#[test] +fn a_new_outbound_proxy_does_not_take_the_key_along() { + let here = pack(&format!( + "version: 1\nlisten:\n control:\n key: {KEY}\nclients:\n - name: default\n key: tw-local\nproxies:\n - name: px\n type: http\n addr: 10.0.0.1:8080\nproviders:\n - name: lan\n base_url: http://192.168.1.10:3000/v1\n key: sk-lan-secret\n proxy: px\n" + )); + let lan = ItemId::new(ItemKind::Provider, "lan"); + let with = |proxies: &str, proxy: &str| { + let mut p = export(&here, false, PackOrigin::Export); + p.config_yaml = format!( + "version: 1\n{proxies}providers:\n - name: lan\n base_url: http://192.168.1.10:3000/v1\n key: ''\n proxy: {proxy}\n" + ); + p + }; + let key_after = |incoming: &Pack| { + let rows = tw_pack::import::preview(&here, incoming, &ctx()).unwrap(); + let decisions: Vec = rows + .iter() + .map(|r| { + decide( + &r.item, + if r.status == Status::New { + Action::Import + } else { + Action::Overwrite + }, + ) + }) + .collect(); + let out = tw_pack::import::apply(&here, incoming, &decisions, &ctx()).unwrap(); + let cfg = tw_config::try_parse(&out.pack.config_yaml).unwrap(); + let p = cfg + .providers + .iter() + .find(|p| p.name == "lan") + .unwrap() + .clone(); + let flags = rows.iter().find(|r| r.item == lan).unwrap().flags.clone(); + (p.key.map(|k| k.raw().to_string()), p.disabled, flags) + }; + let gone = (Some(String::new()), true, vec![Flag::MissingKey]); + // 包里新带一个代理 + let evil = "proxies:\n - name: evilpx\n type: http\n addr: proxy.evil.example:8080\n"; + assert_eq!(key_after(&with(evil, "evilpx")), gone); + // 同名的代理换了地址 + let moved = "proxies:\n - name: px\n type: http\n addr: proxy.evil.example:8080\n"; + assert_eq!(key_after(&with(moved, "px")), gone); + // 改成直连、系统代理 + assert_eq!(key_after(&with("", "direct")), gone); + assert_eq!(key_after(&with("", "system")), gone); + // 同一个代理(包里没带它,用的是本机的);换了名字、类型和地址一样的 + let kept = (Some("sk-lan-secret".to_string()), false, Vec::::new()); + assert_eq!(key_after(&with("", "px")), kept); + let renamed = "proxies:\n - name: px2\n type: http\n addr: 10.0.0.1:8080\n"; + assert_eq!(key_after(&with(renamed, "px2")), kept); +} + +/// 改名导入:预览如实说改名之后缺密钥、停用(`rename_flags`),写下去也是这样 +#[test] +fn the_preview_says_what_a_rename_brings() { + let l = local(); + let mut incoming = export(&l, false, PackOrigin::Export); + incoming.config_yaml = "version: 1\nproxies:\n - name: px\n type: http\n addr: 10.0.0.1:8080\n auth: { user: u, pass: '' }\nproviders:\n - name: relay\n base_url: https://relay.example\n key: ''\n proxy: px\n headers:\n X-Token: ''\n max_concurrent: 2\n".into(); + let rows = tw_pack::import::preview(&l, &incoming, &ctx()).unwrap(); + let row = |kind, name: &str| { + rows.iter() + .find(|r| r.item == ItemId::new(kind, name)) + .unwrap() + }; + let relay = row(ItemKind::Provider, "relay"); + assert_eq!(relay.status, Status::Conflict); + assert!(relay.flags.is_empty(), "{:?}", relay.flags); + assert_eq!(relay.rename_flags, [Flag::MissingKey]); + // 相同的那一项没有改名可选 + let px = row(ItemKind::Proxy, "px"); + assert_eq!(px.status, Status::Same); + assert!(px.rename_flags.is_empty()); + let out = tw_pack::import::apply( + &l, + &incoming, + &[decide(&relay.item, Action::Rename("relay (2)".into()))], + &ctx(), + ) + .unwrap(); + let cfg = tw_config::try_parse(&out.pack.config_yaml).unwrap(); + let p = cfg + .providers + .iter() + .find(|p| p.name == "relay (2)") + .unwrap(); + assert_eq!(p.key.as_ref().map(|k| k.raw()), Some("")); + assert!(p.disabled); +} + +/// 安全模式下整份恢复同步位置上的版本:本机不同步的上游(和指向它的路由、绑着那条路由的 +/// 密钥)那一版里本来就没有,从最近一个读得成的本机版本里取回来 +#[test] +fn a_whole_restore_of_a_synced_version_keeps_what_this_machine_does_not_sync() { + let here = pack(&format!( + "version: 1\nlisten:\n control:\n key: {KEY}\nclients:\n - name: default\n key: tw-a\n - name: local-cli\n key: tw-l\n route: local\nproxies:\n - name: corp\n type: socks5h\n addr: 127.0.0.1:7890\nproviders:\n - name: a\n base_url: https://a.example\n key: sk-a\n - name: ollama\n base_url: http://127.0.0.1:11434\nroutes:\n - name: local\n rules:\n - name: all\n to: ollama\n{SYNC} exclude: [ollama]\n" + )); + tw_config::try_parse(&here.config_yaml).unwrap_or_else(|e| panic!("{e}\n{}", here.config_yaml)); + let mut synced = pack( + "version: 1\nclients:\n - name: default\n key: tw-a\nproviders:\n - name: a\n base_url: https://a2.example\n key: sk-a\n", + ); + synced.manifest.origin = PackOrigin::Sync; + // 现在这份读不成 YAML:本机那几节和不同步的上游都从最近一个本机版本里取 + let not_yaml = "listen: [\n :::\n"; + let out = tw_pack::restore::whole(&synced, not_yaml, Some(&here)); + let cfg = tw_config::try_parse(&out.config_yaml) + .unwrap_or_else(|e| panic!("{e}\n{}", out.config_yaml)); + let names: Vec<&str> = cfg.providers.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["a", "ollama"]); + assert_eq!(cfg.providers[0].base_url, "https://a2.example"); + assert!(cfg.routes.iter().any(|r| r.name == "local")); + assert!(cfg.clients.iter().any(|c| c.name == "local-cli")); + assert_eq!(cfg.sync.unwrap().exclude, ["ollama"]); + // 读得成 YAML、读不成一份配置:一样 + let broken = format!("{}not_a_field: 1\n", here.config_yaml); + let out = tw_pack::restore::whole(&synced, &broken, Some(&here)); + let cfg = tw_config::try_parse(&out.config_yaml) + .unwrap_or_else(|e| panic!("{e}\n{}", out.config_yaml)); + assert!(cfg.providers.iter().any(|p| p.name == "ollama")); +} diff --git a/docs/config.md b/docs/config.md index 071833a5..f0788e96 100644 --- a/docs/config.md +++ b/docs/config.md @@ -145,9 +145,9 @@ A pack carries no settings of this machine and no ChatGPT sign-in; without passphrase is asked for, or read from `TWCORE_PASSPHRASE`). Overwriting an item here with one from a pack without keys keeps this machine's keys only while the item still points at the same place (scheme, host and port; AWS -region; OAuth token endpoint; proxy address); otherwise, and for an item -imported under another name, the upstream comes in disabled until its key -is entered. +region; OAuth token endpoint; the outbound proxy it goes through; a proxy's +address); otherwise, and for an item imported under another name, the +upstream comes in disabled until its key is entered. 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 @@ -410,7 +410,7 @@ Upstreams: the APIs requests are forwarded to. | Field | Type | Default | Description | |---|---|---|---| | `name` | string | **required** | Name of the upstream; unique, and not the name of a group. Names starting with `__` are reserved. | -| `base_url` | string | **required** | Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`). For Bedrock, the region's runtime endpoint: `https://bedrock-runtime..amazonaws.com`. | +| `base_url` | string | **required** | Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`). For Bedrock, the region's runtime endpoint: `https://bedrock-runtime..amazonaws.com`. No backslashes, and no user name or password before the host (credentials go in `key` or `headers`). | | `key` | string, `${VAR}` allowed | — | API key. It goes in the header the protocol expects: `x-api-key` (Anthropic), `Authorization: Bearer` (OpenAI, and a Bedrock API key), `x-goog-api-key` (Gemini). Leave it out for upstreams without a key, or when the credential is written in `headers`. Cannot be combined with `oauth` or `aws`. | | `headers` | map of header name → value | `{}` | Additional request headers, in the order written; values may use `${VAR}`, and `{{access_token}}` where `oauth` is set. At most 32. Headers HTTP or the gateway manages (`host`, `content-length`, `connection`, …) cannot be set. | | `oauth` | object, [`providers[].oauth`](#cfg-providers-oauth) | — | OAuth credential: an access token obtained from a refresh token. Instead of `key`. | diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index aa16e5ac..751d2ed5 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -81,7 +81,7 @@ twcore import all.twconfig --dry-run # 导入会做什么 twcore config set /listen/gateway/port 8790 --int ``` -恢复时保留本机设置(监听、控制面密钥、保留期限、同步设置)、ChatGPT 的登录,以及本机已接管的客户端正在使用的网关密钥。导入时加入本机没有的项目,与本机不同的项目逐项选择处理方式(`--on-conflict` 一次指定全部)。配置包不含本机设置与 ChatGPT 登录;未指定 `--no-secrets` 时其中以明文保存密钥,除非指定 `--encrypt`(口令在终端输入,或取自环境变量 `TWCORE_PASSPHRASE`)。以不含密钥的配置包覆盖本机项目时,只有目的地不变(协议、主机与端口,AWS 区域,OAuth 令牌端点,代理地址)才沿用本机的密钥;目的地变了的、改名导入的上游以停用状态导入,填入密钥后再启用。 +恢复时保留本机设置(监听、控制面密钥、保留期限、同步设置)、ChatGPT 的登录,以及本机已接管的客户端正在使用的网关密钥。导入时加入本机没有的项目,与本机不同的项目逐项选择处理方式(`--on-conflict` 一次指定全部)。配置包不含本机设置与 ChatGPT 登录;未指定 `--no-secrets` 时其中以明文保存密钥,除非指定 `--encrypt`(口令在终端输入,或取自环境变量 `TWCORE_PASSPHRASE`)。以不含密钥的配置包覆盖本机项目时,只有目的地不变(协议、主机与端口,AWS 区域,OAuth 令牌端点,所走的出站代理,代理的地址)才沿用本机的密钥;目的地变了的、改名导入的上游以停用状态导入,填入密钥后再启用。 这些命令直接操作文件,因此 core 没有运行时也能使用,而那往往正是最需要恢复的时候;配置完全无法读取时,恢复把整个版本写回,控制面密钥保持现在的值。正在运行的 core 会像对待其他保存一样接收这些改动。 @@ -294,7 +294,7 @@ clients: | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| | `name` | 字符串 | **必填** | 上游的名字,不能重复,也不能和策略组同名。以 `__` 开头的名字保留给内置项。 | -| `base_url` | 字符串 | **必填** | 接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。Bedrock 写所在区域的推理地址:`https://bedrock-runtime.<区域>.amazonaws.com`。 | +| `base_url` | 字符串 | **必填** | 接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。Bedrock 写所在区域的推理地址:`https://bedrock-runtime.<区域>.amazonaws.com`。地址中不能有反斜杠,主机前不能写用户名或密码(凭据写在 `key` 或 `headers` 中)。 | | `key` | 字符串,可写 `${VAR}` | — | API 密钥,放进协议规定的请求头:`x-api-key`(Anthropic)、`Authorization: Bearer`(OpenAI,以及 Bedrock API Key)、`x-goog-api-key`(Gemini)。上游不需要密钥、或凭据写在 `headers` 里时不写。不能和 `oauth`、`aws` 同时写。 | | `headers` | 请求头名 → 值的映射 | `{}` | 额外的请求头,按书写顺序发送;值可以用 `${VAR}`,配置了 `oauth` 时可以用 `{{access_token}}`。最多 32 个。HTTP 或网关管理的请求头(`host`、`content-length`、`connection` 等)不能设置。 | | `oauth` | 对象,见 [`providers[].oauth`](#cfg-providers-oauth) | — | OAuth 凭据:用 refresh token 换取 access token。与 `key` 二选一。 |