From 588b05aa96f62133d8aac7ffee63404aa16f70ae Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:19:45 +0800 Subject: [PATCH] feat(control): expose what the configuration page cannot currently reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview carried enough to render a read-only page and not enough to build an editor. Four gaps, each of which made a whole section of the config unreachable from the UI. **Proxies were a list of names.** `proxies: Vec` — so the dropdown on an upstream could reference a proxy but nothing could create one, and anyone who had not hand-edited `config.yaml` saw a menu holding only `direct` and `system`. `ProxyView` carries name, kind, address, and how many upstreams use it, which is what you need before deleting one. **The password does not travel.** `ProxyAuth.pass` is the same class of thing as an upstream key, and this view reaches logs, diagnostic bundles and screenshots people paste into issues. `has_auth` says whether there is one; the test asserts neither the password nor the username appears in the serialised view. **`client_probes` had no representation at all**, and its absence was compounding: a routing rule's `when.intent` condition can only match when the corresponding probe class is set to `route`, so any rule written with `intent` was silently dead and nothing said why. The five classes now come through with their mode, along with a sentence each describing what that class of request is. That list lives on `ClientProbes` rather than in the frontend — adding a sixth class is this struct's business, and a list hard-coded in the UI silently loses a row that day. **`limits` was likewise invisible**, as were a key's `route`, its `allow`, and its concurrency ceiling. ## One real bug `listen.bind` was rendered with `format!("{:?}").to_lowercase()`. That happens to produce the right word for `Loopback` and `All`, and produces `addr(192.168.1.5)` for a concrete interface — which the UI then compares against its three choices and never matches. It uses `Display` now, which exists precisely for this. Co-Authored-By: Claude Opus 5 --- crates/tw-api/src/lib.rs | 64 ++++++++++++++++++++++++++++++- crates/tw-config/src/probes.rs | 52 +++++++++++++++++++++++++ crates/tw-config/src/proxy.rs | 11 ++++++ crates/tw-control/src/lib.rs | 70 +++++++++++++++++++++++++++++++++- 4 files changed, 193 insertions(+), 4 deletions(-) diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index 3390f1ae..3dec2051 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -333,10 +333,10 @@ impl Event { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Overview { pub providers: Vec, - /// 配置里定义过的代理名。**界面上换代理要从这里选** —— 让用户 + /// 配置里定义过的代理。**界面上换代理要从这里选** —— 让用户 /// 手打一个名字,打错了就是一次静默的「配了没生效」 #[serde(default)] - pub proxies: Vec, + pub proxies: Vec, pub routes: Vec, pub groups: Vec, pub clients: Vec, @@ -349,6 +349,60 @@ pub struct Overview { /// 要切到拦截」,一个切不了的开关让那个设计不成立。 #[serde(default)] pub security: SecurityView, + /// 没绑路由的密钥走哪条 + #[serde(default)] + pub default_route: String, + /// 客户端自己发的辅助请求怎么处理 + #[serde(default)] + pub client_probes: Vec, + /// 并发上限 + #[serde(default)] + pub limits: LimitsView, +} + +/// 一个出站代理。 +/// +/// **密码不在这里。**`ProxyAuth.pass` 和上游的 key 是同一类东西 —— +/// 这个视图会进日志、进诊断包、进用户贴出来的截图。有没有认证是要 +/// 显示的,认证内容不是。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyView { + pub name: String, + /// `http` / `socks5` + pub kind: String, + pub addr: String, + pub has_auth: bool, + /// 有几家上游在用它。删之前要知道 + pub used_by: usize, +} + +/// 一类客户端辅助请求的处置。 +/// +/// **这一段以前在界面上完全不存在,而它的缺席是连锁的**:路由条件 +/// `when.intent` 只有在对应那一类被配成 `route` 时才可能命中,所以 +/// 界面上那些写了 `intent` 的规则永远不会生效,而用户无从知道为什么。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProbeView { + /// `health_check` / `warmup` / `titling` / `topic_detect` / `suggestion` + pub id: String, + /// 中文名 + pub label: String, + /// 这一类是什么请求,一句话 + pub what: String, + /// `intercept` / `route` / `passthrough` + pub mode: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LimitsView { + /// 全局并发 + pub max_concurrent: usize, + /// 单个上游 + pub per_provider: usize, + /// 队列上限。满了才真的拒绝 + pub queue_depth: usize, + /// 排太久还是要放弃 + pub queue_timeout_secs: u64, } /// 三条防线。每条三态,而**「拦截」在每条上做的事不一样**,所以动词也 @@ -449,6 +503,12 @@ pub struct ClientView { /// 已脱敏 pub key: String, pub max_concurrent: Option, + /// 绑的那条路由。`None` = 走默认路由 + #[serde(default)] + pub route: Option, + /// 这把密钥能看到哪些模型。三态:不写 / 写非空 / 写 `[]`(一个都不给) + #[serde(default)] + pub allow: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/tw-config/src/probes.rs b/crates/tw-config/src/probes.rs index ad00e27f..9ad238e5 100644 --- a/crates/tw-config/src/probes.rs +++ b/crates/tw-config/src/probes.rs @@ -27,6 +27,58 @@ pub enum ProbeAction { /// 便宜的可换了。 Route, } +impl ProbeAction { + /// 写进 YAML 的那个词。**界面写回的是它,不是中文标签** —— + /// 写中文的话下一次加载会因为不是合法取值整份被拒。 + pub fn slug(&self) -> &'static str { + match self { + ProbeAction::Intercept => "intercept", + ProbeAction::Passthrough => "passthrough", + ProbeAction::Route => "route", + } + } +} + +impl ClientProbes { + /// 五类,按「界面上从上到下」的顺序,每类带一句人话。 + /// + /// **这份清单住在这里而不是界面里**:增删一类是这个结构体的事, + /// 而一个只在前端硬编码的清单会在加了第六类的那天悄悄少一行。 + pub fn all(&self) -> [(&'static str, &'static str, &'static str, ProbeAction); 5] { + [ + ( + "health_check", + "连通性检查", + "客户端启动时打的一发空请求,只为看网关通不通。默认本地应答。", + self.health_check, + ), + ( + "warmup", + "预热", + "正文恰好是 Warmup 的那一发,用来把连接和缓存捂热。默认本地应答。", + self.warmup, + ), + ( + "titling", + "起标题", + "给会话起个名字。**默认放行** —— 拦了的话每个会话都叫同一个名字。", + self.titling, + ), + ( + "topic_detect", + "话题识别", + "客户端判断这轮聊的是什么,用来决定要不要换上下文。默认放行。", + self.topic_detect, + ), + ( + "suggestion", + "补全建议", + "输入框里那些灰色的建议。默认放行。", + self.suggestion, + ), + ] + } +} /// 五种辅助请求各自怎么处理。 /// diff --git a/crates/tw-config/src/proxy.rs b/crates/tw-config/src/proxy.rs index 4d4dfd3f..4dc3848a 100644 --- a/crates/tw-config/src/proxy.rs +++ b/crates/tw-config/src/proxy.rs @@ -25,6 +25,17 @@ pub enum ProxyKind { Http, Https, } +impl ProxyKind { + /// 写进 YAML 的那个词。界面写回的是它,不是显示用的标签。 + pub fn slug(&self) -> &'static str { + match self { + ProxyKind::Socks5 => "socks5", + ProxyKind::Socks5h => "socks5h", + ProxyKind::Http => "http", + ProxyKind::Https => "https", + } + } +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProxyAuth { diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index a23939a5..9abd69a2 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -214,7 +214,19 @@ async fn overview(State(s): State) -> Json { let cfg = &*cfg; let engine = cfg.engine(); Json(tw_api::Overview { - proxies: cfg.proxies.iter().map(|x| x.name.clone()).collect(), + proxies: cfg + .proxies + .iter() + .map(|x| tw_api::ProxyView { + name: x.name.clone(), + kind: x.kind.slug().to_string(), + addr: x.addr.clone(), + // **密码不出这个函数。**它和上游的 key 是同一类东西, + // 而这个视图会进日志、进诊断包、进用户贴出来的截图。 + has_auth: x.auth.is_some(), + used_by: cfg.providers.iter().filter(|p| p.proxy == x.name).count(), + }) + .collect(), providers: cfg .providers .iter() @@ -296,6 +308,8 @@ async fn overview(State(s): State) -> Json { name: c.name.clone(), key: tw_secret::mask_secret(&c.key), max_concurrent: c.max_concurrent, + route: c.route.clone(), + allow: c.allow.clone(), }) .collect(), security: tw_api::SecurityView { @@ -305,8 +319,29 @@ async fn overview(State(s): State) -> Json { scan_rules_added: cfg.security.scan_rules.add.len(), scan_rules_disabled: cfg.security.scan_rules.disable.len(), }, + default_route: engine.default_route().to_string(), + client_probes: cfg + .client_probes + .all() + .into_iter() + .map(|(id, label, what, mode)| tw_api::ProbeView { + id: id.to_string(), + label: label.to_string(), + what: what.to_string(), + mode: mode.slug().to_string(), + }) + .collect(), + limits: tw_api::LimitsView { + max_concurrent: cfg.limits.max_concurrent, + per_provider: cfg.limits.per_provider, + queue_depth: cfg.limits.queue_depth, + queue_timeout_secs: cfg.limits.queue_timeout_secs, + }, listen: tw_api::ListenView { - bind: format!("{:?}", cfg.listen.gateway.bind).to_lowercase(), + // **`Display` 不是 `Debug`。**`{:?}` 对 `Loopback` / `All` + // 碰巧给出正确的小写词,对 `Addr(192.168.1.5)` 给的是 + // `addr(192.168.1.5)` —— 界面拿它去比对档位,永远不相等。 + bind: cfg.listen.gateway.bind.to_string(), port: cfg.listen.gateway.port, allow_from: cfg.listen.gateway.effective_allow_from(), exposed: cfg.listen.gateway.bind.is_exposed(), @@ -1695,3 +1730,34 @@ mod describe_tests { assert!(lines[0].contains("relay"), "{lines:?}"); } } + +#[cfg(test)] +mod overview_tests { + /// **代理的密码不能出现在概览里。**它和上游的 key 是同一类东西, + /// 而这个视图会进日志、进诊断包、进用户贴出来的截图。 + #[test] + fn a_proxy_password_never_reaches_the_overview() { + let cfg: tw_config::Config = serde_yaml_ng::from_str( + "version: 1\nclients:\n - name: c\n key: tw-k\nproviders:\n - name: p\n base_url: https://x.com\n key: sk-a\n proxy: 翻墙\nproxies:\n - name: 翻墙\n type: socks5h\n addr: 127.0.0.1:1080\n auth:\n user: u\n pass: 这是密码不能漏\n", + ) + .unwrap(); + let view: Vec = cfg + .proxies + .iter() + .map(|x| tw_api::ProxyView { + name: x.name.clone(), + kind: x.kind.slug().to_string(), + addr: x.addr.clone(), + has_auth: x.auth.is_some(), + used_by: cfg.providers.iter().filter(|p| p.proxy == x.name).count(), + }) + .collect(); + let json = serde_json::to_string(&view).unwrap(); + assert!(!json.contains("这是密码不能漏"), "{json}"); + assert!(!json.contains("\"u\""), "用户名也是凭据的一半:{json}"); + // 有没有认证是要显示的,认证内容不是 + assert!(view[0].has_auth); + // 有几家在用 —— 删之前要知道 + assert_eq!(view[0].used_by, 1); + } +}