diff --git a/bin/twcore/src/main.rs b/bin/twcore/src/main.rs index 92bf20e..eecb44f 100644 --- a/bin/twcore/src/main.rs +++ b/bin/twcore/src/main.rs @@ -749,7 +749,32 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> Err(e) => tracing::warn!("a control key could not be added to the configuration: {e}"), } } - let cfg = tw_config::load(path).with_context(|| format!("loading {}", path.display()))?; + // **安全模式下配置读不了也要起控制面。**安全模式就是给「配置出了问题」准备的:用户要能 + // 看到哪一行错了、在界面里改、回滚、一键修复。以前这一步读不了就退出,安全模式也一样 —— + // 守护连败五次进安全模式,安全模式的 core 又退出,界面上只剩一句「已停止」,原因只在日志里。 + // + // 顶上的是一份临时的空配置,只有控制面的钥匙取自原文(桌面端从同一个文件读它)。原文连 + // 钥匙都找不到(YAML 坏到解析不了)就照旧退出:那时起了控制面也没人进得来 + let (cfg, stood_in) = match tw_config::load(path) { + Ok(cfg) => (cfg, None), + Err(e) => { + let standin = safe + .then(|| std::fs::read_to_string(path).ok()) + .flatten() + .and_then(|text| tw_config::stand_in(&text)); + match standin { + Some((cfg, r)) => { + tracing::error!( + "safe mode: the configuration does not load ({r}); serving the control plane with a stand-in" + ); + (cfg, Some(r)) + } + None => { + return Err(e).with_context(|| format!("loading {}", path.display())); + } + } + } + }; // `--port` 是一个**显式的覆盖**,配置文件不该推翻它。所以给了它 // 之后就不再跟着配置里的监听地址走(「温」那一级)。 let overridden = port.is_some(); @@ -841,11 +866,11 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> } // 配置的唯一入口。UI、CLI、文件监听都从这里进。 - let manager = std::sync::Arc::new(tw_control::ConfigManager::new( - config_path, - state.clone(), - state.bus.clone(), - )); + let manager = tw_control::ConfigManager::new(config_path, state.clone(), state.bus.clone()); + let manager = std::sync::Arc::new(match &stood_in { + Some(r) => manager.standing_in(r), + None => manager, + }); // 默认插件(随 core 发的那几个):没给过的装上(停用着),没动过的换成新版。 // 启动时在控制面起来之前走一遍,界面第一次取插件就看得到它们;之后每换入一份 // 配置再走一遍。**不挡启动**:哪个没办成只记一行、说一声。安全模式不走 —— diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index 0b46fc1..1821437 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -86,8 +86,10 @@ config.secret.unterminated config.store.conflict config.store.missing config.store.read_failed +config.unknown_field config.unknown_price_sheet config.unknown_rule +config.unknown_variant config.unparsable passthrough config.zero_concurrency control.account_service_not_json @@ -106,6 +108,7 @@ control.chatgpt_login.expired control.chatgpt_login.no_code control.chatgpt_login.page_error control.client_unknown +control.config_not_repairable control.config_stale control.control_key_locked control.default_key_cannot_delete diff --git a/crates/tw-api/src/ep.rs b/crates/tw-api/src/ep.rs index 7e9dd81..1f47347 100644 --- a/crates/tw-api/src/ep.rs +++ b/crates/tw-api/src/ep.rs @@ -35,6 +35,10 @@ endpoints! { ConfigHistory: GET "/config/history", () => Vec; ConfigAt: GET "/config/at", api::ConfigAtQuery => api::ConfigAt; ConfigRollback: POST "/config/rollback", api::RollbackRequest => api::ConfigWritten; + /// 配置读不进来时,一键修复会改哪几处 + ConfigRepairPlan: GET "/config/repair", () => api::ConfigRepair; + /// 照那几处修好写回:取值改回默认值、删掉不认识的字段 + RepairConfig: POST "/config/repair", api::ConfigRepairRequest => api::ConfigWritten; SaveListen: PUT "/listen", api::ListenSave => api::ConfigWritten; // ─────────────────────────────────────────────── 用量与记录 diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index 91dc9b4..db0e472 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -405,6 +405,16 @@ slug_enum! { } } +slug_enum! { + /// 一键修复改的是哪一种错。 + pub enum ConfigFixKind { + /// 取值不在可选范围里:删掉这一行,回到默认值 + UnknownValue = "unknown_value", + /// 不认识的字段:删掉 + UnknownField = "unknown_field", + } +} + slug_enum! { /// 路由规则 `when` 里的键。 pub enum ConditionField { @@ -721,7 +731,14 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// [`DryRunOutcome`] 删了 `passthrough`:转发的那几类照常求值规则。[`RouteSave`] 的 /// `route_probes` 删了,消息码 `control.unknown_probe_class` 跟着删。配置里 /// `client_probes` 的取值同样只剩 `intercept` / `forward`。 -pub const CONTROL_API_VERSION: u32 = 36; +/// +/// **37 起配置读不进来时也有路可走**:安全模式下 core 读不了配置文件,照样起控制面(只用 +/// 文件里的控制面钥匙,其余是临时的空配置),`Status.config_rejected` 从一开始就说哪一行错了。 +/// 新端点 `GET /config/repair`([`ConfigRepair`])给出一键修复会改的几处,`POST /config/repair` +/// ([`ConfigRepairRequest`] → [`ConfigWritten`])照着修好写回。取值不在可选范围里、字段不认识 +/// 这两种字段错有了自己的码:`config.unknown_variant`、`config.unknown_field`(以前是 +/// `config.unparsable` 里的一句英文原话);修不了时 `control.config_not_repairable`。 +pub const CONTROL_API_VERSION: u32 = 37; #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] @@ -2622,6 +2639,40 @@ pub struct ConfigWritten { pub version: String, } +/// 配置读不进来时,一键修复会改哪几处(`GET /config/repair`)。 +/// +/// **只修两种,都是删掉一个键**:取值不在可选范围里的(回到默认值)、不认识的字段。修完 +/// 整份配置读得进来才给;修不了、或者本来就读得进来,`fixes` 是空的。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ConfigRepair { + /// 按磁盘上哪一版算的。修的时候带回来(`POST /config/repair`):文件变了就不修 + pub base_version: String, + pub fixes: Vec, +} + +/// 一键修复要改的一处。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ConfigFix { + pub kind: ConfigFixKind, + /// 字段的路径:`client_probes.titling`、`providers[0].protocol` + pub field: String, + /// 修之前的原文里是第几行,1 起 + pub line: Option, + /// 原来写着的值,**已脱敏**。不认识的字段的值不是一个标量时没有 + pub value: Option, + /// 修完之后的值,也就是默认值。不认识的字段、或者默认值写不成一个标量时没有 + pub now: Option, +} + +/// 照 [`ConfigRepair`] 修好并写回。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ConfigRepairRequest { + pub base_version: String, +} + // ─────────────────────────────────────────────── 上游与代理的增删改 /// 新建或修改一个上游时交过来的定义。 @@ -5237,6 +5288,11 @@ mod tests { ); check(ProbeClass::ALL, ProbeClass::slug, ProbeClass::from_slug); check(ProbeMode::ALL, ProbeMode::slug, ProbeMode::from_slug); + check( + ConfigFixKind::ALL, + ConfigFixKind::slug, + ConfigFixKind::from_slug, + ); check( ConditionField::ALL, ConditionField::slug, diff --git a/crates/tw-config/src/lib.rs b/crates/tw-config/src/lib.rs index ac38845..56323bc 100644 --- a/crates/tw-config/src/lib.rs +++ b/crates/tw-config/src/lib.rs @@ -22,6 +22,7 @@ pub mod proxy; pub mod refs; pub mod reload; pub mod remote; +pub mod repair; mod retention; mod security; pub mod store; @@ -1156,7 +1157,7 @@ pub fn write(path: &Path, cfg: &Config) -> Result<(), WriteError> { pub use failover::{Failover, MAX_PAUSE_SECS, MAX_STREAM_START_WAIT_SECS}; pub use probes::{ClientProbes, ProbeAction}; -pub use reload::{Rejected, Stage, try_parse}; +pub use reload::{Rejected, Stage, stand_in, try_parse}; pub use retention::Retention; pub use security::{ ContentAction, ContentMatch, ContentPolicy, CustomContentRule, CustomRedactRule, diff --git a/crates/tw-config/src/reload.rs b/crates/tw-config/src/reload.rs index 6f24e1a..a875efd 100644 --- a/crates/tw-config/src/reload.rs +++ b/crates/tw-config/src/reload.rs @@ -128,7 +128,10 @@ pub fn try_parse(text: &str) -> Result { } else { Stage::Syntax }, - message: Box::new(msg!(UNPARSABLE, detail = e => "{detail}")), + message: Box::new( + field_msg(&e.to_string()) + .unwrap_or_else(|| msg!(UNPARSABLE, detail = e => "{detail}")), + ), line, column: loc.as_ref().map(|l| l.column()), excerpt: line.and_then(|l| excerpt_of(text, l)), @@ -149,6 +152,76 @@ pub fn try_parse(text: &str) -> Result { Ok(cfg) } +/// 安全模式下磁盘上那份读不了时,core 临时顶上的配置,连同读不了的原因。 +/// +/// **只有控制面的钥匙取自原文**:桌面端从同一个文件读它来连控制面,两边对不上就连不上, +/// 用户也就看不到错在哪一行。其余全是默认值 —— 安全模式不起数据面,用不着上游和密钥。 +/// 原文读得进来(不需要顶)、或者连钥匙都找不到(YAML 坏到解析不了,起了控制面也没人 +/// 进得来)都是 `None` +pub fn stand_in(text: &str) -> Option<(Config, Rejected)> { + let r = try_parse(text).err()?; + let key = crate::control_key::raw_in(text).ok().flatten()?; + let cfg = Config { + listen: crate::Listen { + control: crate::ControlListen { + key: Some(key), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + Some((cfg, r)) +} + +/// serde 最常见的两种字段错,换成带码、带参数的一句话:取值不在可选范围里、字段名不认识。 +/// +/// **原话是英文,而这两种恰恰最常见**:手改配置写错一个取值、拼错一个字段名,界面上就只有 +/// 一句「unknown variant `passthrough`, expected `intercept` or `forward`」。拆出字段、写下的 +/// 值和可选的几个,界面就能说成自己的话。认不出的照旧走 [`UNPARSABLE`]。 +/// +/// serde_yaml 的原话形如 ``client_probes.titling: unknown variant `passthrough`, expected +/// `intercept` or `forward` at line 48 column 12``:冒号前是字段的路径(顶层没有),末尾是 +/// 位置(行号另有字段,这里去掉)。 +fn field_msg(e: &str) -> Option { + let e = e.split(" at line ").next()?; + let (path, rest) = match e.find(": unknown ") { + Some(i) => (&e[..i], &e[i + 2..]), + None => ("", e), + }; + if let Some(r) = rest.strip_prefix("unknown variant `") { + let (value, after) = r.split_once('`')?; + let expected = quoted(after.split_once("expected ")?.1)?; + if path.is_empty() { + return None; + } + return Some(msg!( + "config.unknown_variant", field = path, value = value, expected = expected => + "{field} cannot be {value}; expected one of: {expected}" + )); + } + if let Some(r) = rest.strip_prefix("unknown field `") { + let (name, after) = r.split_once('`')?; + let expected = quoted(after.split_once("expected ")?.1)?; + let field = if path.is_empty() { + name.to_string() + } else { + format!("{path}.{name}") + }; + return Some(msg!( + "config.unknown_field", field = field, expected = expected => + "{field} is not a known field; known fields: {expected}" + )); + } + None +} + +/// 「`a`, `b` or `c`」里反引号括起来的那几个,用逗号连起来。一个都没有是 `None` +fn quoted(s: &str) -> Option { + let all: Vec<&str> = s.split('`').skip(1).step_by(2).collect(); + (!all.is_empty()).then(|| all.join(", ")) +} + fn is_field_error(m: &str) -> bool { m.contains("unknown field") || m.contains("missing field") @@ -237,6 +310,54 @@ mod tests { assert_eq!(r.line, Some(4), "{r:?}"); } + #[test] + fn a_config_that_does_not_load_is_stood_in_for_with_its_own_control_key() { + let bad = format!("{GOOD}client_probes:\n titling: passthrough\n"); + let (cfg, r) = stand_in(&bad).expect("钥匙在,就该顶得上"); + assert_eq!( + cfg.listen.control.key.as_deref(), + Some("c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00") + ); + assert!(cfg.providers.is_empty() && cfg.clients.is_empty()); + assert_eq!(r.line, Some(9)); + // 读得进来的不用顶;连钥匙都找不到的顶了也没人进得来 + assert!(stand_in(GOOD).is_none()); + assert!(stand_in("version: 1\nclients: [\n").is_none()); + } + + #[test] + fn a_value_outside_its_choices_names_the_field_the_value_and_the_choices() { + // 手改配置最常见的一种错:写了一个已经不存在的取值。界面要能翻译这句话 + let bad = format!("{GOOD}client_probes:\n titling: passthrough\n"); + let r = try_parse(&bad).unwrap_err(); + assert_eq!(r.stage, Stage::Schema, "{r:?}"); + assert_eq!(r.message.code, "config.unknown_variant", "{r:?}"); + let args = &r.message.args; + assert_eq!(args["field"], "client_probes.titling"); + assert_eq!(args["value"], "passthrough"); + assert_eq!(args["expected"], "intercept, forward"); + assert_eq!(r.line, Some(9), "{r:?}"); + assert_eq!(r.excerpt.as_deref(), Some(" titling: passthrough")); + } + + #[test] + fn a_misspelled_field_says_where_and_what_is_known() { + let r = try_parse("version: 1\nclients:\n - name: c\n kye: tw-k\n").unwrap_err(); + assert_eq!(r.message.code, "config.unknown_field", "{r:?}"); + let args = &r.message.args; + assert_eq!(args["field"], "clients[0].kye"); + assert!(args["expected"].contains("key"), "{r:?}"); + // 带码的这两种直接就是那句话,不再垫一句「第几行有字段错误」:行号在 `line` 里 + assert_eq!(r.msg().code, "config.unknown_field"); + } + + #[test] + fn other_serde_errors_keep_the_original_sentence() { + assert!(field_msg("invalid type: string \"x\", expected u16 at line 3 column 9").is_none()); + // 顶层没有路径的取值错,说不出是哪个字段,也不拆 + assert!(field_msg("unknown variant `x`, expected `a` or `b`").is_none()); + } + #[test] fn a_semantic_error_has_no_line_number_because_there_is_no_honest_one() { // **编一个行号出来比不给更糟** —— 用户会盯着那一行看半天。 diff --git a/crates/tw-config/src/repair.rs b/crates/tw-config/src/repair.rs new file mode 100644 index 0000000..a7dfcab --- /dev/null +++ b/crates/tw-config/src/repair.rs @@ -0,0 +1,314 @@ +//! 配置读不进来时的一键修复。 +//! +//! **只修两种,而且都只是删掉一个键**:取值不在可选范围里的(删掉就回到默认值),不认识的 +//! 字段(删掉)。手改配置最常见的错就是这两种 —— 写了一个已经不存在的取值、拼错一个字段名 +//! —— 而它们的修法不需要猜用户想写什么。语法错(要看标点)、缺字段(没有默认值可回)、语义错 +//! (整份配置的事)都不碰。 +//! +//! **修完整份配置读得进来才算数**:删掉一处之后冒出别的错(比如那个字段没有默认值,删了就 +//! 成了缺字段),整个修复就不给。半修好的配置还是读不进来,给了只会让人以为好了。 +//! +//! 删是最小改动(`tw_yaml::remove_key`):注释、排版、别的键原样留着;删空了的父节点一起删。 + +use crate::{Config, Rejected, try_parse}; +use tw_yaml::Step; + +/// 一次修复要改的那几处,和改完的原文。 +#[derive(Debug, Clone, PartialEq)] +pub struct Repair { + pub text: String, + pub fixes: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Fix { + pub kind: FixKind, + /// 字段的路径,`client_probes.titling`、`providers[0].protocol` + pub field: String, + /// 在**修之前的原文**里是第几行,1 起 + pub line: Option, + /// 原来写着的值。不认识的字段是它整个的值(是一个标量的时候) + pub value: Option, + /// 修完之后这个字段的值,也就是默认值。不认识的字段、或者默认值写不成一个标量时没有 + pub now: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FixKind { + /// 取值不在可选范围里:删掉这一行,回到默认值 + UnknownValue, + /// 不认识的字段:删掉 + UnknownField, +} + +/// 一份配置最多改几处。**防的是改不完的情况**(删一处冒一处),正常的手改错误一两处就完了 +const MAX_FIXES: usize = 20; + +/// 算出这份原文的修复。读得进来(不用修)、或者修不了,都是 `None`。 +pub fn repair(text: &str) -> Option { + let mut out = text.to_string(); + let mut found = Vec::new(); + for _ in 0..MAX_FIXES { + let r = match try_parse(&out) { + Ok(cfg) => { + if found.is_empty() { + return None; + } + return Some(Repair { + fixes: found.into_iter().map(|f| finish(f, &cfg)).collect(), + text: out, + }); + } + Err(r) => r, + }; + let (kind, field) = fixable(&r)?; + let path = steps(&field)?; + // 行号和原值都按**修之前的原文**找:前面删过行,`r.line` 已经对不上了 + let before = tw_yaml::find(text, &path).ok(); + let line = before + .as_ref() + .map(|f| text[..f.bytes.start].matches('\n').count() + 1) + .or(r.line); + let value = before.map(|f| masked(&path, &f.value)); + out = tw_yaml::remove_key(&out, &path).ok()?; + found.push(Fix { + kind, + field, + line, + value, + now: None, + }); + } + None +} + +/// 原值给人看之前脱敏,和出错那一行的原文同一套(`reload::excerpt_of`):拼错名字的那个 +/// 字段完全可能就是一把密钥。**连着键名一起判断**(`mask_line` 看键名也看值的形状), +/// 判完再把值取回来 +fn masked(path: &[Step], v: &str) -> String { + let name = match path.last() { + Some(Step::Key(k)) => k.as_str(), + _ => "value", + }; + let line = tw_secret::mask_line(&crate::control_key::mask_hex_runs(&format!("{name}: {v}"))); + match line.split_once(": ") { + Some((_, v)) => v.to_string(), + None => line, + } +} + +/// 这一处能不能修,能修的话是哪一种、哪个字段 +fn fixable(r: &Rejected) -> Option<(FixKind, String)> { + let kind = match r.message.code.as_str() { + "config.unknown_variant" => FixKind::UnknownValue, + "config.unknown_field" => FixKind::UnknownField, + _ => return None, + }; + Some((kind, r.message.args.get("field")?.clone())) +} + +/// 取值改回默认值的那几处,查出默认值是什么,给人看 +fn finish(mut f: Fix, cfg: &Config) -> Fix { + if f.kind == FixKind::UnknownValue { + f.now = steps(&f.field).and_then(|p| { + let (first, rest) = p.split_first()?; + let Step::Key(name) = first else { return None }; + at(§ion(cfg, name)?, rest) + }); + } + f +} + +/// 顶层的一节,**单独序列化**。整份配置序列化时,还是默认值的那几节(`client_probes`、 +/// `security`……)整节不写 —— 改回默认值的恰恰是它们,从整份里查不到 +fn section(cfg: &Config, name: &str) -> Option { + use serde_yaml_ng::to_value; + match name { + "listen" => to_value(&cfg.listen), + "clients" => to_value(&cfg.clients), + "providers" => to_value(&cfg.providers), + "proxies" => to_value(&cfg.proxies), + "pricing" => to_value(&cfg.pricing), + "client_probes" => to_value(&cfg.client_probes), + "security" => to_value(&cfg.security), + "retention" => to_value(&cfg.retention), + "failover" => to_value(&cfg.failover), + "groups" => to_value(&cfg.groups), + "routes" => to_value(&cfg.routes), + "plugins" => to_value(&cfg.plugins), + _ => return None, + } + .ok() +} + +fn at(v: &serde_yaml_ng::Value, path: &[Step]) -> Option { + let mut cur = v; + for s in path { + cur = match s { + Step::Key(k) => cur.get(k.as_str())?, + Step::Index(i) => cur.get(*i)?, + }; + } + match cur { + serde_yaml_ng::Value::String(s) => Some(s.clone()), + serde_yaml_ng::Value::Bool(b) => Some(b.to_string()), + serde_yaml_ng::Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +/// `providers[0].protocol` → 键、下标、键。**认不出就是 `None`**(键里带点、方括号的那种), +/// 那一处就不修 —— 删错一个键比不修糟得多 +fn steps(field: &str) -> Option> { + let mut out = Vec::new(); + for part in field.split('.') { + let (key, rest) = match part.find('[') { + Some(i) => (&part[..i], &part[i..]), + None => (part, ""), + }; + if key.is_empty() || key.contains(']') { + return None; + } + out.push(Step::key(key)); + let mut rest = rest; + while !rest.is_empty() { + let inner = rest.strip_prefix('[')?; + let (n, after) = inner.split_once(']')?; + out.push(Step::Index(n.parse().ok()?)); + rest = after; + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GOOD: &str = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: tw-k\n"; + + #[test] + fn a_value_outside_its_choices_goes_back_to_the_default() { + let bad = + format!("{GOOD}client_probes:\n health_check: intercept\n titling: passthrough\n"); + let r = repair(&bad).expect("该修得了"); + assert_eq!( + r.fixes, + vec![Fix { + kind: FixKind::UnknownValue, + field: "client_probes.titling".into(), + line: Some(10), + value: Some("passthrough".into()), + now: Some("forward".into()), + }] + ); + // 只删那一行,别的原样留着 + assert_eq!( + r.text, + format!("{GOOD}client_probes:\n health_check: intercept\n") + ); + assert!(try_parse(&r.text).is_ok()); + } + + #[test] + fn an_emptied_section_goes_with_its_last_key() { + // 剩一个空的 `client_probes:` 读回来是 null —— 对一个结构体字段是解析错误 + let bad = format!("{GOOD}client_probes:\n titling: route\n"); + let r = repair(&bad).unwrap(); + assert_eq!(r.text, GOOD); + } + + #[test] + fn several_mistakes_are_fixed_together_with_lines_from_the_original() { + let bad = format!( + "{GOOD}client_probes:\n titling: passthrough\n suggestion: route\nretention:\n body_dayz: 3\n" + ); + let r = repair(&bad).expect("该修得了"); + let got: Vec<_> = r + .fixes + .iter() + .map(|f| (f.kind, f.field.as_str(), f.line)) + .collect(); + assert_eq!( + got, + vec![ + (FixKind::UnknownValue, "client_probes.titling", Some(9)), + (FixKind::UnknownValue, "client_probes.suggestion", Some(10)), + (FixKind::UnknownField, "retention.body_dayz", Some(12)), + ] + ); + assert_eq!(r.fixes[2].value.as_deref(), Some("3")); + assert_eq!(r.fixes[2].now, None); + assert!(try_parse(&r.text).is_ok()); + } + + #[test] + fn a_misspelled_field_in_a_list_item_is_removed() { + let bad = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: tw-k\n colour: red\n"; + let r = repair(bad).unwrap(); + assert_eq!(r.fixes[0].field, "clients[0].colour"); + assert_eq!(r.text, GOOD); + } + + #[test] + fn comments_and_layout_are_kept() { + let bad = format!( + "# 我的配置\n{GOOD}client_probes:\n # 标题照常转发\n titling: passthrough # 旧写法\n warmup: intercept\n" + ); + let r = repair(&bad).unwrap(); + assert!(r.text.starts_with("# 我的配置\n"), "{}", r.text); + assert!(r.text.contains(" warmup: intercept\n"), "{}", r.text); + assert!(!r.text.contains("passthrough"), "{}", r.text); + } + + #[test] + fn nothing_to_fix_or_nothing_fixable_is_none() { + assert_eq!(repair(GOOD), None); + // 语法错:要看标点,不猜 + assert_eq!(repair("version: 1\nclients: [\n"), None); + // 语义错(两个同名上游):整份配置的事,删哪一处都是猜 + let dup = format!( + "{GOOD}providers:\n - name: a\n base_url: https://x\n - name: a\n base_url: https://y\n" + ); + assert_eq!(repair(&dup), None); + } + + #[test] + fn a_half_fixed_config_is_not_offered() { + // 删掉不认识的字段之后还剩一个语义错(两个同名上游):修完还是读不进来,就不给 + let bad = format!( + "{GOOD}providers:\n - name: a\n base_url: https://x\n colour: red\n - name: a\n base_url: https://y\n" + ); + assert_eq!(repair(&bad), None); + // 只有那一处的话修得好 + let ok = + format!("{GOOD}providers:\n - name: a\n base_url: https://x\n colour: red\n"); + let r = repair(&ok).unwrap(); + assert_eq!(r.fixes[0].field, "providers[0].colour"); + assert!(try_parse(&r.text).is_ok()); + } + + #[test] + fn a_secret_under_a_misspelled_name_is_not_shown() { + let bad = format!( + "{GOOD}providers:\n - name: a\n base_url: https://x\n api_kye: sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789\n" + ); + let r = repair(&bad).unwrap(); + let shown = r.fixes[0].value.as_deref().unwrap(); + assert!(!shown.contains("abcdefghijklmnopqrstuvwxyz"), "{shown}"); + } + + #[test] + fn paths_with_indices_are_understood_and_odd_ones_are_left_alone() { + assert_eq!( + steps("providers[0].protocol"), + Some(vec![ + Step::key("providers"), + Step::Index(0), + Step::key("protocol") + ]) + ); + assert_eq!(steps("a[x].b"), None); + assert_eq!(steps(""), None); + } +} diff --git a/crates/tw-control/src/config.rs b/crates/tw-control/src/config.rs index 71e8e44..d087966 100644 --- a/crates/tw-control/src/config.rs +++ b/crates/tw-control/src/config.rs @@ -121,6 +121,25 @@ impl ConfigManager { } } + /// 安全模式下磁盘上那份读不了,在服务的是 core 临时顶上的配置([`tw_config::stand_in`])。 + /// + /// **被拒那件事从一开始就挂着**(`Status.config_rejected`):界面连上来就说得出哪一行 + /// 错了。`seen` 清掉:在服务的不是文件里那份 —— 不清的话,把同样的内容再存一次会被当成 + /// 「改回了在服务的那一版」,被拒那件事就被抹掉了 + pub fn standing_in(mut self, r: &tw_config::Rejected) -> Self { + *self.seen.get_mut() = None; + if let Ok(g) = self.rejected.get_mut() { + *g = Some(tw_api::ConfigRejection { + stage: r.stage.into(), + message: (*r.message).clone(), + line: r.line, + excerpt: r.excerpt.clone(), + at_ms: now_ms(), + }); + } + self + } + /// 数据面。管理面里要碰插件文件、编插件的那几处从这里拿 pub fn gateway(&self) -> &tw_gateway::AppState { &self.gateway diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index a23e928..4da38f5 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -105,6 +105,8 @@ pub fn router(state: ControlState) -> Router { .at(ep::ConfigHistory, config_history) .at(ep::ConfigAt, config::path_at) .at(ep::ConfigRollback, config_rollback) + .at(ep::ConfigRepairPlan, config_repair_plan) + .at(ep::RepairConfig, repair_config) .at(ep::Summary, summary) .at(ep::CostBuckets, cost_buckets) .at(ep::CostBucketsBy, cost_buckets_by) @@ -1405,6 +1407,74 @@ async fn config_rollback( Ok(Json(tw_api::ConfigWritten { version })) } +/// 一键修复会改哪几处。修不了、或者本来就读得进来,`fixes` 是空的 +async fn config_repair_plan( + State(s): State, +) -> Result, Fail> { + let cur = s.cfg.current().map_err(unreadable_config)?; + let fixes = tw_config::repair::repair(&cur.text) + .map(|r| r.fixes.into_iter().map(fix_view).collect()) + .unwrap_or_default(); + Ok(Json(tw_api::ConfigRepair { + base_version: cur.version(), + fixes, + })) +} + +/// 照一键修复的那几处修好、写回。**按磁盘上现在那一份重新算**,不收界面交来的原文: +/// 界面手里的钥匙是打码的,而修的只是删掉几个键,用不着它交什么 +async fn repair_config( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let cur = s + .cfg + .current() + .map_err(|e| apply_fail(ApplyError::Store(e)))?; + if cur.version() != req.base_version { + return Err(apply_fail(ApplyError::Stale { + base: req.base_version, + current: cur.version(), + })); + } + let Some(r) = tw_config::repair::repair(&cur.text) else { + return Err(fail( + StatusCode::BAD_REQUEST, + msg!( + "control.config_not_repairable" => + "The configuration has no error that can be repaired automatically. Edit the \ + configuration file, or roll back to an earlier version" + ), + )); + }; + // 修只删键,插件那一节不会变;照样过一遍,和别的整份写入一条路 + plugins::guard_raw_write(&s, &cur.text, &r.text).await?; + let version = s + .cfg + .write( + &r.text, + Some(&cur.version()), + tw_config::history::Origin::Ui, + ) + .await + .map_err(apply_fail)?; + Ok(Json(tw_api::ConfigWritten { version })) +} + +fn fix_view(f: tw_config::repair::Fix) -> tw_api::ConfigFix { + use tw_config::repair::FixKind; + tw_api::ConfigFix { + kind: match f.kind { + FixKind::UnknownValue => tw_api::ConfigFixKind::UnknownValue, + FixKind::UnknownField => tw_api::ConfigFixKind::UnknownField, + }, + field: f.field, + line: f.line, + value: f.value, + now: f.now, + } +} + /// 控制面的错误响应。 /// /// **响应体是一个 JSON 的 [`Msg`],不是一句纯文本。**上一版发的是文本, diff --git a/crates/tw-control/tests/safe_mode.rs b/crates/tw-control/tests/safe_mode.rs new file mode 100644 index 0000000..6c4f009 --- /dev/null +++ b/crates/tw-control/tests/safe_mode.rs @@ -0,0 +1,189 @@ +//! 安全模式下配置文件读不了:core 顶上一份临时配置起控制面(`tw_config::stand_in`), +//! 界面连上来就看得到哪一行错了,能一键修复(`/config/repair`),修好的那一份换进来。 + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; +use tw_control::{ConfigManager, ControlState}; + +const GOOD: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 +clients: + - name: default + key: tw-aaaa +"; + +struct Bed { + dir: tempfile::TempDir, + app: axum::Router, + gw: tw_gateway::AppState, + mgr: Arc, +} + +impl Bed { + fn file(&self) -> String { + std::fs::read_to_string(self.dir.path().join("config.yaml")).unwrap() + } +} + +/// 照 `twcore serve --safe` 起控制面那样:读不了就顶上 +fn safe_mode(yaml: &str) -> Bed { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("config.yaml"); + std::fs::write(&p, yaml).unwrap(); + let (cfg, r) = tw_config::stand_in(yaml).expect("这份配置读不了、钥匙还在"); + let gw = tw_gateway::AppState::new(cfg).unwrap(); + let bus = gw.bus.clone(); + let mgr = Arc::new(ConfigManager::new(p, gw.clone(), bus).standing_in(&r)); + let state = ControlState { + shutdown: Default::default(), + remote: Default::default(), + cfg: mgr.clone(), + gateway: gw.clone(), + store: None, + started: std::time::Instant::now(), + price_updater: Default::default(), + chatgpt: Default::default(), + zai: Default::default(), + }; + Bed { + app: tw_control::router(state), + dir: d, + gw, + mgr, + } +} + +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 << 20).await.unwrap(); + let text = String::from_utf8_lossy(&b).to_string(); + ( + st, + serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text)), + ) +} + +#[tokio::test] +async fn the_status_says_which_line_is_wrong_from_the_start() { + // **界面半路才连上来**:被拒那件事是现状,不靠事件。安全模式一起来就挂着 + let b = safe_mode(&format!("{GOOD}client_probes:\n titling: passthrough\n")); + let (st, s) = call(&b.app, "GET", "/status", serde_json::Value::Null).await; + assert_eq!(st, StatusCode::OK, "{s}"); + let r = &s["config_rejected"]; + assert_eq!(r["stage"], "schema", "{s}"); + assert_eq!(r["line"], 9, "{s}"); + assert_eq!(r["excerpt"], " titling: passthrough", "{s}"); + assert_eq!(r["message"]["code"], "config.unknown_variant", "{s}"); + assert_eq!( + r["message"]["args"]["field"], "client_probes.titling", + "{s}" + ); +} + +#[tokio::test] +async fn one_click_repair_lists_the_fixes_then_writes_and_swaps_in() { + let b = safe_mode(&format!( + "{GOOD}client_probes:\n titling: passthrough\nretention:\n body_dayz: 3\n" + )); + let (st, plan) = call(&b.app, "GET", "/config/repair", serde_json::Value::Null).await; + assert_eq!(st, StatusCode::OK, "{plan}"); + let fixes = plan["fixes"].as_array().unwrap(); + assert_eq!(fixes.len(), 2, "{plan}"); + assert_eq!(fixes[0]["kind"], "unknown_value"); + assert_eq!(fixes[0]["value"], "passthrough"); + assert_eq!(fixes[0]["now"], "forward"); + assert_eq!(fixes[1]["kind"], "unknown_field"); + assert_eq!(fixes[1]["field"], "retention.body_dayz"); + + let (st, w) = call( + &b.app, + "POST", + "/config/repair", + serde_json::json!({ "base_version": plan["base_version"] }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{w}"); + assert_eq!(b.file(), GOOD, "只删那几处,别的原样"); + // 修好的那一份换进来了,被拒那件事也就过去了 + assert_eq!(b.gw.config().clients.len(), 1); + let (_, s) = call(&b.app, "GET", "/status", serde_json::Value::Null).await; + assert!(s.get("config_rejected").is_none_or(|v| v.is_null()), "{s}"); + // 没什么可修的了 + let (_, plan) = call(&b.app, "GET", "/config/repair", serde_json::Value::Null).await; + assert_eq!(plan["fixes"], serde_json::json!([])); +} + +#[tokio::test] +async fn a_repair_against_an_older_version_is_refused() { + let b = safe_mode(&format!("{GOOD}client_probes:\n titling: passthrough\n")); + let (st, _) = call( + &b.app, + "POST", + "/config/repair", + serde_json::json!({ "base_version": "blake3:000000000000" }), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); + assert!(b.file().contains("passthrough"), "文件变了就不修"); +} + +#[tokio::test] +async fn an_error_that_is_not_fixable_offers_nothing() { + // 两个同名上游:整份配置的事,删哪一处都是猜 + let b = safe_mode(&format!( + "{GOOD}providers:\n - name: a\n base_url: https://x\n - name: a\n base_url: https://y\n" + )); + let (_, plan) = call(&b.app, "GET", "/config/repair", serde_json::Value::Null).await; + assert_eq!(plan["fixes"], serde_json::json!([]), "{plan}"); + let (st, e) = call( + &b.app, + "POST", + "/config/repair", + serde_json::json!({ "base_version": plan["base_version"] }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{e}"); + assert_eq!(e["code"], "control.config_not_repairable", "{e}"); +} + +#[tokio::test] +async fn saving_the_same_broken_text_again_does_not_clear_the_rejection() { + // 在服务的是临时配置,不是文件里那份:同样的内容再写一次(编辑器里保存、touch) + // 不能被当成「改回了在服务的那一版」 + let bad = format!("{GOOD}client_probes:\n titling: passthrough\n"); + let b = safe_mode(&bad); + std::fs::write(b.dir.path().join("config.yaml"), &bad).unwrap(); + assert!( + b.mgr.reload_from_disk().await.is_err(), + "同样的坏内容还是读不进来" + ); + let (_, s) = call(&b.app, "GET", "/status", serde_json::Value::Null).await; + assert!(!s["config_rejected"].is_null(), "{s}"); + // 在文件里改好了(编辑器里保存),文件监听那一路换得进来 + std::fs::write(b.dir.path().join("config.yaml"), GOOD).unwrap(); + assert!(b.mgr.reload_from_disk().await.unwrap().is_some()); + let (_, s) = call(&b.app, "GET", "/status", serde_json::Value::Null).await; + assert!(s.get("config_rejected").is_none_or(|v| v.is_null()), "{s}"); +}