Skip to content

Commit eac87ce

Browse files
committed
feat: add interactive config descriptions
1 parent a6cf309 commit eac87ce

17 files changed

Lines changed: 445 additions & 8 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ npm ci --prefix config-generator
1515
npm run dev --prefix config-generator
1616
```
1717

18-
打开 `http://127.0.0.1:8080/`。无需启动 Python 或 Zensical。支持配对生成、服务端或客户端单独生成、多用户、三种证书模式、SOCKS5 认证、重连、0-RTT 和 TCP/UDP 转发。
18+
打开 `http://127.0.0.1:8080/`。无需启动 Python 或 Zensical。支持配对生成、服务端或客户端单独生成、多用户、三种证书模式、SOCKS5 认证、重连、0-RTT 和 TCP/UDP 转发;“配置详解”可按枚举分支浏览 YAML,并通过悬浮或键盘聚焦查看逐项说明。
1919

2020
独立构建:
2121

‎config-generator/schema/config.xml‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,91 @@
133133
<value name="serverHostname"><coalesce><source from="/hostname" transform="trim"/><source ref="host" when="client"/></coalesce></value>
134134
<value name="clientSni"><coalesce><source from="/sni" transform="trim"/><source from="/hostname" transform="trim" when="server"/><source ref="host"/></coalesce></value>
135135
</values>
136+
<config-desc title="配置项详解" description="选择配置类型和枚举分支,查看完整 YAML 结构;悬浮或聚焦任意一行可了解字段作用。">
137+
<config name="server" label="服务端配置" filename="server.yaml">
138+
<selector name="tls" label="证书模式" default="certificate">
139+
<choice value="certificate" label="已有证书"/><choice value="acme" label="ACME 自动证书"/><choice value="self" label="自签名测试"/>
140+
</selector>
141+
<selector name="controller" label="拥塞控制" default="bbr">
142+
<choice value="bbr" label="BBR"/><choice value="bbr3" label="BBR3"/><choice value="cubic" label="CUBIC"/><choice value="newreno" label="New Reno"/>
143+
</selector>
144+
<selector name="early-data" label="0-RTT" default="off">
145+
<choice value="off" label="关闭(推荐)"/><choice value="on" label="开启"/>
146+
</selector>
147+
<line yaml="server: &quot;[::]:8443&quot;" description="TUIC 服务端监听的 UDP 地址。IPv6 监听地址需要方括号;防火墙也必须放行对应 UDP 端口。"/>
148+
<line yaml="log_level: info" description="运行日志级别,可使用 trace、debug、info、warn、error 或 off。生产环境通常使用 info。"/>
149+
<line yaml="zero_rtt_handshake: true" description="允许 QUIC 0-RTT 早期数据以降低重连延迟;早期数据可能被重放,只有明确接受风险时才开启。" when="early-data=on"/>
150+
<line yaml="data_dir: /var/lib/tuic" description="ACME 证书与账户状态的持久化目录。运行用户必须可写,容器部署时应挂载持久卷。" when="tls=acme"/>
151+
<line yaml="users:" description="允许连接的 TUIC 用户映射;键是 UUID,值是对应密码。至少配置一个用户。"/>
152+
<line yaml=" &quot;00000000-0000-4000-8000-000000000001&quot;: &quot;change-this-password&quot;" description="单个用户凭据。UUID 必须唯一且非全零,密码应使用高强度随机值;客户端必须使用完全相同的一组凭据。"/>
153+
<line yaml="tls:" description="服务端 TLS 配置。TUIC 基于 QUIC,TLS 同时负责加密和服务端身份验证。"/>
154+
<line yaml=" hostname: tuic.example.com" description="证书对应的 DNS 主机名。ACME 用它申请证书,自签名模式用它生成证书。"/>
155+
<line yaml=" alpn:" description="QUIC TLS 的应用层协议协商列表。当前配置固定使用 h3。"/>
156+
<line yaml=" - h3" description="客户端与服务端必须协商相同的 ALPN;生成器使用 h3。"/>
157+
<line yaml=" certificate: /etc/tuic/fullchain.pem" description="已有证书模式下的证书链文件路径。文件必须在服务端运行环境中存在并可读。" when="tls=certificate"/>
158+
<line yaml=" private_key: /etc/tuic/privatekey.pem" description="已有证书模式下的私钥文件路径。请限制文件权限,不要把私钥内容直接写进配置。" when="tls=certificate"/>
159+
<line yaml=" auto_ssl: true" description="启用 ACME 自动申请和续期证书。域名需指向服务器,并开放 HTTP-01 所需的 TCP 80。" when="tls=acme"/>
160+
<line yaml=" acme_email: admin@example.com" description="ACME 账户联系邮箱,用于证书服务通知。" when="tls=acme"/>
161+
<line yaml=" self_sign: true" description="启动时生成自签名证书,仅适合受控测试;客户端必须显式跳过验证,因此不应在生产环境使用。" when="tls=self"/>
162+
<line yaml="backend:" description="底层 QUIC 实现及其传输参数。"/>
163+
<line yaml=" mode: quinn" description="选择 Quinn 作为 QUIC 后端;这是当前生成器支持的服务端后端。"/>
164+
<line yaml=" quinn:" description="Quinn 后端的专属配置命名空间。"/>
165+
<line yaml=" congestion_control:" description="拥塞控制配置,决定发送速率如何响应网络容量和丢包。"/>
166+
<line yaml=" controller: bbr" description="BBR 以带宽和往返时延建模,适合多数代理链路。" when="controller=bbr"/>
167+
<line yaml=" controller: bbr3" description="BBR3 选项;当前版本与 BBR 共用实现,行为差异以对应 TUIC 版本为准。" when="controller=bbr3"/>
168+
<line yaml=" controller: cubic" description="CUBIC 是常见的基于丢包的拥塞控制,在传统网络环境中兼容性较好。" when="controller=cubic"/>
169+
<line yaml=" controller: newreno" description="New Reno 是较保守的基于丢包算法,适合用于兼容性比较和诊断。" when="controller=newreno"/>
170+
</config>
171+
<config name="client" label="客户端配置" filename="client.yaml">
172+
<selector name="address" label="服务端地址" default="domain">
173+
<choice value="domain" label="域名"/><choice value="ip" label="IP 地址"/>
174+
</selector>
175+
<selector name="verification" label="证书验证" default="verify">
176+
<choice value="verify" label="严格验证(推荐)"/><choice value="skip" label="跳过验证(仅测试)"/>
177+
</selector>
178+
<selector name="reconnect" label="断线重连" default="on">
179+
<choice value="on" label="开启"/><choice value="off" label="关闭"/>
180+
</selector>
181+
<selector name="early-data" label="0-RTT" default="off">
182+
<choice value="off" label="关闭(推荐)"/><choice value="on" label="开启"/>
183+
</selector>
184+
<selector name="local-auth" label="SOCKS5 认证" default="off">
185+
<choice value="off" label="关闭"/><choice value="on" label="用户名与密码"/>
186+
</selector>
187+
<selector name="forward" label="端口转发示例" default="none">
188+
<choice value="none" label="不配置"/><choice value="tcp" label="TCP 转发"/><choice value="udp" label="UDP 转发"/>
189+
</selector>
190+
<line yaml="server: tuic.example.com:8443" description="客户端连接的 TUIC 服务端地址,包含 UDP 端口。域名会在客户端解析。" when="address=domain"/>
191+
<line yaml="server: &quot;[2001:db8::1]:8443&quot;" description="直接连接 IPv6 地址时使用方括号包围地址,再追加 UDP 端口。" when="address=ip"/>
192+
<line yaml="ip: &quot;2001:db8::1&quot;" description="显式指定服务端 IP,可绕过域名解析;使用域名连接时通常不需要此字段。" when="address=ip"/>
193+
<line yaml="uuid: &quot;00000000-0000-4000-8000-000000000001&quot;" description="服务端 users 映射中存在的用户 UUID。配对生成会自动保持两端一致。"/>
194+
<line yaml="password: &quot;change-this-password&quot;" description="UUID 对应的 TUIC 密码。它属于敏感信息,配置文件应限制读取权限。"/>
195+
<line yaml="log_level: info" description="客户端日志级别。排障时可暂时提高到 debug 或 trace,但日志量会明显增加。"/>
196+
<line yaml="zero_rtt_handshake: true" description="允许客户端在恢复会话时发送 0-RTT 早期数据以降低延迟;早期数据可能被重放,需与服务端同时开启。" when="early-data=on"/>
197+
<line yaml="reconnect: true" description="连接中断后自动重试,适合长期运行的本地代理。" when="reconnect=on"/>
198+
<line yaml="reconnect: false" description="连接中断后不自动重试,适合由外部进程管理器控制重启的场景。" when="reconnect=off"/>
199+
<line yaml="lazy: true" description="按需建立到服务端的连接;首次代理请求到来前不主动连接。"/>
200+
<line yaml="reconnect_initial_backoff: 500ms" description="首次重连前的等待时间。短暂网络抖动时,退避可以避免立即反复建连。" when="reconnect=on"/>
201+
<line yaml="reconnect_max_backoff: 30000ms" description="指数退避允许增长到的最大等待时间,必须不小于首次等待。" when="reconnect=on"/>
202+
<line yaml="tls:" description="客户端 TLS 验证与协议协商配置。"/>
203+
<line yaml=" sni: tuic.example.com" description="TLS SNI 与证书主机名。即使通过 IP 连接,也应填写证书对应的 DNS 名称。"/>
204+
<line yaml=" alpn:" description="客户端提出的 ALPN 列表,必须与服务端一致。"/>
205+
<line yaml=" - h3" description="当前生成器使用的 QUIC 应用层协议标识。"/>
206+
<line yaml=" skip_cert_verify: false" description="严格验证证书链和主机名,可防止中间人攻击,是生产环境的安全默认值。" when="verification=verify"/>
207+
<line yaml=" skip_cert_verify: true" description="跳过服务端证书验证,会失去服务端身份保证;只能用于受控的临时测试。" when="verification=skip"/>
208+
<line yaml="local:" description="本地代理监听与静态端口转发配置。"/>
209+
<line yaml=" server: 127.0.0.1:1080" description="本地 SOCKS5 监听地址。默认仅允许本机访问;对外监听时应启用认证并配置防火墙。"/>
210+
<line yaml=" username: proxy-user" description="本地 SOCKS5 用户名,与 TUIC 服务端的 UUID 认证相互独立。" when="local-auth=on"/>
211+
<line yaml=" password: &quot;local-proxy-password&quot;" description="本地 SOCKS5 密码。它同样是敏感信息,应使用随机值并保护配置文件。" when="local-auth=on"/>
212+
<line yaml=" tcp_forward:" description="TCP 静态转发规则列表;每条规则把本地 TCP 监听转发到远端目标。" when="forward=tcp"/>
213+
<line yaml=" - listen: 127.0.0.1:8080" description="单条 TCP 转发的本地监听地址。使用回环地址可避免意外暴露服务。" when="forward=tcp"/>
214+
<line yaml=" remote: example.com:80" description="TCP 转发的远端目标地址,由服务端侧发起连接;服务端 ACL 仍可能阻止私有或回环目标。" when="forward=tcp"/>
215+
<line yaml=" udp_forward:" description="UDP 静态转发规则列表;UDP 无连接,因此还需要会话超时。" when="forward=udp"/>
216+
<line yaml=" - listen: 127.0.0.1:5353" description="单条 UDP 转发的本地监听地址。" when="forward=udp"/>
217+
<line yaml=" remote: dns.example.com:53" description="UDP 数据报转发到的远端目标地址。" when="forward=udp"/>
218+
<line yaml=" timeout: 60s" description="无数据后保留 UDP 会话的时间;到期后释放相关状态。" when="forward=udp"/>
219+
</config>
220+
</config-desc>
136221
<!-- TUIC 4719113 / Wind 9025349: literal output names, no executable code. -->
137222
<outputs>
138223
<object name="server" when="server" label="服务端" filename="server" command="tuic-server -c {filename}">

