Merge 97e106d070
into 4d6be465c7
commit
32fbfcd073
|
@ -1,179 +1,173 @@
|
||||||
# 1. 加速服务:
|
# 1. 加速服务:
|
||||||
|
1. 什么是 `加速服务`?
|
||||||
|
`加速服务` 即 `代理服务`,它通过中间人攻击的方式,将网络请求拦截下来,并经过DNS加速、篡改、重定向、代理等一系列的功能,达到加速访问、或访问原本无法访问的站点等目的。
|
||||||
|
|
||||||
1. 什么是 `加速服务`?
|
2. 启动加速服务:点击首页的 `代理服务` 右侧的开关按钮,即可启动加速服务。
|
||||||
|
|
||||||
- `加速服务` 即 `代理服务`,它通过中间人攻击的方式,将网络请求拦截下来,并经过DNS加速、修改、重定向、代理等一系列的功能,达到加速访问、或访问原本无法访问的站点等目的。<br>
|
|
||||||
2. 如何启动加速服务:<br>
|
|
||||||
- 点击首页的【代理服务】右侧的开关按钮,即可启动/关闭加速服务。<br>
|
|
||||||
- 点击首页的【系统代理】右侧的开关按钮,即可将dev-sidecar设置/不设置为系统默认代理。(系统只能有一个默认代理,在将dev-sidecar与其他网络辅助软件共用时请谨慎开启本开关)<br>
|
|
||||||
- 点击首页的【NPM加速】和【Git.exe代理】右侧的开关按钮,即可启动/关闭dev-sidecar为对应软件提供的加速服务。如果你的电脑上并未安装NPM或Git,则这两个按钮将不可用,这是正常情况。
|
|
||||||
|
|
||||||
|
|
||||||
# 2. 根证书使用说明:
|
# 2. 根证书使用说明:
|
||||||
|
1. 什么是根证书?
|
||||||
1. 什么是根证书:TODO
|
2. [为什么需要安装根证书这么高风险性的步骤](https://github.com/docmirror/dev-sidecar/blob/master/doc/caroot.md)
|
||||||
2. [为什么需要安装根证书这么高风险性的步骤](https://github.com/docmirror/dev-sidecar/blob/master/doc/caroot.md)
|
3. 如何安装根证书:
|
||||||
3. 如何安装根证书:参见dev-sidecar【首页】的【安装根证书】按钮(注意Firefox浏览器还需要一次手动导入根证书)
|
|
||||||
|
|
||||||
# 3. 模式:
|
# 3. 模式:
|
||||||
|
1. 安全模式:
|
||||||
1. 安全模式:TODO
|
2. 默认模式:
|
||||||
2. 默认模式:TODO
|
3. 增强模式(彩蛋):
|
||||||
3. 增强模式(彩蛋):TODO
|
|
||||||
|
|
||||||
# 4. 拦截功能使用和配置说明:
|
# 4. 拦截功能使用和配置说明:
|
||||||
|
1. 请求拦截器:
|
||||||
|
| 请求拦截器名称 | 拦截器配置名 | 请求拦截优先级 | 快速响应码 |
|
||||||
|
| ----------------- | -------------- | ------------- | --------- |
|
||||||
|
| OPTIONS请求拦截器 | options | 101 | 200 |
|
||||||
|
| 快速成功拦截器 | success | 102 | 200 |
|
||||||
|
| 快速失败拦截器 | abort | 103 | 403 |
|
||||||
|
| 缓存请求拦截器 | cacheXxx | 104 | 304 |
|
||||||
|
| 重定向拦截器 | redirect | 105 | 302 |
|
||||||
|
| 请求篡改拦截器 | requestReplace | 111 | |
|
||||||
|
| 代理拦截器 | proxy | 121 | |
|
||||||
|
| SNI拦截器 | sni | 122 | |
|
||||||
|
|
||||||
## 4.1. 拦截器类型:
|
2. 响应拦截器:
|
||||||
|
| 响应拦截器名称 | 拦截器配置名 | 响应拦截优先级 |
|
||||||
### 1)请求拦截器:
|
| ---------------- | --------------- | ------------- |
|
||||||
| 请求拦截器名称 | 拦截器配置名 | 请求拦截优先级 | 作用 |
|
| OPTIONS响应拦截器 | options | 201 |
|
||||||
| ----------------- | -------------- | ------------- | --------- |
|
| 缓存响应拦截器 | cacheXxx | 202 |
|
||||||
| OPTIONS请求拦截器 | options | 101 | 直接响应200,不发送该OPTIONS请求 |
|
| 响应篡改拦截器 | responseReplace | 203 |
|
||||||
| 快速成功拦截器 | success | 102 | 直接响应200,不发送该请求 |
|
| 脚本拦截器 | script | 211 |
|
||||||
| 快速失败拦截器 | abort | 103 | 直接响应403,不发送该请求 |
|
|
||||||
| 缓存请求拦截器 | cacheXxx | 104 | 如果缓存还生效,直接响应304,不发送该请求<br>如果缓存已过期或无缓存,则发送请求<br>注:只对GET请求生效! |
|
|
||||||
| 重定向拦截器 | redirect | 105 | 重定向到指定地址,直接响应302,不发送该请求 |
|
|
||||||
| 请求篡改拦截器 | requestReplace | 111 | 篡改请求头,达到想要的目的 |
|
|
||||||
| 代理拦截器 | proxy | 121 | 将请求转发到指定地址 |
|
|
||||||
| SNI拦截器 | sni | 122 | 设置 `servername`,用于避开GFW |
|
|
||||||
|
|
||||||
### 2)响应拦截器:
|
|
||||||
| 响应拦截器名称 | 拦截器配置名 | 响应拦截优先级 | 作用 |
|
|
||||||
| ---------------- | --------------- | ------------- | --------- |
|
|
||||||
| OPTIONS响应拦截器 | options | 201 | 设置跨域所需的响应头,避免被浏览器的跨域策略阻拦 |
|
|
||||||
| 缓存响应拦截器 | cacheXxx | 202 | 设置缓存所需的响应头,使浏览器缓存当前请求<br>注:只对GET请求生效! |
|
|
||||||
| 响应篡改拦截器 | responseReplace | 203 | 篡改响应头,避免被浏览器的安全策略阻拦 |
|
|
||||||
| 脚本拦截器 | script | 211 | 注入JavaScript脚本到页面中,如:Github油猴脚本 |
|
|
||||||
|
|
||||||
## 4.2. 拦截配置说明书:
|
|
||||||
|
|
||||||
TODO:内容待完善
|
|
||||||
|
|
||||||
# 5. 域名白名单:
|
# 5. 域名白名单:
|
||||||
|
|
||||||
选择哪些域名不会被dev-sidecar处理。
|
# 6. DNS服务管理:
|
||||||
|
|
||||||
**注意:** 该设置与【系统代理-自定义排除域名】的区别在于:
|
|
||||||
|
|
||||||
1. 前者只是被dev-sidecar自身忽略,后者则是写入系统设置、不会被(任何的)系统代理处理,在手动修改系统代理设置时务必小心后者可能残留的作用!
|
|
||||||
2. 在条目较多时,前者的性能不如后者,可能产生明显延迟。<br>
|
|
||||||
|
|
||||||
在config.json的 `proxy.excludeIpList:object` 中设置,**该字段**格式如下:<br>
|
|
||||||
> 注意:这里点号用来作为JSON object嵌套关系的缩写,冒号指明该条目的类型(主要用来区分object和list),并没有哪一个Object的key为 `proxy.excludeIpList`。为避免歧义,配置中object和list的key总不应包含点号。下同)
|
|
||||||
|
|
||||||
|
## 6.1. 配置 `DNS-over-HTTPS` 的DNS服务:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"proxy": {
|
"cloudflare": {
|
||||||
"excludeIpList": {
|
"type": "https",
|
||||||
"example1.com": true,
|
"server": "https://1.1.1.1/dns-query",
|
||||||
"example2.com": false,
|
"cacheSize": 1000
|
||||||
"example3.com": null,
|
}
|
||||||
"example4.com": {
|
}
|
||||||
"desc1": "域名对应字段设置为false时会被处理,null会移除现有设置(多用于远程配置)",
|
```
|
||||||
"desc2": "其他情况下就和设置true一样,不会被处理。因而你可以像这样插入注释",
|
或
|
||||||
"desc3": "同样的技巧可以用在其他本应设置一个bool值的地方",
|
```json
|
||||||
"desc4": "原则上来说config.json不支持//形式的注释,但下文为了方便阅读,还是这么写了"
|
{
|
||||||
}
|
"cloudflare": {
|
||||||
}
|
"server": "https://1.1.1.1/dns-query", // 地址上带有 `https://`,type可以不配置
|
||||||
|
"cacheSize": 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
# 6. DNS服务管理:
|
## 6.2. 配置 `DNS-over-TLS` 的DNS服务:
|
||||||
|
|
||||||
用来配置在dev-sidecar中需要的指定DNS,出于保密和可靠起见建议使用DoH和DoT。<br>
|
|
||||||
在 `server.dns.provider:object` 中设置,**其中的每个条目** 格式如下:
|
|
||||||
|
|
||||||
## 6.1. 配置 `DNS-over-HTTPS`(简称DoH):
|
|
||||||
> 注:并非被所有DNS支持,但是保证只要能使用就一定匿名且可靠的DNS服务。
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"cloudflare": {
|
{
|
||||||
"type": "https", // 如果server上以"https://"开头指明了协议,就不需要写type了
|
"cloudflareTLS": {
|
||||||
"server": "https://1.1.1.1/dns-query",
|
"type": "tls",
|
||||||
"cacheSize": 1000
|
"server": "1.1.1.1",
|
||||||
|
"port": 853, // 不配置时,默认端口为:853
|
||||||
|
"servername": "cloudflare-dns.com", // SNI
|
||||||
|
"cacheSize": 1000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
或
|
||||||
## 6.2. 配置 `DNS-over-TLS`(简称DoT):
|
|
||||||
> 并非被所有DNS支持,但是保证只要能使用就一定匿名且可靠的DNS服务。
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"cloudflareTLS": {
|
{
|
||||||
"type": "tls", // 如果server上以"tls://"开头指明了协议,就不需要写type了
|
"cloudflareTLS": {
|
||||||
"server": "1.1.1.1",
|
"server": "tls://1.1.1.1",
|
||||||
"port": 853, // 不配置时,默认端口为:853
|
"port": 853, // 不配置时,默认端口为:853
|
||||||
"servername": "cloudflare-dns.com", // 需要伪造成的SNI
|
"servername": "cloudflare-dns.com", // SNI
|
||||||
//"sni": "cloudflare-dns.com", // SNI缩写配置
|
//"sni": "cloudflare-dns.com", // SNI缩写配置
|
||||||
"cacheSize": 1000
|
"cacheSize": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
或
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cloudflareTLS": {
|
||||||
|
"server": "tls://1.1.1.1:853",
|
||||||
|
"servername": "cloudflare-dns.com", // SNI
|
||||||
|
//"sni": "cloudflare-dns.com", // SNI缩写配置
|
||||||
|
"cacheSize": 1000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6.3. 配置 `TCP` 的DNS服务:
|
## 6.3. 配置 `TCP` 的DNS服务:
|
||||||
> 并非被所有DNS支持,该方法既不保密也不可靠
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"googleTCP": {
|
{
|
||||||
"type": "tcp", // 如果server上以"tcp://"开头指明了协议,就不需要写type了
|
"googleTCP": {
|
||||||
"server": "8.8.8.8",
|
"type": "tcp",
|
||||||
"port": 53, // 不配置时,默认端口为:53
|
"server": "8.8.8.8",
|
||||||
"cacheSize": 1000
|
"port": 53, // 不配置时,默认端口为:53
|
||||||
|
"cacheSize": 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
或
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"googleTCP": {
|
||||||
|
"server": "tcp://8.8.8.8",
|
||||||
|
"port": 53, // 不配置时,默认端口为:53
|
||||||
|
"cacheSize": 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
或
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"googleTCP": {
|
||||||
|
"server": "tcp://8.8.8.8:53",
|
||||||
|
"cacheSize": 1000,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6.4. 配置 `UDP` 的DNS服务:
|
## 6.4. 配置 `UDP` 的DNS服务:
|
||||||
> 所有DNS服务器均支持UDP方式,但该方法既不保密也不可靠
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"google": {
|
{
|
||||||
"type": "udp", // 如果server上以"udp://"开头指明了协议,就不需要写type了
|
"google": {
|
||||||
"server": "8.8.8.8",
|
"type": "udp",
|
||||||
"port": 53, // 不配置时,默认端口为:53
|
"server": "8.8.8.8",
|
||||||
"cacheSize": 1000
|
"port": 53, // 不配置时,默认端口为:53
|
||||||
|
"cacheSize": 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
或
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"google": {
|
||||||
|
"server": "udp://8.8.8.8",
|
||||||
|
"port": 53, // 不配置时,默认端口为:53
|
||||||
|
"cacheSize": 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
或
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"google": {
|
||||||
|
"server": "udp://8.8.8.8:53",
|
||||||
|
"cacheSize": 1000,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
# 7. DNS设置:
|
# 7. DNS设置:
|
||||||
|
|
||||||
选择哪些域名需要使用指定的DNS(需要先在【DNS服务管理】中设置)获取IP。<br>
|
|
||||||
在config.json中的 `server.dns.mapping:key-value` 中设置,**其中的每个条目**格式如下:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"*.example.com": "your-dns-name"
|
|
||||||
```
|
|
||||||
|
|
||||||
# 8. IP预设置:
|
# 8. IP预设置:
|
||||||
|
|
||||||
为一些DNS无法获取的域名手动设置ip,起到类似于hosts的作用(仅在dev-sidecar开启时生效)。<br>
|
|
||||||
在config.json中的 `server.preSetIpList:object` 中设置,**其中的每个条目**格式如下:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"example.com": {
|
|
||||||
"1.1.1.1": true, // 如果有多个IP,可以继续添加
|
|
||||||
"1.0.0.1": false, // 指定为false时,不使用该IP
|
|
||||||
"2.2.2.2": {
|
|
||||||
"desc": "这样可以合法的在配置中插入注释。上面使用的//注释方式在文件中是不允许的"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
# 9. IP测速:
|
# 9. IP测速:
|
||||||
|
|
||||||
用来对从指定的DNS与IP预设置中获取到的IP测试TCP延迟,也可以用来测试DoH和DoT服务器的可用性,后者操作如下:先在【DNS服务管理】中配置好需要测试的DNS设置,然后在【IP测速】里添加一个没有设置【IP预设置】的辅助域名,并选择使用需检测的DNS进行解析。<br>
|
|
||||||
对于DoH/DoT而言,由于答案不能被篡改和窃听,所以辅助域名要么获得真实IP(说明可用)要么没有收到答案(说明不可用)。该方法不适用于常规TCP/UDP的DNS,因为它们没有加密,即使收到答案也可能被篡改而不可用)。<br>
|
|
||||||
在config.json中的 `server.dns.speedTest:object`中设置,**该条目** 格式如下:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"speedTest": {
|
|
||||||
"hostnameList": [
|
|
||||||
"example1.com",
|
|
||||||
"example2.com"
|
|
||||||
],
|
|
||||||
"dnsProviders": [
|
|
||||||
"your-DNS-name-used-in-test1",
|
|
||||||
"your-DNS-name-used-in-test2"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
|
@ -161,9 +161,32 @@ export default {
|
||||||
},
|
},
|
||||||
registerSpeedTestEvent () {
|
registerSpeedTestEvent () {
|
||||||
const listener = async (event, message) => {
|
const listener = async (event, message) => {
|
||||||
console.log('get speed event', event, message)
|
|
||||||
if (message.key === 'getList') {
|
if (message.key === 'getList') {
|
||||||
this.speedTestList = message.value
|
// 数据验证和标准化
|
||||||
|
const validatedData = {}
|
||||||
|
for (const hostname in message.value) {
|
||||||
|
const item = message.value[hostname]
|
||||||
|
if (!item.backupList) {
|
||||||
|
console.warn(`Missing backupList for ${hostname}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
validatedData[hostname] = {
|
||||||
|
alive: item.alive || [],
|
||||||
|
backupList: item.backupList.map(ipObj => {
|
||||||
|
// 标准化IP地址格式
|
||||||
|
const standardized = {
|
||||||
|
host: ipObj.host,
|
||||||
|
port: ipObj.port || 443,
|
||||||
|
dns: ipObj.dns || 'unknown',
|
||||||
|
time: ipObj.time || null
|
||||||
|
}
|
||||||
|
return standardized
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.speedTestList = validatedData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.$api.ipc.on('speed', listener)
|
this.$api.ipc.on('speed', listener)
|
||||||
|
@ -453,8 +476,10 @@ export default {
|
||||||
<a-tag
|
<a-tag
|
||||||
v-for="(element, index) of item.backupList" :key="index" style="margin:2px;"
|
v-for="(element, index) of item.backupList" :key="index" style="margin:2px;"
|
||||||
:title="element.dns" :color="element.time ? (element.time > config.server.setting.lowSpeedDelay ? 'orange' : 'green') : 'red'"
|
:title="element.dns" :color="element.time ? (element.time > config.server.setting.lowSpeedDelay ? 'orange' : 'green') : 'red'"
|
||||||
|
:class="{'ipv6-tag': element.host.includes(':')}"
|
||||||
>
|
>
|
||||||
{{ element.host }} {{ element.time }}{{ element.time ? 'ms' : '' }} {{ element.dns }}
|
{{ element.host }} {{ element.time }}{{ element.time ? 'ms' : '' }} {{ element.dns }}
|
||||||
|
<span v-if="element.host.includes(':')" class="ipv6-badge">IPv6</span>
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
@ -509,4 +534,103 @@ export default {
|
||||||
width: 45px;
|
width: 45px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.ipv6-tag {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 45px !important;
|
||||||
|
margin-right: 5px !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
min-width: 200px !important;
|
||||||
|
}
|
||||||
|
.ipv6-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 10px;
|
||||||
|
background: #1890ff;
|
||||||
|
color: white;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
line-height: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
.ip-box {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ip-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ip-item .ip-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ip-item .ip-speed {
|
||||||
|
margin-left: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ip-item .ip-speed.success {
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
.ip-item .ip-speed.warning {
|
||||||
|
color: #faad14;
|
||||||
|
}
|
||||||
|
.ip-item .ip-speed.error {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
.domain-box {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.domain-box .domain-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.domain-box .domain-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.domain-box .domain-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
const LRUCache = require('lru-cache')
|
const LRUCache = require('lru-cache')
|
||||||
|
const net = require('node:net')
|
||||||
const log = require('../../utils/util.log.server')
|
const log = require('../../utils/util.log.server')
|
||||||
const matchUtil = require('../../utils/util.match')
|
const matchUtil = require('../../utils/util.match')
|
||||||
const { DynamicChoice } = require('../choice/index')
|
const { DynamicChoice } = require('../choice/index')
|
||||||
|
@ -52,7 +53,7 @@ module.exports = class BaseDNS {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async lookup (hostname) {
|
async lookup (hostname, options = {}) {
|
||||||
try {
|
try {
|
||||||
let ipCache = this.cache.get(hostname)
|
let ipCache = this.cache.get(hostname)
|
||||||
if (ipCache) {
|
if (ipCache) {
|
||||||
|
@ -66,9 +67,9 @@ module.exports = class BaseDNS {
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = new Date()
|
const t = new Date()
|
||||||
let ipList = await this._lookupInternal(hostname)
|
let ipList = await this._lookupInternal(hostname, options)
|
||||||
if (ipList == null) {
|
if (ipList == null) {
|
||||||
// 没有获取到ipv4地址
|
// 没有获取到ip
|
||||||
ipList = []
|
ipList = []
|
||||||
}
|
}
|
||||||
ipList.push(hostname) // 把原域名加入到统计里去
|
ipList.push(hostname) // 把原域名加入到统计里去
|
||||||
|
@ -83,7 +84,7 @@ module.exports = class BaseDNS {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _lookupInternal (hostname) {
|
async _lookupInternal (hostname, options = {}) {
|
||||||
// 获取当前域名的预设IP列表
|
// 获取当前域名的预设IP列表
|
||||||
let hostnamePreSetIpList = matchUtil.matchHostname(this.preSetIpList, hostname, `matched preSetIpList(${this.dnsName})`)
|
let hostnamePreSetIpList = matchUtil.matchHostname(this.preSetIpList, hostname, `matched preSetIpList(${this.dnsName})`)
|
||||||
if (hostnamePreSetIpList && (hostnamePreSetIpList.length > 0 || hostnamePreSetIpList.length === undefined)) {
|
if (hostnamePreSetIpList && (hostnamePreSetIpList.length > 0 || hostnamePreSetIpList.length === undefined)) {
|
||||||
|
@ -94,30 +95,54 @@ module.exports = class BaseDNS {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hostnamePreSetIpList.length > 0) {
|
if (hostnamePreSetIpList.length > 0) {
|
||||||
hostnamePreSetIpList.isPreSet = true
|
const result = []
|
||||||
log.info(`[DNS-over-${this.dnsType} '${this.dnsName}'] 获取到该域名的预设IP列表: ${hostname} - ${JSON.stringify(hostnamePreSetIpList)}`)
|
for (const item of hostnamePreSetIpList) {
|
||||||
return hostnamePreSetIpList
|
if (net.isIP(item)) {
|
||||||
|
// 如果是IP地址,直接使用
|
||||||
|
result.push(item)
|
||||||
|
} else {
|
||||||
|
// 如果是域名,进行DNS解析
|
||||||
|
try {
|
||||||
|
const resolved = await this._lookup(item, options)
|
||||||
|
if (resolved && resolved.length > 0) {
|
||||||
|
result.push(...resolved)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.error(`[DNS-over-${this.dnsType} '${this.dnsName}'] 解析预设域名失败: ${item}`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
result.isPreSet = true
|
||||||
|
log.info(`[DNS-over-${this.dnsType} '${this.dnsName}'] 获取到该域名的预设IP列表: ${hostname} - ${JSON.stringify(result)}`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this._lookup(hostname)
|
return await this._lookup(hostname, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
async _lookup (hostname) {
|
async _lookup (hostname, options = {}) {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
try {
|
try {
|
||||||
const response = await this._doDnsQuery(hostname)
|
const response = await this._doDnsQuery(hostname, options)
|
||||||
const cost = Date.now() - start
|
const cost = Date.now() - start
|
||||||
if (response == null || response.answers == null || response.answers.length == null || response.answers.length === 0) {
|
if (response == null || response.answers == null || response.answers.length == null || response.answers.length === 0) {
|
||||||
// 说明没有获取到ip
|
// 说明没有获取到ip
|
||||||
log.warn(`[DNS-over-${this.dnsType} '${this.dnsName}'] 没有该域名的IP地址: ${hostname}, cost: ${cost} ms, response:`, response)
|
log.warn(`[DNS-over-${this.dnsType} '${this.dnsName}'] 没有该域名的IP地址: ${hostname}, cost: ${cost} ms, response:`, response)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const ret = response.answers.filter(item => item.type === 'A').map(item => item.data)
|
|
||||||
|
// 根据查询类型过滤结果
|
||||||
|
const type = options.family === 6 ? 'AAAA' : 'A'
|
||||||
|
const ret = response.answers.filter(item => item.type === type).map(item => item.data)
|
||||||
|
|
||||||
if (ret.length === 0) {
|
if (ret.length === 0) {
|
||||||
log.info(`[DNS-over-${this.dnsType} '${this.dnsName}'] 没有该域名的IPv4地址: ${hostname}, cost: ${cost} ms`)
|
log.info(`[DNS-over-${this.dnsType} '${this.dnsName}'] 没有该域名的IPv${options.family === 6 ? '6' : '4'}地址: ${hostname}, cost: ${cost} ms`)
|
||||||
} else {
|
} else {
|
||||||
log.info(`[DNS-over-${this.dnsType} '${this.dnsName}'] 获取到该域名的IPv4地址: ${hostname} - ${JSON.stringify(ret)}, cost: ${cost} ms`)
|
log.info(`[DNS-over-${this.dnsType} '${this.dnsName}'] 获取到该域名的IPv${options.family === 6 ? '6' : '4'}地址: ${hostname} - ${JSON.stringify(ret)}, cost: ${cost} ms`)
|
||||||
}
|
}
|
||||||
return ret
|
return ret
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -8,9 +8,19 @@ module.exports = class DNSOverHTTPS extends BaseDNS {
|
||||||
constructor (dnsName, cacheSize, preSetIpList, dnsServer) {
|
constructor (dnsName, cacheSize, preSetIpList, dnsServer) {
|
||||||
super(dnsName, 'HTTPS', cacheSize, preSetIpList)
|
super(dnsName, 'HTTPS', cacheSize, preSetIpList)
|
||||||
this.dnsServer = dnsServer
|
this.dnsServer = dnsServer
|
||||||
|
this.isIPv6 = dnsServer.includes(':') && dnsServer.includes('[') && dnsServer.includes(']')
|
||||||
}
|
}
|
||||||
|
|
||||||
async _doDnsQuery (hostname) {
|
async _doDnsQuery (hostname, options = {}) {
|
||||||
return await dohQueryAsync({ url: this.dnsServer }, [{ type: 'A', name: hostname }])
|
return await dohQueryAsync(
|
||||||
|
{
|
||||||
|
url: this.dnsServer,
|
||||||
|
family: this.isIPv6 ? 6 : 4
|
||||||
|
},
|
||||||
|
[{
|
||||||
|
type: options.family === 6 ? 'AAAA' : 'A',
|
||||||
|
name: hostname
|
||||||
|
}]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,16 +4,17 @@ const dnsPacket = require('dns-packet')
|
||||||
const randi = require('random-int')
|
const randi = require('random-int')
|
||||||
const BaseDNS = require('./base')
|
const BaseDNS = require('./base')
|
||||||
|
|
||||||
const defaultPort = 53 // UDP类型的DNS服务默认端口号
|
const defaultPort = 53 // TCP类型的DNS服务默认端口号
|
||||||
|
|
||||||
module.exports = class DNSOverTCP extends BaseDNS {
|
module.exports = class DNSOverTCP extends BaseDNS {
|
||||||
constructor (dnsName, cacheSize, preSetIpList, dnsServer, dnsServerPort) {
|
constructor (dnsName, cacheSize, preSetIpList, dnsServer, dnsServerPort) {
|
||||||
super(dnsName, 'TCP', cacheSize, preSetIpList)
|
super(dnsName, 'TCP', cacheSize, preSetIpList)
|
||||||
this.dnsServer = dnsServer
|
this.dnsServer = dnsServer
|
||||||
this.dnsServerPort = Number.parseInt(dnsServerPort) || defaultPort
|
this.dnsServerPort = Number.parseInt(dnsServerPort) || defaultPort
|
||||||
|
this.isIPv6 = dnsServer.includes(':') && dnsServer.includes('[') && dnsServer.includes(']')
|
||||||
}
|
}
|
||||||
|
|
||||||
_doDnsQuery (hostname) {
|
_doDnsQuery (hostname, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 构造 DNS 查询报文
|
// 构造 DNS 查询报文
|
||||||
const packet = dnsPacket.encode({
|
const packet = dnsPacket.encode({
|
||||||
|
@ -21,7 +22,7 @@ module.exports = class DNSOverTCP extends BaseDNS {
|
||||||
type: 'query',
|
type: 'query',
|
||||||
id: randi(0x0, 0xFFFF),
|
id: randi(0x0, 0xFFFF),
|
||||||
questions: [{
|
questions: [{
|
||||||
type: 'A',
|
type: options.family === 6 ? 'AAAA' : 'A',
|
||||||
name: hostname,
|
name: hostname,
|
||||||
}],
|
}],
|
||||||
})
|
})
|
||||||
|
@ -30,6 +31,7 @@ module.exports = class DNSOverTCP extends BaseDNS {
|
||||||
const tcpClient = net.createConnection({
|
const tcpClient = net.createConnection({
|
||||||
host: this.dnsServer,
|
host: this.dnsServer,
|
||||||
port: this.dnsServerPort,
|
port: this.dnsServerPort,
|
||||||
|
family: this.isIPv6 ? 6 : 4
|
||||||
}, () => {
|
}, () => {
|
||||||
// TCP DNS 报文前需添加 2 字节长度头
|
// TCP DNS 报文前需添加 2 字节长度头
|
||||||
const lengthBuffer = Buffer.alloc(2)
|
const lengthBuffer = Buffer.alloc(2)
|
||||||
|
|
|
@ -9,19 +9,21 @@ module.exports = class DNSOverTLS extends BaseDNS {
|
||||||
this.dnsServer = dnsServer
|
this.dnsServer = dnsServer
|
||||||
this.dnsServerPort = Number.parseInt(dnsServerPort) || defaultPort
|
this.dnsServerPort = Number.parseInt(dnsServerPort) || defaultPort
|
||||||
this.dnsServerName = dnsServerName
|
this.dnsServerName = dnsServerName
|
||||||
|
this.isIPv6 = dnsServer.includes(':') && dnsServer.includes('[') && dnsServer.includes(']')
|
||||||
}
|
}
|
||||||
|
|
||||||
async _doDnsQuery (hostname) {
|
async _doDnsQuery (hostname, options = {}) {
|
||||||
const options = {
|
const queryOptions = {
|
||||||
host: this.dnsServer,
|
host: this.dnsServer,
|
||||||
port: this.dnsServerPort,
|
port: this.dnsServerPort,
|
||||||
servername: this.dnsServerName || this.dnsServer,
|
servername: this.dnsServerName || this.dnsServer,
|
||||||
|
family: this.isIPv6 ? 6 : 4,
|
||||||
|
|
||||||
name: hostname,
|
name: hostname,
|
||||||
klass: 'IN',
|
klass: 'IN',
|
||||||
type: 'A',
|
type: options.family === 6 ? 'AAAA' : 'A',
|
||||||
}
|
}
|
||||||
|
|
||||||
return await dnstls.query(options)
|
return await dnstls.query(queryOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ module.exports = class DNSOverUDP extends BaseDNS {
|
||||||
this.socketType = this.isIPv6 ? 'udp6' : 'udp4'
|
this.socketType = this.isIPv6 ? 'udp6' : 'udp4'
|
||||||
}
|
}
|
||||||
|
|
||||||
_doDnsQuery (hostname) {
|
_doDnsQuery (hostname, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 构造 DNS 查询报文
|
// 构造 DNS 查询报文
|
||||||
const packet = dnsPacket.encode({
|
const packet = dnsPacket.encode({
|
||||||
|
@ -23,7 +23,7 @@ module.exports = class DNSOverUDP extends BaseDNS {
|
||||||
type: 'query',
|
type: 'query',
|
||||||
id: randi(0x0, 0xFFFF),
|
id: randi(0x0, 0xFFFF),
|
||||||
questions: [{
|
questions: [{
|
||||||
type: 'A',
|
type: options.family === 6 ? 'AAAA' : 'A',
|
||||||
name: hostname,
|
name: hostname,
|
||||||
}],
|
}],
|
||||||
})
|
})
|
||||||
|
|
|
@ -11,52 +11,70 @@ module.exports = {
|
||||||
if (tester) {
|
if (tester) {
|
||||||
const aliveIpObj = tester.pickFastAliveIpObj()
|
const aliveIpObj = tester.pickFastAliveIpObj()
|
||||||
if (aliveIpObj) {
|
if (aliveIpObj) {
|
||||||
log.info(`----- ${action}: ${hostname}, use alive ip from dns '${aliveIpObj.dns}': ${aliveIpObj.host}${target} -----`)
|
const family = aliveIpObj.host.includes(':') ? 6 : 4
|
||||||
if (res) {
|
if (res) {
|
||||||
res.setHeader('DS-DNS-Lookup', `IpTester: ${aliveIpObj.host} ${aliveIpObj.dns === '预设IP' ? 'PreSet' : aliveIpObj.dns}`)
|
res.setHeader('DS-DNS-Lookup', `IpTester: ${aliveIpObj.host} ${aliveIpObj.dns === '预设IP' ? 'PreSet' : aliveIpObj.dns}`)
|
||||||
}
|
}
|
||||||
callback(null, aliveIpObj.host, 4)
|
callback(null, aliveIpObj.host, family)
|
||||||
return
|
return
|
||||||
} else {
|
|
||||||
log.info(`----- ${action}: ${hostname}, no alive ip${target}, tester: { "ready": ${tester.ready}, "backupList": ${JSON.stringify(tester.backupList)} }`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dns.lookup(hostname).then((ip) => {
|
|
||||||
|
// 优先尝试IPv6查询
|
||||||
|
dns.lookup(hostname, { family: 6 }).then((ip) => {
|
||||||
|
if (ip && ip !== hostname) {
|
||||||
|
if (isDnsIntercept) {
|
||||||
|
isDnsIntercept.dns = dns
|
||||||
|
isDnsIntercept.hostname = hostname
|
||||||
|
isDnsIntercept.ip = ip
|
||||||
|
}
|
||||||
|
if (res) {
|
||||||
|
res.setHeader('DS-DNS-Lookup', `DNS: ${ip} ${dns.dnsName === '预设IP' ? 'PreSet' : dns.dnsName}`)
|
||||||
|
}
|
||||||
|
callback(null, ip, 6)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到IPv4查询
|
||||||
|
return dns.lookup(hostname)
|
||||||
|
}).then((ip) => {
|
||||||
|
if (!ip || ip === hostname) {
|
||||||
|
// 使用默认dns
|
||||||
|
return defaultDns.lookup(hostname, options, callback)
|
||||||
|
}
|
||||||
|
|
||||||
if (isDnsIntercept) {
|
if (isDnsIntercept) {
|
||||||
isDnsIntercept.dns = dns
|
isDnsIntercept.dns = dns
|
||||||
isDnsIntercept.hostname = hostname
|
isDnsIntercept.hostname = hostname
|
||||||
isDnsIntercept.ip = ip
|
isDnsIntercept.ip = ip
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ip !== hostname) {
|
// 判断是否为测速失败的IP
|
||||||
// 判断是否为测速失败的IP,如果是,则不使用当前IP
|
let isTestFailedIp = false
|
||||||
let isTestFailedIp = false
|
if (tester && tester.ready && tester.backupList && tester.backupList.length > 0) {
|
||||||
if (tester && tester.ready && tester.backupList && tester.backupList.length > 0) {
|
for (let i = 0; i < tester.backupList.length; i++) {
|
||||||
for (let i = 0; i < tester.backupList.length; i++) {
|
const item = tester.backupList[i]
|
||||||
const item = tester.backupList[i]
|
if (item.host === ip) {
|
||||||
if (item.host === ip) {
|
if (item.time == null) {
|
||||||
if (item.time == null) {
|
isTestFailedIp = true
|
||||||
isTestFailedIp = true
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isTestFailedIp === false) {
|
}
|
||||||
log.info(`----- ${action}: ${hostname}, use ip from dns '${dns.dnsName}': ${ip}${target} -----`)
|
|
||||||
if (res) {
|
if (!isTestFailedIp) {
|
||||||
res.setHeader('DS-DNS-Lookup', `DNS: ${ip} ${dns.dnsName === '预设IP' ? 'PreSet' : dns.dnsName}`)
|
const family = ip.includes(':') ? 6 : 4
|
||||||
}
|
if (res) {
|
||||||
callback(null, ip, 4)
|
res.setHeader('DS-DNS-Lookup', `DNS: ${ip} ${dns.dnsName === '预设IP' ? 'PreSet' : dns.dnsName}`)
|
||||||
return
|
|
||||||
} else {
|
|
||||||
// 使用默认dns
|
|
||||||
log.info(`----- ${action}: ${hostname}, use hostname by default DNS: ${hostname}, skip test failed ip from dns '${dns.dnsName}: ${ip}'${target}, options:`, options)
|
|
||||||
}
|
}
|
||||||
|
callback(null, ip, family)
|
||||||
} else {
|
} else {
|
||||||
// 使用默认dns
|
// 使用默认dns
|
||||||
log.info(`----- ${action}: ${hostname}, use hostname by default DNS: ${hostname}${target}, options:`, options, ', dns:', dns)
|
defaultDns.lookup(hostname, options, callback)
|
||||||
}
|
}
|
||||||
|
}).catch((e) => {
|
||||||
|
log.error(`DNS lookup error for ${hostname}:`, e)
|
||||||
defaultDns.lookup(hostname, options, callback)
|
defaultDns.lookup(hostname, options, callback)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,7 +79,43 @@ class SpeedTester {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFromOneDns (dns) {
|
async getFromOneDns (dns) {
|
||||||
return await dns._lookupInternal(this.hostname)
|
const results = []
|
||||||
|
let isPreSet = false
|
||||||
|
|
||||||
|
// 优先尝试IPv6查询
|
||||||
|
try {
|
||||||
|
const ipv6Result = await dns._lookupInternal(this.hostname, { family: 6 })
|
||||||
|
if (ipv6Result && ipv6Result.length > 0) {
|
||||||
|
isPreSet = ipv6Result.isPreSet === true
|
||||||
|
// 标准化IPv6地址格式
|
||||||
|
const standardized = ipv6Result.map(ip => {
|
||||||
|
// 确保IPv6地址格式统一
|
||||||
|
if (ip.includes(':')) {
|
||||||
|
return ip.toLowerCase().replace(/\[|\]/g, '')
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
})
|
||||||
|
results.push(...standardized)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// IPv6查询失败,继续尝试IPv4
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试IPv4查询
|
||||||
|
try {
|
||||||
|
const ipv4Result = await dns._lookupInternal(this.hostname)
|
||||||
|
if (ipv4Result) {
|
||||||
|
isPreSet = isPreSet || ipv4Result.isPreSet === true
|
||||||
|
results.push(...ipv4Result)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// IPv4查询失败
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPreSet) {
|
||||||
|
results.isPreSet = true
|
||||||
|
}
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
async test () {
|
async test () {
|
||||||
|
@ -87,8 +123,6 @@ class SpeedTester {
|
||||||
const newBackupList = [...newList, ...this.backupList]
|
const newBackupList = [...newList, ...this.backupList]
|
||||||
this.backupList = _.unionBy(newBackupList, 'host')
|
this.backupList = _.unionBy(newBackupList, 'host')
|
||||||
this.testCount++
|
this.testCount++
|
||||||
|
|
||||||
log.info('[speed]', this.hostname, '➜ ip-list:', this.backupList)
|
|
||||||
await this.testBackups()
|
await this.testBackups()
|
||||||
if (config.notify) {
|
if (config.notify) {
|
||||||
config.notify({ key: 'test' })
|
config.notify({ key: 'test' })
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
const net = require('net');
|
||||||
|
const { setTimeout } = require('timers/promises');
|
||||||
|
|
||||||
|
// 测试的IPv6地址和端口
|
||||||
|
const TEST_HOST = '6.ipw.cn';
|
||||||
|
const TEST_PORT = 80;
|
||||||
|
const TIMEOUT = 5000; // 5秒超时
|
||||||
|
|
||||||
|
async function testIPv6Connection() {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
|
||||||
|
// 设置超时
|
||||||
|
socket.setTimeout(TIMEOUT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试连接
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
socket.on('connect', () => {
|
||||||
|
const { address, port } = socket.address();
|
||||||
|
console.log(`成功连接到 ${TEST_HOST} 的IPv6地址 [${address}]:${port}`);
|
||||||
|
socket.end();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(new Error('连接超时'));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.connect(TEST_PORT, TEST_HOST);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('IPv6连接测试失败:', err.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行测试
|
||||||
|
testIPv6Connection()
|
||||||
|
.then(success => {
|
||||||
|
console.log(`IPv6连接测试结果: ${success ? '成功' : '失败'}`);
|
||||||
|
process.exit(success ? 0 : 1);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('测试过程中发生错误:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
Loading…
Reference in New Issue