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
64 changes: 62 additions & 2 deletions crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,10 +333,10 @@ impl Event {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Overview {
pub providers: Vec<ProviderView>,
/// 配置里定义过的代理名。**界面上换代理要从这里选** —— 让用户
/// 配置里定义过的代理。**界面上换代理要从这里选** —— 让用户
/// 手打一个名字,打错了就是一次静默的「配了没生效」
#[serde(default)]
pub proxies: Vec<String>,
pub proxies: Vec<ProxyView>,
pub routes: Vec<RouteView>,
pub groups: Vec<GroupView>,
pub clients: Vec<ClientView>,
Expand All @@ -349,6 +349,60 @@ pub struct Overview {
/// 要切到拦截」,一个切不了的开关让那个设计不成立。
#[serde(default)]
pub security: SecurityView,
/// 没绑路由的密钥走哪条
#[serde(default)]
pub default_route: String,
/// 客户端自己发的辅助请求怎么处理
#[serde(default)]
pub client_probes: Vec<ProbeView>,
/// 并发上限
#[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,
}

/// 三条防线。每条三态,而**「拦截」在每条上做的事不一样**,所以动词也
Expand Down Expand Up @@ -449,6 +503,12 @@ pub struct ClientView {
/// 已脱敏
pub key: String,
pub max_concurrent: Option<usize>,
/// 绑的那条路由。`None` = 走默认路由
#[serde(default)]
pub route: Option<String>,
/// 这把密钥能看到哪些模型。三态:不写 / 写非空 / 写 `[]`(一个都不给)
#[serde(default)]
pub allow: Option<Vec<String>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
52 changes: 52 additions & 0 deletions crates/tw-config/src/probes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
]
}
}

/// 五种辅助请求各自怎么处理。
///
Expand Down
11 changes: 11 additions & 0 deletions crates/tw-config/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
70 changes: 68 additions & 2 deletions crates/tw-control/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,19 @@ async fn overview(State(s): State<ControlState>) -> Json<tw_api::Overview> {
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()
Expand Down Expand Up @@ -296,6 +308,8 @@ async fn overview(State(s): State<ControlState>) -> Json<tw_api::Overview> {
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 {
Expand All @@ -305,8 +319,29 @@ async fn overview(State(s): State<ControlState>) -> Json<tw_api::Overview> {
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(),
Expand Down Expand Up @@ -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<tw_api::ProxyView> = 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);
}
}
Loading