‎config-generator/schema/example.xml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@
3838
<assert key="last" when="range" message="结束数量不能小于起始数量。"><compare op="gte"><source from="/last" transform="integer"/><source from="/first" transform="integer"/></compare></assert>
3939
<unique collection="entries" key="id" message="项目名称重复。"><source from="id" transform="trim lowercase"/></unique>
4040
</rules>
41+
<config-desc title="清单格式详解" description="此说明同样完全来自替换后的 XML。">
42+
<config name="snapshot" label="完整清单" filename="snapshot.yaml">
43+
<selector name="visibility" label="可见性" default="team"><choice value="team" label="团队"/><choice value="private" label="私有"/></selector>
44+
<line yaml="project: example" description="清单所属项目的名称。"/>
45+
<line yaml="visibility: team" description="团队成员可以读取这份清单。" when="visibility=team"/>
46+
<line yaml="visibility: private" description="只有清单所有者可以读取。" when="visibility=private"/>
47+
</config>
48+
</config-desc>
4149
<outputs>
4250
<object name="snapshot" label="完整清单" filename="snapshot" command="notebook --input {filename}" when="all">
4351
<string name="title" from="/project"/>

‎config-generator/src/dsl.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
//! Static XML configuration descriptions, deserialized with Serde. No callbacks or scripts.
2+
mod description;
23
mod metadata;
34
mod parser;
45
mod rules;
@@ -10,6 +11,7 @@ use std::{
1011
net::IpAddr,
1112
};
1213

