From 26f97183c52735644be90ed054dbdaf017af367f Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:27:36 +0800 Subject: [PATCH] feat(api): expose the three security modes and each provider's redact set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/overview` didn't carry `security` at all, and `ProviderView` didn't carry `redact`. So the desktop UI could show neither — the only way to find out whether outbound redaction was on was to open config.yaml. That quietly broke §5.0's premise. All three defences ship parked in observe mode on purpose: the design is that a user sees evidence collected about their own traffic and then decides whether to switch to enforce. A switch nobody can reach makes the observe default a permanent state rather than a starting one. - `SecurityView` carries all three modes plus the counts of added and disabled scan rules. The verbs differ per line — redact replaces, inspect_tools cuts the stream, scan_configs only warns — so the UI has to say what enforce means here rather than labelling all three "enforce". - `ProviderView.redact` gives the resolved set, like `trust` does, with an `explicit` flag beside it. Unset means empty for vendor endpoints and the four defaults elsewhere, and the UI has to distinguish "not written" from "written empty". `Mode::slug()` is new and separate from `label()`. `label()` is the Chinese shown to a human; `slug()` is what gets written back to YAML. Writing the label into config.yaml would fail the next load outright, because this layer deliberately refuses to silently fall back (§5.0: "thought I had enforce on, was only observing" is the worst state) — so it would surface as a startup failure the user can't interpret. The round-trip test is the point of that split: it deserializes every slug back and asserts it lands on the same mode. It isn't testing a getter, it's pinning the contract between the UI and the loader. 1060 tests, 42 smoke checks, clippy clean. Co-Authored-By: Claude Opus 5 --- crates/tw-api/src/lib.rs | 33 ++++++++++++++++++++++++++++++++ crates/tw-config/src/security.rs | 32 +++++++++++++++++++++++++++++++ crates/tw-control/src/lib.rs | 16 ++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index e1ffc602..a2f5d9ba 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -341,6 +341,30 @@ pub struct Overview { pub groups: Vec, pub clients: Vec, pub listen: ListenView, + /// 三条防线各自的状态(§5.0)。 + /// + /// **界面要能配它们,而不只是显示。**在此之前这三个字段根本没出现 + /// 在这个视图里,于是「脱敏开没开」只能去翻 config.yaml —— 而 §5.0 + /// 的整个设计前提是「出厂停在观察态,用户看到证据之后自己决定要不 + /// 要切到拦截」,一个切不了的开关让那个设计不成立。 + #[serde(default)] + pub security: SecurityView, +} + +/// 三条防线。每条三态,而**「拦截」在每条上做的事不一样**,所以动词也 +/// 一起给出来 —— 界面上统一叫「拦截」的话,用户点下去并不知道会发生 +/// 什么(§5.0)。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SecurityView { + /// 出站脱敏。拦截态 = 替换成占位符 + pub redact: String, + /// 入站审查。拦截态 = 切断响应流 + pub inspect_tools: String, + /// 配置面扫描。拦截态 = 告警(它本来就不删东西,§5.3) + pub scan_configs: String, + /// 用户加了几条自定义扫描规则、停用了几条内置的(§5.3) + pub scan_rules_added: usize, + pub scan_rules_disabled: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -367,6 +391,15 @@ pub struct ProviderView { /// 以为自己改不动它。 #[serde(default)] pub trust_explicit: bool, + /// 这家上游实际会脱哪几类(§5.1)。 + /// + /// 给的是**判完的结果**,不是配置里写的那几个字 —— 不写的话官方端点 + /// 是空的、其余是那四类默认。界面上要能看出「没写」和「写了空」的 + /// 区别,所以另给一个 explicit 标志。 + #[serde(default)] + pub redact: Vec, + #[serde(default)] + pub redact_explicit: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/tw-config/src/security.rs b/crates/tw-config/src/security.rs index feef28c7..c047d7bb 100644 --- a/crates/tw-config/src/security.rs +++ b/crates/tw-config/src/security.rs @@ -39,6 +39,20 @@ impl Mode { Mode::Enforce => "拦截", } } + + /// 配置文件里写的那个词。 + /// + /// `label()` 是给人看的中文,**这个是写回 YAML 用的**。两者必须分开: + /// 界面上把「观察」原样写进 config.yaml 的话,下一次加载会因为 + /// 「不是合法取值」整份被拒 —— 而这一层刻意不做静默回落(§5.0), + /// 所以那是一次真的、用户看不懂的启动失败。 + pub fn slug(&self) -> &'static str { + match self { + Mode::Off => "off", + Mode::Observe => "observe", + Mode::Enforce => "enforce", + } + } } /// 用户自己加的一条扫描规则(§5.3)。 @@ -126,6 +140,24 @@ impl Security { mod tests { use super::*; + /// `slug()` 必须真的能被反序列化回来。 + /// + /// 界面要能改这三个开关,而它写回 config.yaml 的就是这个字符串。 + /// 写错一个词的后果不是「按默认值来」—— 这一层刻意不做静默回落 + /// (§5.0:「以为自己开了拦截,其实只在观察」是最糟的状态),所以 + /// 整份配置会被拒,表现成一次用户看不懂的启动失败。 + /// + /// 所以这条不是在测一个 getter,是在把界面和加载器之间那个约定钉住。 + #[test] + fn every_slug_round_trips_through_yaml() { + for m in [Mode::Off, Mode::Observe, Mode::Enforce] { + let yaml = format!("redact: {}\n", m.slug()); + let back: Security = serde_yaml_ng::from_str(&yaml) + .unwrap_or_else(|e| panic!("slug {:?} 读不回来:{e}", m.slug())); + assert_eq!(back.redact, m, "slug {:?} 解析成了别的档", m.slug()); + } + } + #[test] fn everything_ships_in_observe_mode() { // **这条测的是一个产品决定,不是一段逻辑。**默认值改成 Off 就 diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index 1094b601..5065bce7 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -214,6 +214,15 @@ async fn overview(State(s): State) -> Json { .label() .to_string(), trust_explicit: p.trust.is_some(), + // 和 trust 同一个理由:给判完的结果。不写的时候官方端点是 + // 空的、其余是那四类默认,而用户要看的是「这家实际脱哪几 + // 类」(§5.1) + redact: p + .effective_redact() + .iter() + .map(|k| k.slug().to_string()) + .collect(), + redact_explicit: p.redact.is_some(), }) .collect(), routes: engine @@ -253,6 +262,13 @@ async fn overview(State(s): State) -> Json { max_concurrent: c.max_concurrent, }) .collect(), + security: tw_api::SecurityView { + redact: cfg.security.redact.slug().to_string(), + inspect_tools: cfg.security.inspect_tools.slug().to_string(), + scan_configs: cfg.security.scan_configs.slug().to_string(), + scan_rules_added: cfg.security.scan_rules.add.len(), + scan_rules_disabled: cfg.security.scan_rules.disable.len(), + }, listen: tw_api::ListenView { bind: format!("{:?}", cfg.listen.gateway.bind).to_lowercase(), port: cfg.listen.gateway.port,