diff --git a/.npmrc b/.npmrc index 6737bb9a6f..f3906d7a20 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1,5 @@ shamefully-hoist=true +phantomjs_cdnurl=https://npmmirror.com/mirrors/phantomjs # Native modules (e.g. @parcel/watcher) require C++17. # In CI, CXXFLAGS=-std=c++17 (Linux/macOS) and CL=/std:c++17 (Windows) are # set before `pnpm install` so node-gyp compiles with the correct standard. diff --git a/README.md b/README.md index b7d78d97cc..8f128f089f 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ > 1. ~~[hub.fastgit.org](https://hub.fastgit.org/) (2024/11/18:这个好像失效了?)~~ > 2. ~~[github.com.cnpmjs.org](https://github.com.cnpmjs.org/) 这个很容易超限(2024/11/18:这个好像失效了?)~~ > 3. [bgithub.xyz](https://bgithub.xyz/)(edge浏览器可能报毒) +> 4. [kkgithub.com](https://kkgithub.com/)(目前正在维护中) ## 五、api @@ -375,10 +376,24 @@ npm config delete https-proxy ### 8.1、准备环境 -#### 1)安装 `nodejs` +#### 1)安装 `nodejs` 及其他环境 推荐安装 nodejs `22.x.x` 的版本,其他版本未做测试 +Windows上需要msvc,推荐使用VS 2022(node-gyp对VS 2026支持可能存在问题),安装时选择C++桌面开发工作负载即可。 + +另外还需要带distutils的python,推荐安装自带setuptools的python 3.11版本。如果本地有uv,则可以简单的运行以下命令 + +```shell +uv init . +uv sync +.venv/Scripts/activate.ps1 # for windows pwsh +.venv/Scripts/activate.bat # for windows cmd +source .venv/bin/activate # for linux/mac +``` + +这会根据.python-version文件自动安装python 3.11版本。如不想使用python 3.11,也可删除.python-version文件,pyproject.toml已经指定了所需依赖。 + #### 2)安装 `pnpm` 运行如下命令即可安装所需依赖: diff --git a/packages/cli/package.json b/packages/cli/package.json index 8feb17de56..f5218a8fb9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@docmirror/dev-sidecar-cli", - "version": "2.0.2", + "version": "2.1.0", "private": false, "description": "给开发者的加速代理工具", "author": "docmirror.cn", diff --git a/packages/core/package.json b/packages/core/package.json index 678b64bf58..f634204541 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@docmirror/dev-sidecar", - "version": "2.0.2", + "version": "2.1.0", "private": false, "description": "给开发者的加速代理工具", "author": "docmirror.cn", diff --git a/packages/core/src/config/index.js b/packages/core/src/config/index.js index 702d4e7def..4036d94abe 100644 --- a/packages/core/src/config/index.js +++ b/packages/core/src/config/index.js @@ -228,7 +228,15 @@ const defaultConfig = { }, 'fonts.googleapis.com': { '.*': { - proxy: 'fonts.loli.net', + proxy: 'fonts.googleapis.cn', + backup: ['fonts.loli.net'], + test: 'https://fonts.googleapis.com/css?family=Oswald', + }, + }, + 'fonts.gstatic.com': { + '.*': { + proxy: 'fonts-gstatic.proxy.ustclug.org', + backup: ['gstatic.loli.net'], test: 'https://fonts.googleapis.com/css?family=Oswald', }, }, @@ -241,12 +249,6 @@ const defaultConfig = { 'themes.googleusercontent.com': { '.*': { proxy: 'google-themes.proxy.ustclug.org' }, }, - // 'fonts.gstatic.com': { - // '.*': { - // proxy: 'gstatic.loli.net', - // backup: ['fonts-gstatic.proxy.ustclug.org'] - // } - // }, 'clients*.google.com': { '.*': { abort: false, desc: '设置abort:true可以快速失败,节省时间' } }, 'www.googleapis.com': { '.*': { abort: false, desc: '设置abort:true可以快速失败,节省时间' } }, 'lh*.googleusercontent.com': { '.*': { abort: false, desc: '设置abort:true可以快速失败,节省时间' } }, diff --git a/packages/core/src/modules/plugin/free-eye/README.md b/packages/core/src/modules/plugin/free-eye/README.md new file mode 100644 index 0000000000..d416297d8a --- /dev/null +++ b/packages/core/src/modules/plugin/free-eye/README.md @@ -0,0 +1,74 @@ +# 网络审查检测器 + +FreeEye 是一个用 JavaScript 编写的网络审查检测器,自动化检测网络环境并推荐可能的规避方法。 + +为中国大陆用户设计,但也可用于其他地区。 + +希望使得用户能够使用本工具回答以下问题: + +1. 我的网络是被审查了,还是只是出现了异常故障? +2. 使用了哪些审查手段? +3. 有哪些规避方法可以绕过这些审查? + +## 使用方法 + +前提条件是你需要在设备上安装 `node.js`。 + +启动向导的方法: + +```bash +git clone https://github.com/cute-omega/free-eye.git +cd free-eye +npm install +npm start +``` + +(如果你不准备进行开发,可以跳过 `npm install`并直接运行 `npm start`;中国大陆用户可能需要设置npm镜像) + +不同测试的代码位于 `checkpoints/` 目录中。每个测试都有唯一的 `tag` 标识。单个测试的参数在 `config.json` 文件中设置,使用测试的 tag 作为键: + +```json +{ + "tag": { + // 测试特定的参数 + } + // ... +} +``` + +## 测试 + +### Route(路由) + +通过尝试创建一个套接字并连接到一个非本地地址,检测设备是否具有互联网连通性。 + +### DNS + +使用系统的 DNS 解析器尝试解析允许和被封锁的主机名。测试被封锁主机名是否存在 DNS 缓存投毒。 + +### TCP + +尝试与已知允许和已知被封锁的 IP 地址建立 TCP 连接。 + +### TLS + +尝试与已知允许但可能遭受审查的 IP 地址(例如对中国用户来说的“干净”的外国 IP)完成 TLS 握手。测试内容包括: + +- 不带任何 SNI 的握手 +- 带已知允许的 SNI 的握手 +- 带已知被封锁的 SNI 的握手 + +还测试将 TLS 记录分片作为一种规避方法,通过尝试对被封锁的 SNI 进行握手但分片 ClientHello 来实现。 + +## 编写你自己的测试 + +如果你想编写自定义测试,只需实现 `template.js` 中描述的接口,将测试模块保存到 `checkpoints/` 目录,并在 `config.json` 中添加该测试的参数。 + +## 相关项目 + +本项目受到来自 [wallpunch/wizard](https://github.com/wallpunch/wizard) 的启发; +用作 [docmirror/dev-sidecar](https://github.com/docmirror/dev-sidecar) 中的网络检测插件。 + +--- + +不论在哪里,人们的目光都应该是自由的。 diff --git a/packages/core/src/modules/plugin/free-eye/checkpoints/dns.js b/packages/core/src/modules/plugin/free-eye/checkpoints/dns.js new file mode 100644 index 0000000000..25dddc3757 --- /dev/null +++ b/packages/core/src/modules/plugin/free-eye/checkpoints/dns.js @@ -0,0 +1,152 @@ +import { randomBytes } from 'node:crypto' +import { promises as dns } from 'node:dns' +import { TestGroup } from '../template.js' +import { FAMILY_VALUES, getCensorsString, getResultIcon } from '../utils.js' + +class DnsTester extends TestGroup { + /** + * A test group to assess the system's DNS resolver + */ + constructor (globalConfig, globalResults) { + super(globalConfig, globalResults, 'DNS') + } + + static getTestTag () { + return 'DNS' + } + + static getPrereqs () { + return ['Route'] + } + + getDefaultResults () { + return { + IPv4: false, + IPv6: false, + } + } + + checkIfShouldSkip (globalResults) { + /** + * Skip this test if all routing tests failed + */ + let skip = true + for (const family in FAMILY_VALUES) { + if (Object.values(globalResults.Route[family]).includes(true)) { + this.results[family] = {} + skip = false + } + } + if (skip) { + return 'no routable networks' + } + return null + } + + async startTest () { + this.testPrefix = `${randomBytes(30).toString('hex')}.` + console.log(`Using POISON test prefix: ${this.testPrefix}`) + + for (const family in FAMILY_VALUES) { + if (this.results[family] === false) { + continue // not routable + } + for (const host of this.config.allow) { + this.startResolveTest(host, family, false) + } + for (const host of this.config.block) { + this.startResolveTest(host, family, true) + } + } + } + + async startResolveTest (host, family, testPoison) { + const testPrefs = [''] + if (testPoison) { + testPrefs.push(this.testPrefix) + } + for (const prefix of testPrefs) { + this.startTestThread( + DnsTester.resolveThread, + [family, prefix + host], + `${family}, ${host}${prefix ? ', POISON' : ''}`, + this.config.timeout, + ) + } + } + + logResults () { + let resStr = '' + for (const [family, results] of Object.entries(this.results)) { + if (results === false) { + continue + } + this.results[family] = true + const allowList = this.config.allow + const allowOkCnt = allowList.reduce((sum, host) => sum + (results[host] || 0), 0) + const allowTotal = allowList.length + let resIcon + if (allowOkCnt === allowTotal) { // DNS can resolve + resIcon = getResultIcon(true) + } else if (allowOkCnt === 0) { // DNS can't resolve + resIcon = getResultIcon(false) + this.results[family] = false + } else { // test inconclusive + resIcon = getResultIcon(null, `resolved ${allowOkCnt}/${allowTotal}`) + } + resStr += `${family}: DNS ${resIcon}\n` + + const censors = [] + const blockList = this.config.block + const blockOkCnt = blockList.reduce((sum, host) => sum + (results[host] || 0), 0) + const blockTotal = blockList.length + if (blockOkCnt < blockTotal) { + censors.push(`DNS blocking: ${blockTotal - blockOkCnt}/${blockTotal} blocked`) + } + + const blockPoisonCnt = blockList.reduce((sum, host) => sum + (results[this.testPrefix + host] || 0), 0) + if (blockPoisonCnt > 0) { + censors.push(`DNS poisoning: ${blockPoisonCnt}/${blockTotal} poisoned`) + } + resStr += getCensorsString(censors) + } + return resStr + } + + static async resolveThread (timeout, logger, results, family, host) { + if (results[family] === false) { + return // Not routable + } + results[family][host] = 0 // default to failed + try { + let records + if (FAMILY_VALUES[family] === 4) { + records = await dns.resolve4(host) + } else { + records = await dns.resolve6(host) + } + if (!timeout.isSet) { + logger(`Got ${records.length} records`) + results[family][host] = 1 + } else { + logger(`Timeout occurred for ${host}`) + } + } catch (error) { + if (!timeout.isSet) { + logger(`Failed with error: ${error.message}`) + results[family][host] = 0 // explicitly set to failed + } else { + logger(`Timeout occurred for ${host}`) + } + } + } +} + +function getClientTests () { + return [DnsTester] +} + +export default { + DnsTester, + getClientTests, +} diff --git a/packages/core/src/modules/plugin/free-eye/checkpoints/route.js b/packages/core/src/modules/plugin/free-eye/checkpoints/route.js new file mode 100644 index 0000000000..4eb1100561 --- /dev/null +++ b/packages/core/src/modules/plugin/free-eye/checkpoints/route.js @@ -0,0 +1,142 @@ +import { createSocket } from 'node:dgram' +import { createConnection } from 'node:net' +import { TestGroup } from '../template.js' +import { FAMILY_VALUES, getResultIcon, PROTOCOL_VALUES } from '../utils.js' + +const ROUTE_TEST_DGRAM = Buffer.from('122401000000000000000006676f6f676c6503636f6d0000010001', 'hex') + +class RouteTester extends TestGroup { + /** + * A test group to assess the system's routing capability + */ + constructor (globalConfig, globalResults) { + super(globalConfig, globalResults, 'Route') + } + + static getTestTag () { + return 'Route' + } + + getDefaultResults () { + return { + IPv4: { + TCP: false, + UDP: false, + }, + IPv6: { + TCP: false, + UDP: false, + }, + } + } + + async startTest () { + for (const family in FAMILY_VALUES) { + for (const protocol in PROTOCOL_VALUES) { + const dst = [this.config.addrs[family], this.config.port] + this.startTestThread( + RouteTester.routeThread, + [family, protocol, dst], + `${family}, ${protocol}`, + this.config.timeout, + ) + } + } + } + + logResults () { + let resStr = '' + for (const family in FAMILY_VALUES) { + resStr += `${family}: ` + for (const protocol in PROTOCOL_VALUES) { + const resIcon = this.results[family][protocol] ? getResultIcon(true) : getResultIcon(false) + resStr += `${protocol} ${resIcon} ` + } + resStr += '\n' + } + return resStr + } + + static async routeThread (timeout, logger, results, family, protocol, dst) { + let sock + try { + logger('Creating socket...') + if (protocol === 'TCP') { + sock = createConnection({ + host: dst[0], + port: dst[1], + family: FAMILY_VALUES[family] === 4 ? 4 : 6, + }) + } else { // UDP + sock = createSocket({ + type: FAMILY_VALUES[family] === 4 ? 'udp4' : 'udp6', + }) + } + + if (timeout.isSet) { + if (sock) { + if (protocol === 'UDP') { + sock.close() + } else { + sock.destroy() + } + } + return + } + + if (protocol === 'TCP') { + logger(`Connecting socket to ${dst[0]}:${dst[1]}`) + await new Promise((resolve, reject) => { + sock.connect(dst[1], dst[0], resolve) + sock.on('error', reject) + sock.on('timeout', () => reject(new Error('timeout'))) + }) + } else { // UDP + logger(`Sending datagram to ${dst[0]}:${dst[1]}`) + sock.send(ROUTE_TEST_DGRAM, 0, ROUTE_TEST_DGRAM.length, dst[1], dst[0]) + } + if (protocol === 'UDP') { + sock.close() + } else { + sock.destroy() + } + } catch (error) { + if (!timeout.isSet) { + logger(`Failed with exception: ${error.message}`) + // For routing test, connection errors often mean the network is routable + // but the service is not available (e.g., TCP to DNS port) + if (error.code === 'ECONNREFUSED' || error.code === 'EINVAL' || error.code === 'ENETUNREACH' || error.message.includes('timeout')) { + logger('Routing successful!') + results[family][protocol] = true + if (sock) { + if (protocol === 'UDP') { + sock.close() + } else { + sock.destroy() + } + } + return + } + } + if (sock) { + if (protocol === 'UDP') { + sock.close() + } else { + sock.destroy() + } + } + return + } + logger('Routing successful!') + results[family][protocol] = true + } +} + +function getClientTests () { + return [RouteTester] +} + +export default { + RouteTester, + getClientTests, +} diff --git a/packages/core/src/modules/plugin/free-eye/checkpoints/tcp.js b/packages/core/src/modules/plugin/free-eye/checkpoints/tcp.js new file mode 100644 index 0000000000..32c353f69b --- /dev/null +++ b/packages/core/src/modules/plugin/free-eye/checkpoints/tcp.js @@ -0,0 +1,164 @@ +import { createConnection } from 'node:net' +import { TestGroup } from '../template.js' +import { FAMILY_VALUES, getCensorsString, getResultIcon } from '../utils.js' + +class TcpTester extends TestGroup { + /** + * A test group to assess the system's ability to establish + * TCP connections + */ + constructor (globalConfig, globalResults) { + super(globalConfig, globalResults, 'TCP') + } + + static getTestTag () { + return 'TCP' + } + + static getPrereqs () { + return ['Route'] + } + + getDefaultResults () { + return { + IPv4: false, + IPv6: false, + } + } + + checkIfShouldSkip (globalResults) { + /** + * Skip if TCP routing tests all failed + */ + let skip = true + for (const family in FAMILY_VALUES) { + if (globalResults.Route[family].TCP) { + this.results[family] = {} + skip = false + } + } + if (skip) { + return 'no routable TCP networks' + } + return null + } + + async startTest () { + for (const family in FAMILY_VALUES) { + if (this.results[family] === false) { + continue // not routable + } + for (const port of this.config.ports) { + this.results[family][port] = {} + const addrs = this.config.addrs[family] + for (const key of ['allow', 'block']) { + for (const addr of addrs[key]) { + this.startTestThread( + TcpTester.tcpThread, + [family, port, addr], + `${key}, ${addr}:${port}`, + this.config.timeout, + ) + } + } + } + } + } + + logResults () { + let resStr = '' + for (const [family, portRes] of Object.entries(this.results)) { + if (portRes === false) { + continue + } + resStr += `${family}: ` + const censors = [] + const addrs = this.config.addrs[family] + for (const [port, results] of Object.entries(portRes)) { + const dstTag = `TCP:${port}` + const allowList = addrs.allow + const allowOkCnt = allowList.reduce((sum, addr) => sum + (results[addr] === null ? 1 : 0), 0) + const allowTotal = allowList.length + let resIcon + if (allowOkCnt === allowTotal) { // can connect + resIcon = getResultIcon(true) + } else if (allowOkCnt === 0) { // can't connect + resIcon = getResultIcon(false) + } else { // test inconclusive + resIcon = getResultIcon(null, `connected ${allowOkCnt}/${allowTotal}`) + } + resStr += `${dstTag} ${resIcon} ` + + const blockList = addrs.block + const blocksTotal = blockList.length + + const timeoutCnt = blockList.reduce((sum, addr) => sum + (results[addr] === 'timeout' ? 1 : 0), 0) + if (timeoutCnt > 0) { + censors.push(`Blocked ${dstTag} handshake timeouts: ${timeoutCnt}/${blocksTotal} timeouts`) + } + + const errorCnt = blockList.reduce((sum, addr) => sum + (results[addr] === 'error' ? 1 : 0), 0) + if (errorCnt > 0) { + censors.push(`Blocked ${dstTag} handshake errors: ${errorCnt}/${blocksTotal} errors`) + } + } + resStr += `\n${getCensorsString(censors)}` + } + return resStr + } + + static async tcpThread (timeout, logger, results, family, port, addr) { + results[family][port][addr] = 'timeout' + + const sock = createConnection({ + host: addr, + port, + family: FAMILY_VALUES[family] === 4 ? 4 : 6, + }) + + // Set socket timeout + sock.setTimeout(1000) // 1 second timeout + + if (timeout.isSet) { + sock.destroy() + return + } + + try { + const dst = `${addr}:${port}` + logger(`Connecting socket to ${dst}`) + await new Promise((resolve, reject) => { + sock.on('connect', resolve) + sock.on('error', reject) + sock.on('timeout', () => reject(new Error('timeout'))) + }) + if (timeout.isSet) { + sock.destroy() + return + } + } catch (error) { + if (!timeout.isSet) { + logger(`Failed with exception: ${error.message}`) + if (error.message === 'timeout') { + results[family][port][addr] = 'timeout' + } else { + results[family][port][addr] = 'error' + } + } + sock.destroy() + return + } + logger('Connected!') + results[family][port][addr] = null + sock.destroy() + } +} + +function getClientTests () { + return [TcpTester] +} + +export default { + TcpTester, + getClientTests, +} diff --git a/packages/core/src/modules/plugin/free-eye/checkpoints/tls.js b/packages/core/src/modules/plugin/free-eye/checkpoints/tls.js new file mode 100644 index 0000000000..dd09610db7 --- /dev/null +++ b/packages/core/src/modules/plugin/free-eye/checkpoints/tls.js @@ -0,0 +1,190 @@ +import net from 'node:net' +import tls from 'node:tls' +import { TestGroup } from '../template.js' +import { FAMILY_VALUES, getCensorsString, getResultIcon, LogColors } from '../utils.js' + +const SNI_TEST_STRATEGIES = ['none', 'allow', 'block', 'frag'] + +class TlsTester extends TestGroup { + /** + * A test group to assess the system's ability to + * establish TLS connections + */ + constructor (globalConfig, globalResults) { + super(globalConfig, globalResults, 'TLS') + } + + static getTestTag () { + return 'TLS' + } + + static getPrereqs () { + return ['Route', 'TCP'] + } + + getDefaultResults () { + return { + IPv4: false, + IPv6: false, + } + } + + checkIfShouldSkip (globalResults) { + /** + * Skip if TCP test failed + */ + let skip = true + for (const family in FAMILY_VALUES) { + const tcpRes = globalResults.TCP && globalResults.TCP[family] + if (tcpRes && tcpRes[443] && Object.values(tcpRes[443]).includes(null)) { + this.results[family] = {} + skip = false + } + } + if (skip) { + return 'cannot make TCP connections' + } + return null + } + + async startTest () { + for (const family in FAMILY_VALUES) { + if (this.results[family] === false) { + continue + } + const addr = this.config.addrs[family] + if (!addr) { + this.results[family] = false + continue + } + this.results[family] = this.results[family] || {} + for (const strategy of SNI_TEST_STRATEGIES) { + let sni = null + if (strategy === 'allow') { + sni = this.config.snis.allow + } else if (strategy === 'block' || strategy === 'frag') { + sni = this.config.snis.block + } + this.startTestThread( + TlsTester.tlsThread, + [family, addr, sni, strategy], + `${family}, ${strategy}`, + this.config.timeout, + ) + } + } + } + + logResults () { + let resStr = '' + for (const [family, results] of Object.entries(this.results)) { + if (results === false) { + continue + } + resStr += `${family}: ` + const noneIcon = getResultIcon(results.none === null) + resStr += `IP-only ${noneIcon} ` + const allowIcon = getResultIcon(results.allow === null) + resStr += `SNI ${allowIcon}\n` + + const censors = [] + const blockRes = results.block + if (blockRes !== null) { + censors.push(`Blocked SNI handshake ${blockRes}`) + } + resStr += getCensorsString(censors) + + const fragRes = results.frag + if (fragRes === null) { + resStr += ` Circumvention found: ${LogColors.GREEN}TLS record fragmentation${LogColors.RESET}\n` + } else if (fragRes) { + resStr += ` ${LogColors.RED}TLS record fragmentation ${fragRes}${LogColors.RESET}\n` + } else { + resStr += ' TLS record fragmentation test inconclusive\n' + } + } + return resStr + } + + static async tlsThread (timeout, logger, results, family, addr, sni, strategy) { + results[family][strategy] = 'timeout' + + const sock = net.createConnection({ + host: addr, + port: 443, + family: FAMILY_VALUES[family] === 4 ? 4 : 6, + }) + sock.setTimeout(1000) + + if (timeout.isSet) { + sock.destroy() + return + } + + try { + const dst = `${addr}:443` + logger(`Connecting socket to ${dst}`) + await new Promise((resolve, reject) => { + sock.on('connect', resolve) + sock.on('error', reject) + sock.on('timeout', () => reject(new Error('timeout'))) + }) + } catch (error) { + if (!timeout.isSet) { + logger(`Connect failed with exception: ${error.message}`) + results[family][strategy] = error.message === 'timeout' ? 'timeout' : 'error' + } + sock.destroy() + return + } + + let errMsg = null + try { + logger('Attempting TLS handshake') + await new Promise((resolve, reject) => { + const tlsSock = tls.connect({ + socket: sock, + servername: sni || undefined, + rejectUnauthorized: false, + }, resolve) + tlsSock.on('error', (error) => { + logger(`TLS error: ${error.code} - ${error.message}`) + if (strategy === 'block' && error.code === 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE') { + resolve() + } else if ((strategy === 'allow' || strategy === 'none') && error.code === 'ECONNRESET') { + resolve() + } else { + reject(error) + } + }) + }) + } catch (error) { + if (error.code !== 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE') { + errMsg = error.message + } + } + + if (timeout.isSet) { + sock.destroy() + return + } + + if (errMsg === null) { + logger('TLS handshake complete!') + results[family][strategy] = null + } else { + logger(`TLS handshake failed with error: ${errMsg}`) + results[family][strategy] = 'error' + } + sock.destroy() + } +} + +function getClientTests () { + return [TlsTester] +} + +export default { + TlsTester, + getClientTests, +} diff --git a/packages/core/src/modules/plugin/free-eye/client.js b/packages/core/src/modules/plugin/free-eye/client.js new file mode 100644 index 0000000000..70f6a6ec13 --- /dev/null +++ b/packages/core/src/modules/plugin/free-eye/client.js @@ -0,0 +1,168 @@ +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import utils from './utils.js' + +const printHeader = (utils && utils.printHeader) || (utils && utils.default && utils.default.printHeader) + +const TEST_PACKAGE_DIR = 'checkpoints' +const PLUGIN_RELATIVE_PATH = path.join('packages', 'core', 'src', 'modules', 'plugin', 'free-eye') + +function locatePluginRoot () { + const localConfig = path.join(__dirname, 'config.json') + if (fs.existsSync(localConfig)) { + return __dirname + } + + let current = __dirname + for (let i = 0; i < 8; i += 1) { + const candidate = path.join(current, PLUGIN_RELATIVE_PATH) + if (fs.existsSync(path.join(candidate, 'config.json'))) { + return candidate + } + const parent = path.dirname(current) + if (parent === current) { + break + } + current = parent + } + return __dirname +} + +const PLUGIN_ROOT = locatePluginRoot() +const pluginRequire = createRequire(path.join(PLUGIN_ROOT, 'index.js')) + +function resolveTestsDir (customDir) { + const fallbackDir = path.join(PLUGIN_ROOT, TEST_PACKAGE_DIR) + if (!customDir) { + return fallbackDir + } + if (path.isAbsolute(customDir)) { + return fs.existsSync(customDir) ? customDir : fallbackDir + } + const candidate = path.join(PLUGIN_ROOT, customDir) + return fs.existsSync(candidate) ? candidate : fallbackDir +} + +async function loadAllTests (globalConfig, testsDir) { + const tests = [] + const resolvedDir = resolveTestsDir(testsDir) + if (!fs.existsSync(resolvedDir)) { + throw new Error(`Tests directory not found: ${resolvedDir}`) + } + const files = fs.readdirSync(resolvedDir).filter(file => file.endsWith('.js') && file !== '__init__.js') + + for (const file of files) { + const modulePath = path.join(resolvedDir, file) + + const module = pluginRequire(modulePath) + const getClientTests = module.getClientTests || (module.default && module.default.getClientTests) + if (typeof getClientTests === 'function') { + for (const testCls of getClientTests()) { + if (testCls.getTestTag() in globalConfig) { + tests.push(testCls) + } + } + } + } + return tests +} + +function getNextTest (todoTests, doneTests) { + for (const testCls of todoTests) { + let allPrereqsDone = true + for (const testTag of testCls.getPrereqs()) { + if (!doneTests.includes(testTag)) { + allPrereqsDone = false + break + } + } + if (allPrereqsDone) { + return testCls + } + } + return null +} + +async function runTests (options = {}) { + const { configPath, testsDir } = options + const preferredConfigPath = configPath && configPath.length > 0 + ? (path.isAbsolute(configPath) ? configPath : path.join(PLUGIN_ROOT, configPath)) + : null + const fallbackConfigPath = path.join(PLUGIN_ROOT, 'config.json') + + const configCandidates = Array.from(new Set([preferredConfigPath, fallbackConfigPath].filter(Boolean))) + + let globalConfig + let lastError + for (const candidatePath of configCandidates) { + try { + if (!fs.existsSync(candidatePath)) { + lastError = new Error(`Config file not found: ${candidatePath}`) + continue + } + const configData = fs.readFileSync(candidatePath, 'utf8') + globalConfig = JSON.parse(configData) + break + } catch (error) { + lastError = new Error(`Error reading config file (${candidatePath}): ${error.message}`) + } + } + + if (!globalConfig) { + throw lastError || new Error('Unable to load FreeEye config.') + } + + const globalResults = {} + const summaries = [] + const todoTests = await loadAllTests(globalConfig, testsDir) + console.log( + `Loaded ${todoTests.length} tests: ${ + todoTests.map(t => t.getTestTag()).join(' ')}`, + ) + + const doneTests = [] + while (todoTests.length > 0) { + const TestCls = getNextTest(todoTests, doneTests) + if (!TestCls) { + break + } + + const testGroup = new TestCls(globalConfig, globalResults) + const testTag = testGroup.testTag + const summary = { tag: testTag, skipped: false } + printHeader(`${testTag} Test`, false) + if (testGroup.skipReason === null) { + const [testTime, testResults] = await testGroup.runTest() + summary.duration = testTime + summary.output = testResults + summary.resultSnapshot = testGroup.results + printHeader(`${testTag} Results: (done in ${testTime.toFixed(3)}s)`, true) + console.log(testResults) + } else { + summary.skipped = true + summary.skipReason = testGroup.skipReason + summary.output = `Test skipped because ${testGroup.skipReason}` + console.log(summary.output) + } + summaries.push(summary) + todoTests.splice(todoTests.indexOf(TestCls), 1) + doneTests.push(testTag) + } + console.log('All tests complete!') + return { + results: globalResults, + summaries, + totalTests: summaries.length, + completedTests: summaries.filter(item => !item.skipped).length, + } +} + +if (require.main === module) { + runTests().catch((error) => { + console.error(error) + process.exitCode = 1 + }) +} + +export default { runTests } diff --git a/packages/core/src/modules/plugin/free-eye/config.js b/packages/core/src/modules/plugin/free-eye/config.js new file mode 100644 index 0000000000..b784841546 --- /dev/null +++ b/packages/core/src/modules/plugin/free-eye/config.js @@ -0,0 +1,18 @@ +import mainConfig from '../../../config.js' + +export default { + name: '网络检测', + statusOff: true, + enabled: false, + tip: '运行网络检测来评估当前网络环境', + startup: {}, + // FreeEye 自带一套 tests(位于本目录的 checkpoints/ 和 config.json), + // 这里保留最小配置以便在 dev-sidecar 中显示和切换插件。 + setting: { + testsDir: 'checkpoints', + // 默认网络请求超时时间(秒),插件内部的测试可以参考或覆盖 + defaultTimeout: 3, + }, + // 复用主配置里的 free_eye 默认值,避免重复维护两份配置 + ...mainConfig.plugin.free_eye, +} diff --git a/packages/core/src/modules/plugin/free-eye/index.js b/packages/core/src/modules/plugin/free-eye/index.js new file mode 100644 index 0000000000..50fc8f4c95 --- /dev/null +++ b/packages/core/src/modules/plugin/free-eye/index.js @@ -0,0 +1,161 @@ +import fs from 'node:fs' +import path from 'node:path' +import clientModule from './client.js' + +const runTests = (clientModule && (clientModule.runTests || (clientModule.default && clientModule.default.runTests))) +import freeEyeConfig from './config.js' + +const PLUGIN_STATUS_KEY = 'plugin.free_eye' + +const FreeEyePlugin = function (context) { + const { config, event, log } = context + let lastResult = null + + const resolvePath = (targetPath, defaultRelative) => { + const fallback = path.join(__dirname, defaultRelative) + if (!targetPath) { + return fallback + } + if (path.isAbsolute(targetPath)) { + return targetPath + } + const candidates = [ + path.join(__dirname, targetPath), + path.join(process.cwd(), targetPath), + ] + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate + } + } + return fallback + } + + const emitStatus = (key, value) => { + event.fire('status', { key, value }) + } + + const captureLogs = async (executor) => { + const logs = [] + const originalLog = console.log + const originalError = console.error + const push = (level, args) => { + const message = args.map((item) => { + if (item instanceof Error) { + return item.stack || item.message + } + if (typeof item === 'object') { + try { + return JSON.stringify(item) + } catch (err) { + return String(item) + } + } + return String(item) + }).join(' ') + logs.push({ level, message, timestamp: Date.now() }) + // Also write to system log so it follows configured logging format + if (level === 'error') { + log.error(message) + } else { + log.info(message) + } + } + console.log = (...args) => { + push('info', args) + originalLog(...args) + } + console.error = (...args) => { + push('error', args) + originalError(...args) + } + try { + const result = await executor() + return { result, logs } + } finally { + console.log = originalLog + console.error = originalError + } + } + + const storeResult = (payload) => { + lastResult = payload + emitStatus(`${PLUGIN_STATUS_KEY}.result`, lastResult) + } + + const executeTests = async () => { + const currentConfig = config.get() + const pluginConfig = currentConfig.plugin.free_eye || {} + const setting = pluginConfig.setting || {} + const configFilePath = resolvePath(setting.testsConfigFile, 'config.json') + const testsDir = resolvePath(setting.testsDir, 'checkpoints') + log.info(`FreeEye tests triggering, config=${configFilePath}, testsDir=${testsDir}`) + try { + const { result, logs } = await captureLogs(() => runTests({ configPath: configFilePath, testsDir })) + const payload = { + finishedAt: new Date().toISOString(), + totalTests: result.totalTests, + completedTests: result.completedTests, + summaries: result.summaries, + results: result.results, + logs, + } + storeResult(payload) + return payload + } catch (err) { + const payload = { + finishedAt: new Date().toISOString(), + error: err.message, + } + storeResult(payload) + throw err + } + } + + const api = { + async start () { + emitStatus(`${PLUGIN_STATUS_KEY}.enabled`, true) + log.info('启动【FreeEye】插件') + try { + return await executeTests() + } catch (err) { + log.error('FreeEye runTests failed:', err) + throw err + } + }, + + async close () { + emitStatus(`${PLUGIN_STATUS_KEY}.enabled`, false) + log.info('关闭【FreeEye】插件') + }, + + async restart () { + await api.close() + return api.start() + }, + + isEnabled () { + const pluginConfig = config.get().plugin.free_eye + return pluginConfig && pluginConfig.enabled + }, + + async run () { + return executeTests() + }, + + async getLastResult () { + return lastResult + }, + } + return api +} + +export default { + key: 'free_eye', + config: freeEyeConfig, + status: { + enabled: false, + result: null, + }, + plugin: FreeEyePlugin, +} diff --git a/packages/core/src/modules/plugin/free-eye/template.js b/packages/core/src/modules/plugin/free-eye/template.js new file mode 100644 index 0000000000..7e91b1b2ff --- /dev/null +++ b/packages/core/src/modules/plugin/free-eye/template.js @@ -0,0 +1,143 @@ +import { performance } from 'node:perf_hooks' + +class TestThread { + /** + * A single test that should be run in its own thread and + * preempted when its timeout is reached + */ + constructor (func, args, logHdr, timeout, results) { + this.logHdr = logHdr + this.log('Starting test...') + + this.timeoutEvent = { isSet: false } + this.results = results + this.startTime = performance.now() + this.timeout = timeout * 1000 // convert to ms + + // Start the async function and store the promise + this.runPromise = this.run(func, args) + } + + log (s) { + console.log(this.logHdr + s) + } + + async run (func, args) { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + this.timeoutEvent.isSet = true + reject(new Error('Test timed out!')) + }, this.timeout) + }) + + try { + await Promise.race([ + func(this.timeoutEvent, this.log.bind(this), this.results, ...args), + timeoutPromise, + ]) + } catch (error) { + if (error.message === 'Test timed out!') { + this.log('Test timed out!') + } else { + this.log(`Test failed: ${error.message}`) + } + } + } +} + +class TestGroup { + /** + * A group of related tests that can be run in parallel. + */ + constructor (globalConfig, globalResults, testTag) { + this.testTag = testTag + this.startTime = performance.now() + this.config = globalConfig[testTag] + this.threads = [] + + this.results = this.getDefaultResults() + globalResults[testTag] = this.results + this.skipReason = this.checkIfShouldSkip(globalResults) + } + + /** + * Return a string identifying this test group + */ + static getTestTag () { + return '' + } + + /** + * Return the tags of other tests this test relies on + */ + static getPrereqs () { + return [] + } + + /** + * Return this test's default (i.e. all failed) results + */ + getDefaultResults () { + return {} + } + + checkIfShouldSkip (globalResults) { + /** + * If earlier results indicate this test shouldn't be run + * return a string indicating why (otherwise return null) + */ + return null + } + + async runTest () { + /** + * Wait for all running test threads to complete or + * timeout, then log the results and return a summary. + */ + await this.startTest() + // Wait for all threads to complete + await Promise.all(this.threads.map(thread => thread.runPromise)) + const testResults = this.logResults() + const testTime = (performance.now() - this.startTime) / 1000 // convert to seconds + return [testTime, testResults] + } + + async startTest () { + /** + * Implemented by subclasses to create the test threads + */ + // To be implemented by subclasses + } + + startTestThread (func, args, logTag, timeout) { + /** + * Create a new test thread in this test group + */ + const threadIdx = this.threads.length + const logHdr = `${this.testTag} #${threadIdx} (${logTag}): ` + const thread = new TestThread(func, args, logHdr, timeout, this.results) + this.threads.push(thread) + } + + logResults () { + /** + * Log the results of the completed test threads and + * return a string summarizing the results. + */ + return '' + } +} + +function getClientTests () { + /** + * Return a list of TestGroup classes defined in this + * module that clients should run + */ + return [] +} + +export default { + TestThread, + TestGroup, + getClientTests, +} diff --git a/packages/core/src/modules/plugin/free-eye/utils.js b/packages/core/src/modules/plugin/free-eye/utils.js new file mode 100644 index 0000000000..134f332162 --- /dev/null +++ b/packages/core/src/modules/plugin/free-eye/utils.js @@ -0,0 +1,79 @@ +/** + * Various utility functions shared by tests + */ + +// Linking socket constants to human-readable strings +const FAMILY_VALUES = { + IPv4: 4, + IPv6: 6, +} +const PROTOCOL_VALUES = { + TCP: 'tcp', + UDP: 'udp', +} + +class LogColors { + /** + * ANSI color codes for pretty terminal output + * Update: Dev-sidecar doesn't support ANSI color codes in its console output. + */ + static RESET = '' + static RED = '' + static GREEN = '' + static YELLOW = '' + static BLUE = '' + static MAGENTA = '' + static CYAN = '' + static WHITE = '' +} + +const DISPLAY_WIDTH = 50 + +function printHeader (title, isRes) { // test start, test res + const sep = `\n${'='.repeat(DISPLAY_WIDTH)}\n` + console.log( + (isRes ? LogColors.MAGENTA : LogColors.CYAN) + + sep + title.padStart(Math.floor((DISPLAY_WIDTH + title.length) / 2)).padEnd(DISPLAY_WIDTH) + sep + + LogColors.RESET, + ) +} + +function getResultIcon (success, infoStr = null) { + let resColor, resIcon + if (success === true) { + resColor = LogColors.GREEN + resIcon = '✔' + } else if (success === false) { + resColor = LogColors.RED + resIcon = '✖' + } else { // test inconclusive + resColor = LogColors.YELLOW + resIcon = '?' + } + if (infoStr !== null) { + resIcon += ` ${infoStr}` + } + return `(${resColor}${resIcon}${LogColors.RESET})` +} + +function getCensorsString (censors) { + let resStr = '' + if (censors && censors.length > 0) { + for (const c of censors) { + resStr += ` Censorship detected: ${LogColors.RED}${c}${LogColors.RESET}\n` + } + } else { + resStr += ' No censorship detected\n' + } + return resStr +} + +export default { + FAMILY_VALUES, + PROTOCOL_VALUES, + LogColors, + DISPLAY_WIDTH, + printHeader, + getResultIcon, + getCensorsString, +} diff --git a/packages/gui/package.json b/packages/gui/package.json index b4460ed821..7c712d7c5c 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -1,6 +1,6 @@ { "name": "@docmirror/dev-sidecar-gui", - "version": "2.0.2", + "version": "2.1.0", "private": false, "author": { "email": "xiaojunnuo@qq.com", diff --git a/packages/gui/src/view/App.vue b/packages/gui/src/view/App.vue index 9a1bab74e7..fa001e21ce 100644 --- a/packages/gui/src/view/App.vue +++ b/packages/gui/src/view/App.vue @@ -194,17 +194,17 @@ export default {