14+
pub use description::{ConfigDescription, DescriptionConfig, DescriptionLine, DescriptionSelector};
1315
pub use metadata::{Export, Generator, Notice, Section, Ui};
1416
use serde_json::{Map, Value};
1517

@@ -130,6 +132,7 @@ impl Collection {
130132
pub struct Document {
131133
pub target_version: String,
132134
pub ui: Ui,
135+
pub config_description: Option<ConfigDescription>,
133136
pub exports: Vec<Export>,
134137
validators: BTreeMap<String, Element>,
135138
rules: Vec<Element>,
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
use std::collections::{BTreeMap, BTreeSet};
2+
3+
use serde::Serialize;
4+
5+
use super::{DslError, Element, parser::attrs};
6+
7+
#[derive(Debug, Clone, Serialize)]
8+
pub struct ConfigDescription {
9+
pub title: String,
10+
pub description: String,
11+
pub configs: Vec<DescriptionConfig>,
12+
}
13+
14+
#[derive(Debug, Clone, Serialize)]
15+
pub struct DescriptionConfig {
16+
pub name: String,
17+
pub label: String,
18+
pub filename: String,
19+
pub selectors: Vec<DescriptionSelector>,
20+
pub lines: Vec<DescriptionLine>,
21+
}
22+
23+
#[derive(Debug, Clone, Serialize)]
24+
pub struct DescriptionSelector {
25+
pub name: String,
26+
pub label: String,
27+
pub default: String,
28+
pub choices: Vec<(String, String)>,
29+
}
30+
31+
#[derive(Debug, Clone, Serialize)]
32+
pub struct DescriptionLine {
33+
pub yaml: String,
34+
pub description: String,
35+
pub conditions: Vec<(String, String)>,
36+
}
37+
38+
fn identifier<'a>(node: &'a Element, attr: &str) -> Result<&'a str, DslError> {
39+
let value = node.required(attr)?;
40+
if value.is_empty()
41+
|| !value.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
42+
|| value.as_bytes()[0].is_ascii_digit()
43+
{
44+
return Err(node.error("名称必须是静态标识符"));
45+
}
46+
Ok(value)
47+
}
48+
49+
pub(super) fn parse(node: Option<&Element>) -> Result<Option<ConfigDescription>, DslError> {
50+
let Some(node) = node else { return Ok(None) };
51+
attrs(node, &["title", "description"])?;
52+
let mut configs = Vec::new();
53+
let mut config_names = BTreeSet::new();
54+
for config in &node.children {
55+
if config.tag != "config" {
56+
return Err(config.error("config-desc 只接受 config"));
57+
}
58+
attrs(config, &["name", "label", "filename"])?;
59+
let name = identifier(config, "name")?;
60+
if !config_names.insert(name.to_owned()) {
61+
return Err(config.error("重复配置名称"));
62+
}
63+
let mut selectors = Vec::new();
64+
let mut selector_names = BTreeSet::new();
65+
let mut lines = Vec::new();
66+
for child in &config.children {
67+
match child.tag.as_str() {
68+
"selector" => {
69+
attrs(child, &["name", "label", "default"])?;
70+
let selector_name = identifier(child, "name")?;
71+
if !selector_names.insert(selector_name.to_owned()) {
72+
return Err(child.error("重复选择器名称"));
73+
}
74+
let mut choices = Vec::new();
75+
let mut values = BTreeSet::new();
76+
for choice in &child.children {
77+
if choice.tag != "choice" || !choice.children.is_empty() {
78+
return Err(choice.error("selector 只接受空 choice"));
79+
}
80+
attrs(choice, &["value", "label"])?;
81+
let value = identifier(choice, "value")?;
82+
if !values.insert(value.to_owned()) {
83+
return Err(choice.error("重复选择值"));
84+
}
85+
choices.push((value.into(), choice.required("label")?.into()));
86+
}
87+
let default = child.required("default")?;
88+
if choices.is_empty() || !values.contains(default) {
89+
return Err(child.error("选择器默认值必须属于非空选项"));
90+
}
91+
selectors.push(DescriptionSelector {
92+
name: selector_name.into(),
93+
label: child.required("label")?.into(),
94+
default: default.into(),
95+
choices,
96+
});
97+
}
98+
"line" => {
99+
attrs(child, &["yaml", "description", "when"])?;
100+
if !child.children.is_empty() {
101+
return Err(child.error("line 不接受子元素"));
102+
}
103+
lines.push(DescriptionLine {
104+
yaml: child.required("yaml")?.into(),
105+
description: child.required("description")?.into(),
106+
conditions: parse_conditions(child)?,
107+
});
108+
}
109+
_ => return Err(child.error("config 只接受 selector 或 line")),
110+
}
111+
}
112+
if lines.is_empty() {
113+
return Err(config.error("配置说明至少需要一行 YAML"));
114+
}
115+
let selector_map: BTreeMap<_, _> = selectors.iter().map(|s| (s.name.as_str(), s)).collect();
116+
for line in &lines {
117+
for (selector, value) in &line.conditions {
118+
let Some(selector) = selector_map.get(selector.as_str()) else {
119+
return Err(config.error("YAML 行引用了不存在的选择器"));
120+
};
121+
if !selector.choices.iter().any(|(candidate, _)| candidate == value) {
122+
return Err(config.error("YAML 行引用了不存在的选择值"));
123+
}
124+
}
125+
}
126+
configs.push(DescriptionConfig {
127+
name: name.into(),
128+
label: config.required("label")?.into(),
129+
filename: config.required("filename")?.into(),
130+
selectors,
131+
lines,
132+
});
133+
}
134+
if configs.is_empty() {
135+
return Err(node.error("config-desc 至少需要一个 config"));
136+
}
137+
Ok(Some(ConfigDescription {
138+
title: node.required("title")?.into(),
139+
description: node.attr("description").unwrap_or_default().into(),
140+
configs,
141+
}))
142+
}
143+
144+
fn parse_conditions(node: &Element) -> Result<Vec<(String, String)>, DslError> {
145+
let mut conditions = Vec::new();
146+
let mut names = BTreeSet::new();
147+
for condition in node.attr("when").unwrap_or_default().split(',').filter(|s| !s.is_empty()) {
148+
let Some((name, value)) = condition.split_once('=') else {
149+
return Err(node.error("when 必须使用 selector=value,并以逗号分隔"));
150+
};
151+
if !names.insert(name) || name.is_empty() || value.is_empty() {
152+
return Err(node.error("when 含有空值或重复选择器"));
153+
}
154+
conditions.push((name.into(), value.into()));
155+
}
156+
Ok(conditions)
157+
}

