Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions bin/twcore/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,32 @@ fn cmd_serve(path: &Path, port: Option<u16>, safe: bool, parent: Option<u32>) ->
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();
Expand Down Expand Up @@ -841,11 +866,11 @@ fn cmd_serve(path: &Path, port: Option<u16>, safe: bool, parent: Option<u32>) ->
}

// 配置的唯一入口。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 发的那几个):没给过的装上(停用着),没动过的换成新版。
// 启动时在控制面起来之前走一遍,界面第一次取插件就看得到它们;之后每换入一份
// 配置再走一遍。**不挡启动**:哪个没办成只记一行、说一声。安全模式不走 ——
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-api/msg-codes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions crates/tw-api/src/ep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ endpoints! {
ConfigHistory: GET "/config/history", () => Vec<api::ConfigVersion>;
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;

// ─────────────────────────────────────────────── 用量与记录
Expand Down
58 changes: 57 additions & 1 deletion crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,16 @@ slug_enum! {
}
}

slug_enum! {
/// 一键修复改的是哪一种错。
pub enum ConfigFixKind {
/// 取值不在可选范围里:删掉这一行,回到默认值
UnknownValue = "unknown_value",
/// 不认识的字段:删掉
UnknownField = "unknown_field",
}
}

slug_enum! {
/// 路由规则 `when` 里的键。
pub enum ConditionField {
Expand Down Expand Up @@ -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))]
Expand Down Expand Up @@ -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<ConfigFix>,
}

/// 一键修复要改的一处。
#[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<usize>,
/// 原来写着的值,**已脱敏**。不认识的字段的值不是一个标量时没有
pub value: Option<String>,
/// 修完之后的值,也就是默认值。不认识的字段、或者默认值写不成一个标量时没有
pub now: Option<String>,
}

/// 照 [`ConfigRepair`] 修好并写回。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct ConfigRepairRequest {
pub base_version: String,
}

// ─────────────────────────────────────────────── 上游与代理的增删改

/// 新建或修改一个上游时交过来的定义。
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion crates/tw-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
123 changes: 122 additions & 1 deletion crates/tw-config/src/reload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,10 @@ pub fn try_parse(text: &str) -> Result<Config, Rejected> {
} 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)),
Expand All @@ -149,6 +152,76 @@ pub fn try_parse(text: &str) -> Result<Config, Rejected> {
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<Msg> {
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<String> {
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")
Expand Down Expand Up @@ -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() {
// **编一个行号出来比不给更糟** —— 用户会盯着那一行看半天。
Expand Down
Loading
Loading