Skip to content

Commit d1d2ef4

Browse files
committed
feat(tuic): rewrite docs and expand generator to 2.0.0-dev7
Add quick-start, server and client configuration references, and restructure the TUIC docs navigation. Expand the config generator schema to cover all effective dev7 sections, add an optional-integer validator primitive, and update the generator, browser and site tests.
1 parent f5cffa8 commit d1d2ef4

13 files changed

Lines changed: 1139 additions & 45 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ npm ci --prefix config-generator
1818
npm run dev --prefix config-generator
1919
```
2020

21-
打开 `http://127.0.0.1:8080/`。无需启动 Python 或 Zensical。支持配对生成、服务端或客户端单独生成、多用户、三种证书模式、SOCKS5 认证、重连、0-RTT 和 TCP/UDP 转发;“配置详解”可按枚举分支浏览 YAML,并通过悬浮或键盘聚焦查看逐项说明。
21+
打开 `http://127.0.0.1:8080/`。无需启动 Python 或 Zensical。支持配对生成、服务端或客户端单独生成、多用户、三种证书模式、SOCKS5 认证、日志、连接、TCP/UDP 转发、Quinn/quiche 后端、出站与 ACL 路由、DNS/GeoData、RESTful 管理与 HTTP/3 伪装;“配置详解”可按枚举分支浏览 YAML,并通过悬浮或键盘聚焦查看逐项说明。
2222

2323
安装了 [just](https://just.systems/) 时,可用仓库根目录的快捷命令:
2424

‎config-generator/schema/config.xml‎

Lines changed: 296 additions & 16 deletions
Large diffs are not rendered by default.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ pub(super) fn validators(node: Option<&Element>) -> Result<BTreeMap<String, Elem
220220
"required",
221221
"length",
222222
"integer",
223+
"optional-integer",
223224
"host",
224225
"socket",
225226
"endpoint",

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ impl Document {
6666
_ => false,
6767
},
6868
"integer" => !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()) && text.parse::<u64>().is_ok_and(range),
69+
"optional-integer" => {
70+
text.is_empty() || (text.bytes().all(|b| b.is_ascii_digit()) && text.parse::<u64>().is_ok_and(range))
71+
}
6972
"host" => validation::host_rule(&text).is_none(),
7073
"socket" => validation::endpoint(&text, true).is_some(),
7174
"endpoint" => validation::endpoint(&text, false).is_some(),

‎config-generator/tests/generator.rs‎

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,18 @@ fn complete_default_pair() -> Result<(), String> {
3636
let expected = json!({
3737
"server": {"server":"[::]:8443", "log_level":"info", "users":{u.uuid.clone():u.password},
3838
"tls":{"hostname":"tuic.example.com", "alpn":["h3"], "certificate":"/etc/tuic/fullchain.pem", "private_key":"/etc/tuic/privatekey.pem"},
39-
"backend":{"mode":"quinn", "quinn":{"congestion_control":{"controller":"bbr"}}}},
40-
"client": {"server":"tuic.example.com:8443", "uuid":u.uuid, "password":u.password, "log_level":"info", "reconnect":true, "lazy":true,
41-
"reconnect_initial_backoff":"500ms", "reconnect_max_backoff":"30000ms", "tls":{"sni":"tuic.example.com", "alpn":["h3"], "skip_cert_verify":false}, "local":{"server":"127.0.0.1:1080"}}
39+
"backend":{"mode":"quinn", "quinn":{"congestion_control":{"controller":"bbr", "initial_window":1048576},
40+
"initial_mtu":1200, "min_mtu":1200, "gso":true, "pmtu":true, "send_window":16777216, "receive_window":8388608, "max_idle_time":"30s"}},
41+
"auth_timeout":"3s", "stream_timeout":"60s",
42+
"outbound":{"default":{"type":"direct", "ip_mode":"v4first", "tfo":false}},
43+
"experimental":{"drop_loopback":true, "drop_private":true}},
44+
"client": {"server":"tuic.example.com:8443", "uuid":u.uuid, "password":u.password, "log_level":"info",
45+
"udp_relay_mode":"native", "heartbeat":"3s", "gc_interval":"3s", "gc_lifetime":"15s",
46+
"reconnect":true, "lazy":true,
47+
"reconnect_initial_backoff":"500ms", "reconnect_max_backoff":"30000ms",
48+
"tls":{"sni":"tuic.example.com", "alpn":["h3"], "skip_cert_verify":false},
49+
"backend":{"quinn":{"congestion_control":{"controller":"bbr"}, "send_window":16777216, "receive_window":8388608}},
50+
"local":{"server":"127.0.0.1:1080"}}
4251
});
4352
assert!(build_configs(&s)? == expected);
4453
Ok(())
@@ -228,7 +237,7 @@ fn forwarding_arrays_and_conflicts() -> Result<(), String> {
228237
}
229238