‎config-generator/src/dsl/parser.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ pub(super) fn parse(source: &str) -> Result<Document, DslError> {
2121
"validators",
2222
"rules",
2323
"effects",
24+
"config-desc",
2425
]
2526
.contains(&child.tag.as_str())
2627
{
2728
return Err(child.error("未知文档区块"));
2829
}
29-
if child.tag != "ui" {
30+
if !["ui", "config-desc"].contains(&child.tag.as_str()) {
3031
attrs(child, &[])?;
3132
}
3233
if sections.insert(child.tag.as_str(), child).is_some() {
@@ -126,6 +127,7 @@ pub(super) fn parse(source: &str) -> Result<Document, DslError> {
126127
let mut doc = Document {
127128
target_version: root.required("target-version")?.into(),
128129
ui: metadata::parse_ui(sections.get("ui").copied())?,
130+
config_description: description::parse(sections.get("config-desc").copied())?,
129131
exports: metadata::exports(outputs)?,
130132
validators: metadata::validators(sections.get("validators").copied())?,
131133
rules: metadata::rules(sections.get("rules").copied())?,
@@ -463,6 +465,11 @@ fn references(node: &Element, result: &mut BTreeSet<String>) {
463465
}
464466
}
465467
fn check_references(node: &Element, doc: &Document) -> Result<(), DslError> {
468+
// The static config description has its own selector/value validation and
469+
// never participates in runtime conditions, values, or input projection.
470+
if node.tag == "config-desc" {
471+
return Ok(());
472+
}
466473
for attr in ["all-when", "select-when"] {
467474
if let Some(name) = node.attr(attr)
468475
&& !doc.conditions.contains_key(name)

0 commit comments

Comments
 (0)