230239
#[test]
231-
fn controllers_and_unsupported_client_fields() -> Result<(), String> {
240+
fn controllers_and_wired_client_transport() -> Result<(), String> {
232241
for (controller, _) in options("controller") {
233242
let mut s = ready()?;
234243
s.controller = controller.clone();
@@ -237,9 +246,9 @@ fn controllers_and_unsupported_client_fields() -> Result<(), String> {
237246
c["server"]["backend"]["quinn"]["congestion_control"]["controller"],
238247
*controller
239248
);
240-
for key in ["backend", "udp_relay_mode", "proxy"] {
241-
assert!(c["client"].get(key).is_none());
242-
}
249+
assert_eq!(c["client"]["udp_relay_mode"], "native");
250+
assert_eq!(c["client"]["backend"]["quinn"]["congestion_control"]["controller"], "bbr");
251+
assert!(c["client"].get("proxy").is_none());
243252
assert!(c["client"]["tls"].get("certificates").is_none());
244253
}
245254
Ok(())
@@ -291,3 +300,72 @@ fn form_dsl_binding_and_visibility() -> Result<(), String> {
291300
assert!(certificate.is_some_and(|f| !f.visible(&s)));
292301
Ok(())
293302
}
303+
304+
fn outbound_row(id: u64, name: &str, kind: &str) -> serde_json::Value {
305+
json!({"id": id, "name": name, "type": kind, "ipMode": "v4first", "bindIpv4": "", "bindIpv6": "",
306+
"bindDevice": "", "tfo": false, "routingMarkEnabled": false, "routingMark": "",
307+
"socksAddr": "", "socksUsername": "", "socksPassword": "", "allowUdp": false})
308+
}
309+
310+
#[test]
311+
fn server_advanced_sections_cover_dev7() -> Result<(), String> {
312+
let mut s = ready()?;
313+
s.extra.insert("logEnabled".into(), json!(true));
314+
s.extra.insert("logFile".into(), json!("/var/log/tuic/server.log"));
315+
s.extra.insert("backendMode".into(), json!("quiche"));
316+
s.extra.insert("dnsEnabled".into(), json!(true));
317+
s.extra.insert("dnsMode".into(), json!("custom"));
318+
s.extra.insert(
319+
"dnsServers".into(),
320+
json!([{"id": 0, "server": "tls://1.1.1.1#cloudflare-dns.com"}]),
321+
);
322+
s.extra.insert("geodataEnabled".into(), json!(true));
323+
s.extra.insert("geosite".into(), json!("/var/lib/tuic/geosite.dat"));
324+
s.extra.insert("geoip".into(), json!("/var/lib/tuic/geoip.dat"));
325+
s.extra.insert("restfulEnabled".into(), json!(true));
326+
s.extra.insert("masqueradeEnabled".into(), json!(true));
327+
s.extra.insert(
328+
"acls".into(),
329+
json!([{"id": 0, "addr": "private", "outbound": "reject", "ports": "", "hijack": ""}]),
330+
);
331+
s.extra.insert("rules".into(), json!([{"id": 0, "rule": "MATCH,default"}]));
332+
s.extra.insert(
333+
"outbounds".into(),
334+
json!([outbound_row(0, "default", "direct"), outbound_row(1, "proxy", "socks5")]),
335+
);
336+
s.extra.get_mut("outbounds").expect("outbounds")[1]["socksAddr"] = json!("127.0.0.1:1080");
337+
assert!(validate(&s).is_empty());
338+
let c = build_configs(&s)?;
339+
assert_eq!(c["server"]["log"]["log_file"], "/var/log/tuic/server.log");
340+
assert_eq!(c["server"]["backend"]["mode"], "quiche");
341+
assert!(c["server"]["backend"].get("quinn").is_none());
342+
assert_eq!(c["server"]["backend"]["quiche"]["max_concurrent_bi_streams"], 100);
343+
assert_eq!(c["server"]["dns"]["servers"][0], "tls://1.1.1.1#cloudflare-dns.com");
344+
assert_eq!(c["server"]["geodata"]["geoip"], "/var/lib/tuic/geoip.dat");
345+
assert_eq!(c["server"]["restful"]["enabled"], true);
346+
assert_eq!(c["server"]["masquerade"]["enabled"], true);
347+
assert_eq!(c["server"]["acl"][0]["addr"], "private");
348+
assert_eq!(c["server"]["rules"][0], "MATCH,default");
349+
assert_eq!(c["server"]["outbound"]["default"]["type"], "direct");
350+
assert_eq!(c["server"]["outbound"]["proxy"]["addr"], "127.0.0.1:1080");
351+
assert!(c["server"]["outbound"]["proxy"].get("ip_mode").is_none());
352+
Ok(())
353+
}
354+
355+
#[test]
356+
fn client_transport_windows_and_0rtt() -> Result<(), String> {
357+
let mut s = ready()?;
358+
s.extra.insert("udpRelayMode".into(), json!("quic"));
359+
s.extra.insert("clientController".into(), json!("cubic"));
360+
s.extra.insert("clientSendWindow".into(), json!(4194304));
361+
s.extra.insert("clientReceiveWindow".into(), json!(2097152));
362+
s.zero_rtt = true;
363+
assert!(validate(&s).is_empty());
364+
let c = build_configs(&s)?;
365+
assert_eq!(c["client"]["udp_relay_mode"], "quic");
366+
assert_eq!(c["client"]["zero_rtt_handshake"], true);
367+
assert_eq!(c["client"]["backend"]["quinn"]["congestion_control"]["controller"], "cubic");
368+
assert_eq!(c["client"]["backend"]["quinn"]["send_window"], 4194304);
369+
assert_eq!(c["client"]["backend"]["quinn"]["receive_window"], 2097152);
370+
Ok(())
371+
}

‎tests/config-generator/browser.mjs‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ await page.route('**/*', route => route.request().url().startsWith(new URL(base)
2121
const id = key => page.locator(`[id="cg-${key}"]`);
2222
const preview = page.locator('#cg-preview-code');
2323
const click = name => page.getByRole('button', { name, exact: true }).click();
24+
const section = label => page.locator('summary', { hasText: label });
2425
async function jsonOutput(side) {
2526
await click(side === 'server' ? '服务端' : '客户端');
2627
await id('format').selectOption('json');
@@ -115,7 +116,7 @@ try {
115116
await click('配对生成');
116117
console.log('PASS: validation, IPv6 and independent modes');
117118

118-
await page.locator('summary').click();
119+
await section('高级选项').click();
119120
await id('localAuth').check(); await id('localUsername').fill('tester'); await id('localPassword').fill('a " \\ # 中文');
120121
await click('+ 添加转发');
121122
await id('forwards.0.listen').fill('127.0.0.1:8080'); await id('forwards.0.remote').fill('example.com:80');
@@ -129,11 +130,34 @@ try {
129130
client = await jsonOutput('client'); assert.equal(client.local.password, undefined); assert.equal(client.local.udp_forward, undefined);
130131
console.log('PASS: local auth, reconnect, forward editing and removal');
131132

133+
await section('QUIC 后端').click();
134+
await id('backendMode').selectOption('quiche');
135+
server = await jsonOutput('server');
136+
assert.equal(server.backend.mode, 'quiche');
137+
assert.equal(server.backend.quiche.max_concurrent_bi_streams, 100);
138+
await id('backendMode').selectOption('quinn');
139+
server = await jsonOutput('server');
140+
assert.equal(server.backend.quinn.congestion_control.initial_window, 1048576);
141+
await section('QUIC 后端').click();
142+
143+
await section('路由与出站').click();
144+
await id('dnsEnabled').check();
145+
await id('dnsMode').selectOption('custom');
146+
await id('dnsServers.0.server').fill('tls://1.1.1.1#cloudflare-dns.com');
147+
server = await jsonOutput('server');
148+
assert.equal(server.dns.mode, 'custom');
149+
assert.equal(server.dns.servers[0], 'tls://1.1.1.1#cloudflare-dns.com');
150+
assert.equal(server.outbound.default.type, 'direct');
151+
assert.equal(server.experimental.drop_private, true);
152+
await id('dnsEnabled').uncheck();
153+
await section('路由与出站').click();
154+
console.log('PASS: backend selection, routing, outbound and DNS sections');
155+
132156
const injection = '<img src=x onerror=alert(1)> " \\ 中文';
133157
await id('users.0.password').fill(injection);
134158
await id('format').selectOption('yaml'); assert.ok((await preview.textContent()).includes(injection.slice(0, 27)));
135159
await id('format').selectOption('toml'); assert.equal(await page.locator('#config-generator img').count(), 0);
136-
await id('reveal').uncheck(); await page.locator('summary').click();
160+
await id('reveal').uncheck(); await section('高级选项').click();
137161
await page.evaluate(() => window.scrollTo(0, 0));
138162
await page.screenshot({ path: resolve('.cache/config-generator-desktop.png'), fullPage: true });
139163
await page.setViewportSize({ width: 390, height: 844 });

‎tuic/docs/client/config.md‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# 客户端配置
2+
3+
`tuic-client` 读取单个配置文件,字段以 TOML、JSON5 或 YAML 表示。本页列出 2.0.0-dev7 的全部字段、默认值与行为;**未生效字段**单独标注。
4+
5+
## 配置文件与加载 {#format}
6+
7+
- 格式由扩展名推断:`.toml`、`.json`、`.json5`、`.yaml`、`.yml`。
8+
- `TUIC_FORCE_TOML` 强制按 TOML 解析;`TUIC_CONFIG_FORMAT` 显式指定格式。
9+
- 命令行只有 `-c`、`--config <PATH>`;未提供时报错退出。
10+
11+
现代布局把连接字段放在顶层,`[tls]`、`[backend.quinn]`、`[local]` 分组。旧的 `[relay]` 表仍被接受并自动迁移(见[旧 `[relay]` 迁移](#migration)),但会输出弃用告警。
12+
13+
## 顶层字段 {#top-level}
14+
15+
| 字段 | 类型 | 默认值 | 说明 |
16+
| --- | --- | --- | --- |
17+
| `server` | 字符串 | 必填 | 服务端 `host:port`;IPv6 使用 `[addr]:port`。 |
18+
| `uuid` | UUID | 全零 | TUIC 用户 UUID。 |
19+
| `password` | 字符串 | 空 | TUIC 用户密码。 |
20+
| `ip` | IP | 无 | 显式服务端 IP,绕过域名解析。 |
21+
| `udp_relay_mode` | 枚举 | `native` | `native` 或 `quic`。 |
22+
| `zero_rtt_handshake` | 布尔 | `false` | 恢复会话时发送 0-RTT 早期数据。 |
23+
| `heartbeat` | 时长 | `3s` | 心跳间隔。 |
24+
| `gc_interval` | 时长 | `3s` | UDP 分片回收间隔。 |
25+
| `gc_lifetime` | 时长 | `15s` | UDP 分片保留时间。 |
26+
| `reconnect` | 布尔 | `true` | 断开后自动重连。 |
27+
| `reconnect_initial_backoff` | 时长 | `500ms` | 首次重连等待。 |
28+
| `reconnect_max_backoff` | 时长 | `30s` | 退避上限。 |
29+
| `lazy` | 布尔 | `true` | 按需连接;`false` 为启动即连接,失败时退出。 |
30+
| `log_level` | 字符串 | `info` | 日志级别。 |
31+
| `tls` | 表 | 见 [`[tls]`](#tls) | TLS 验证与协商。 |
32+
| `backend` | 表 | 见 [`[backend.quinn]`](#backend) | QUIC 传输参数。 |
33+
| `local` | 表 | 见 [`[local]`](#local) | 本地代理与转发。 |
34+
| `proxy` | 表 | 见[预留字段](#reserved) | 上游 SOCKS5,当前未生效。 |
35+
| `ipstack_prefer` | 枚举 | `v4first` | 地址族偏好,当前未生效。 |
36+
| `timeout` | 时长 | `8s` | 连接超时,当前未生效。 |
37+
38+
## `[tls]` {#tls}
39+
40+
| 字段 | 类型 | 默认值 | 说明 |
41+
| --- | --- | --- | --- |
42+
| `sni` | 字符串 | 无 | SNI 覆盖;缺省时从 `server` 主机名推导。 |
43+
| `alpn` | 字符串列表 | `[]` | ALPN 列表;为空时回退到 `h3`。 |
44+
| `skip_cert_verify` | 布尔 | `false` | 跳过证书链与主机名校验。 |
45+
| `certificates` | 路径列表 | `[]` | 自定义信任证书,当前未生效。 |
46+
| `disable_sni` | 布尔 | `false` | 不发送 SNI,当前未生效。 |
47+
| `disable_native_certs` | 布尔 | `false` | 禁用平台信任库,当前未生效。 |
48+
49+
用 IP 连接时,`server` 的主机名是 IP 字面量,无法作为 SNI。此时应显式设置 `tls.sni` 为证书域名;否则客户端会使用占位 SNI 并告警。
50+
51+
客户端默认使用平台证书验证(`rustls-platform-verifier`)。`tls.certificates` 不会被读取,因此自签名或私有 CA 场景目前只能通过 `skip_cert_verify = true` 绕过(仅限测试)。
52+
53+
!!! warning "ALPN 必须与服务端一致"
54+
服务端为空时不声明 ALPN;客户端为空时回退 `h3`。两端都建议显式设置 `alpn = ["h3"]`。
55+
56+
## `[backend.quinn]` {#backend}
57+
58+
| 字段 | 类型 | 默认值 | 说明 |
59+
| --- | --- | --- | --- |
60+
| `congestion_control.controller` | 枚举 | `bbr` | `bbr`、`bbr3`、`cubic`、`newreno`。 |
61+
| `send_window` | 整数 | `16777216` | 未经确认可发送的最大字节数。 |
62+
| `receive_window` | 整数 | `8388608` | 每条流可接收的最大字节数。 |
63+
| `initial_mtu` | 整数 | `1200` | 初始 MTU,当前未生效。 |
64+
| `min_mtu` | 整数 | `1200` | 最小 MTU,当前未生效。 |
65+
| `gso` | 布尔 | `true` | UDP GSO,当前未生效。 |
66+
| `pmtu` | 布尔 | `true` | 路径 MTU 发现,当前未生效。 |
67+
68+
客户端 `backend.mode` 仅支持 `quinn`。
69+
70+
## `[local]` {#local}
71+
72+
| 字段 | 类型 | 默认值 | 说明 |
73+
| --- | --- | --- | --- |
74+
| `server` | 套接字 | `127.0.0.1:1080` | 本地 SOCKS5 监听地址。 |
75+
| `username` | 字符串 | 无 | SOCKS5 用户名。 |
76+
| `password` | 字符串 | 无 | SOCKS5 密码。 |
77+
| `tcp_forward` | 表数组 | `[]` | 静态 TCP 转发。 |
78+
| `udp_forward` | 表数组 | `[]` | 静态 UDP 转发。 |
79+
| `dual_stack` | 布尔 | 无 | 双栈监听,当前未生效。 |
80+
| `max_packet_size` | 整数 | `1500` | UDP 最大包大小,当前未生效。 |
81+
82+
SOCKS5 认证与 TUIC 用户认证相互独立。改为非回环监听地址时建议启用认证并配置防火墙。
83+
84+
转发项:
85+
86+
```toml
87+
[[local.tcp_forward]]
88+
listen = "127.0.0.1:8080"
89+
remote = "example.com:80"
90+
91+
[[local.udp_forward]]
92+
listen = "127.0.0.1:5353"
93+
remote = "8.8.8.8:53"
94+
timeout = "60s"
95+
```
96+
97+
`listen` 必须是 `IP:端口`,`remote` 可为域名或 `IP:端口`。服务端默认拦截回环和私有目标地址,见[服务端 `[experimental]`](../server/config.md#experimental)。
98+
99+
## 预留与未生效字段 {#reserved}
100+
101+
以下字段可被解析,但当前版本未接入运行逻辑:
102+
103+
| 字段 | 说明 |
104+
| --- | --- |
105+
| `ipstack_prefer` | 地址族偏好。 |
106+
| `timeout` | 连接超时。 |
107+
| `proxy.server` / `proxy.username` / `proxy.password` / `proxy.udp_buffer_size` | 通过上游 SOCKS5 建立 QUIC 连接。 |
108+
| `tls.certificates` | 自定义信任证书。 |
109+
| `tls.disable_sni` | 抑制 SNI。 |
110+
| `tls.disable_native_certs` | 禁用平台信任库。 |
111+
| `backend.quinn.initial_mtu` / `min_mtu` / `gso` / `pmtu` | 传输细节。 |
112+
| `local.dual_stack` / `max_packet_size` | 本地监听细节。 |
113+
114+
## 旧 `[relay]` 迁移 {#migration}
115+
116+
旧配置把所有连接字段放在 `[relay]` 下。解析时按下列规则迁移,且**现代字段优先**:
117+
118+
| `[relay]` 字段 | 迁移目标 |
119+
| --- | --- |
120+
| `certificates`、`alpn`、`disable_sni`、`sni`、`disable_native_certs`、`skip_cert_verify` | `[tls]` |
121+
| `congestion_control` | `[backend.quinn.congestion_control].controller` |
122+
| `send_window`、`receive_window`、`initial_mtu`、`min_mtu`、`gso`、`pmtu` | `[backend.quinn]` |
123+
| 其余(`server`、`uuid`、`password`、`ip`、`udp_relay_mode` 等) | 顶层同名字段 |
124+
125+
迁移会输出弃用告警,建议尽快改写为现代布局。
126+
127+
## 完整示例 {#example}
128+
129+
```toml
130+
log_level = "info"
131+
server = "tuic.example.com:8443"
132+
uuid = "00000000-0000-4000-8000-000000000001"
133+
password = "change-this-password"
134+
udp_relay_mode = "native"
135+
zero_rtt_handshake = false
136+
heartbeat = "3s"
137+
reconnect = true
138+
reconnect_initial_backoff = "500ms"
139+
reconnect_max_backoff = "30s"
140+
lazy = true
141+
142+
[tls]
143+
sni = "tuic.example.com"
144+
alpn = ["h3"]
145+
skip_cert_verify = false
146+
147+
[backend.quinn.congestion_control]
148+
controller = "bbr"
149+
150+
[local]
151+
server = "127.0.0.1:1080"
152+
153+
[[local.tcp_forward]]
154+
listen = "127.0.0.1:8080"
155+
remote = "example.com:80"
156+
```

0 commit comments

Comments
 (0)