Merge remote-tracking branch 'upstream/master'
commit
715e747a99
|
@ -14,8 +14,8 @@
|
|||
>
|
||||
## 打个广告
|
||||
>
|
||||
> https://ai.handsfree.work
|
||||
> 我的ChatGPT,开发者必备,无需fanQ,快速,稳定,价格良心,100问仅需1元,按需扣费,余额永久有效,大家可以试试
|
||||
> https://github.com/certd/certd
|
||||
> 我的开源证书管理工具项目,全自动申请和部署证书,有需求的可以去试试,帮忙点个star
|
||||
|
||||
|
||||
|
||||
|
@ -399,9 +399,9 @@ npm run electron:build
|
|||
|
||||
1、 加群(请备注dev-sidecar,或简称DS)
|
||||
- QQ 1群:390691483,人数:500 / 500(满)
|
||||
- QQ 2群:[667666069](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=n4nksr4sji93vZtD5e8YEHRT6qbh6VyQ&authKey=XKBZnzmoiJrAFyOT4V%2BCrgX5c13ds59b84g%2FVRhXAIQd%2FlAiilsuwDRGWJct%2B570&noverify=0&group_code=667666069),人数:439 / 500
|
||||
- QQ 2群:[667666069](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=n4nksr4sji93vZtD5e8YEHRT6qbh6VyQ&authKey=XKBZnzmoiJrAFyOT4V%2BCrgX5c13ds59b84g%2FVRhXAIQd%2FlAiilsuwDRGWJct%2B570&noverify=0&group_code=667666069),人数:447 / 500
|
||||
- QQ 3群:419807815,人数:500 / 500(满)
|
||||
- QQ 4群:438148299,人数:200 / 200(满)
|
||||
- QQ 4群:[438148299](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=i_NCBB5f_Bkm2JsEV1tLs2TkQ79UlCID&authKey=nMsVJbJ6P%2FGNO7Q6vsVUadXRKnULUURwR8zvUZJnP3IgzhHYPhYdcBCHvoOh8vYr&noverify=0&group_code=438148299),人数:203 / 1000
|
||||
- QQ 5群:[767622917](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=nAWi_Rxj7mM4Unp5LMiatmUWhGimtbcB&authKey=aswmlWGjbt3GIWXtvjB2GJqqAKuv7hWjk6UBs3MTb%2Biyvr%2Fsbb1kA9CjF6sK7Hgg&noverify=0&group_code=767622917),人数:016 / 200(new)
|
||||
|
||||
|
||||
|
|
|
@ -14,5 +14,5 @@
|
|||
"ignore": []
|
||||
}
|
||||
},
|
||||
"version": "1.8.8"
|
||||
"version": "1.8.9"
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@docmirror/dev-sidecar",
|
||||
"version": "1.8.8",
|
||||
"version": "1.8.9",
|
||||
"description": "给开发者的加速代理工具",
|
||||
"main": "src/index.js",
|
||||
"keywords": [
|
||||
|
@ -17,7 +17,7 @@
|
|||
"test": "mocha"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docmirror/mitmproxy": "^1.8.8",
|
||||
"@docmirror/mitmproxy": "^1.8.9",
|
||||
"agentkeepalive": "^2.1.1",
|
||||
"babel-preset-es2020": "^1.0.2",
|
||||
"charset": "^1.0.0",
|
||||
|
|
|
@ -65,7 +65,10 @@ module.exports = {
|
|||
timeout: 20000,
|
||||
keepAliveTimeout: 30000
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 慢速IP延迟时间:测速超过该值时,则视为延迟高,显示为橙色
|
||||
lowSpeedDelay: 150
|
||||
},
|
||||
compatible: {
|
||||
// **** 自定义兼容配置 **** //
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[SwitchyOmega Conditions]
|
||||
; Require: SwitchyOmega >= 2.3.2
|
||||
; Update Date: 2024/10/14
|
||||
; Update Date: 2024/11/07
|
||||
; Author: Pluwen
|
||||
; Usage: https://github.com/FelisCatus/SwitchyOmega/wiki/RuleListUsage
|
||||
|
||||
|
@ -236,6 +236,8 @@
|
|||
*.ccb.com
|
||||
*.ccgslb.com
|
||||
*.ccgslb.net
|
||||
*.cckefu.net
|
||||
*.cckefu3.com
|
||||
*.cctv.com
|
||||
*.cctvpic.com
|
||||
*.cdn-apple.com
|
||||
|
@ -333,6 +335,7 @@
|
|||
*.dmzj.com
|
||||
*.dns.com
|
||||
*.dnspao.com
|
||||
*.doc88.com
|
||||
*.docer.com
|
||||
*.docin.com
|
||||
*.docschina.org
|
||||
|
@ -395,6 +398,8 @@
|
|||
*.fiio.com
|
||||
*.fir.im
|
||||
*.firefox.com
|
||||
*.fj12379.com
|
||||
*.fjdzyz.com
|
||||
*.fjgdwl.com
|
||||
*.fjhxbank.com
|
||||
*.fliggy.com
|
||||
|
@ -471,6 +476,7 @@
|
|||
*.homestyler.com
|
||||
*.hommk.com
|
||||
*.hongxiu.com
|
||||
*.honor.com
|
||||
*.hostbuf.com
|
||||
*.hostker.com
|
||||
*.hotmail.com
|
||||
|
@ -838,6 +844,7 @@
|
|||
*.pterclub.com
|
||||
*.pythonclub.org
|
||||
*.qbox.me
|
||||
*.qcc.com
|
||||
*.qcloud.com
|
||||
*.qcloudcdn.com
|
||||
*.qcwgg.com
|
||||
|
@ -883,6 +890,7 @@
|
|||
*.redacted.ch
|
||||
*.renren.com
|
||||
*.renrenche.com
|
||||
*.renrendoc.com
|
||||
*.researchgate.net
|
||||
*.rework.tools
|
||||
*.rkecloud.com
|
||||
|
@ -966,7 +974,6 @@
|
|||
*.staticfile.org
|
||||
*.steamcn.com
|
||||
*.steamcontent.com
|
||||
*.steamdb.info
|
||||
*.subhd.tv
|
||||
*.sui.com
|
||||
*.suning.com
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@docmirror/dev-sidecar-gui",
|
||||
"version": "1.8.8",
|
||||
"version": "1.8.9",
|
||||
"private": false,
|
||||
"license": "MPL-2.0",
|
||||
"main": "index.js",
|
||||
|
@ -21,8 +21,10 @@
|
|||
"name": "Greper"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docmirror/dev-sidecar": "^1.8.8",
|
||||
"@docmirror/mitmproxy": "^1.8.8",
|
||||
"@docmirror/dev-sidecar": "^1.8.9",
|
||||
"@docmirror/mitmproxy": "^1.8.9",
|
||||
"@mihomo-party/sysproxy": "^2.0.4",
|
||||
"@natmri/platform-napi": "0.0.7",
|
||||
"adm-zip": "^0.5.5",
|
||||
"ant-design-vue": "^1.6.5",
|
||||
"compressing": "^1.5.1",
|
||||
|
@ -52,9 +54,9 @@
|
|||
"@vue/eslint-config-standard": "^5.1.2",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"electron": "^17.4.11",
|
||||
"electron-builder": "^23.0.3",
|
||||
"electron-devtools-installer": "^3.1.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-builder": "^23.0.3",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
'use strict'
|
||||
/* global __static */
|
||||
import path from 'path'
|
||||
import { app, protocol, BrowserWindow, Menu, Tray, ipcMain, dialog, powerMonitor, nativeImage, nativeTheme, globalShortcut } from 'electron'
|
||||
import { app, protocol, BrowserWindow, Menu, Tray, ipcMain, dialog, nativeImage, nativeTheme, globalShortcut } from 'electron'
|
||||
import { powerMonitor } from './background/powerMonitor'
|
||||
import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'
|
||||
import backend from './bridge/backend'
|
||||
import DevSidecar from '@docmirror/dev-sidecar'
|
||||
import log from './utils/util.log'
|
||||
import minimist from 'minimist'
|
||||
|
||||
const isWindows = process.platform === 'win32'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const isMac = process.platform === 'darwin'
|
||||
// import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'
|
||||
|
@ -187,6 +190,11 @@ function createWindow (startHideWindow) {
|
|||
Menu.setApplicationMenu(null)
|
||||
win.setMenu(null)
|
||||
|
||||
// !!IMPORTANT
|
||||
if (isWindows) {
|
||||
powerMonitor.setupMainWindow(win)
|
||||
}
|
||||
|
||||
if (process.env.WEBPACK_DEV_SERVER_URL) {
|
||||
// Load the url of the dev server if in development mode
|
||||
win.loadURL(process.env.WEBPACK_DEV_SERVER_URL)
|
||||
|
@ -443,8 +451,14 @@ if (!isFirstInstance) {
|
|||
}
|
||||
|
||||
powerMonitor.on('shutdown', async (e) => {
|
||||
e.preventDefault()
|
||||
if (e) {
|
||||
e.preventDefault()
|
||||
}
|
||||
log.info('系统关机,恢复代理设置')
|
||||
if (isWindows) {
|
||||
const Sysproxy = require('@mihomo-party/sysproxy')
|
||||
Sysproxy.triggerManualProxy(false, '', 0, '')
|
||||
}
|
||||
await quit()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
import { powerMonitor as _powerMonitor } from 'electron'
|
||||
import { setMainWindowHandle, insertWndProcHook, removeWndProcHook, releaseShutdownBlock, acquireShutdownBlock } from '@natmri/platform-napi'
|
||||
|
||||
class PowerMonitor {
|
||||
constructor () {
|
||||
this.setup = false
|
||||
this._listeners = []
|
||||
this._shutdownCallback = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BrowserWindow} window
|
||||
*/
|
||||
setupMainWindow (window) {
|
||||
if (!this.setup) {
|
||||
setMainWindowHandle(window.getNativeWindowHandle().readBigInt64LE())
|
||||
this.setup = true
|
||||
}
|
||||
}
|
||||
|
||||
addListener (event, listener) {
|
||||
return this.on(event, listener)
|
||||
}
|
||||
|
||||
removeListener (event, listener) {
|
||||
return this.off(event, listener)
|
||||
}
|
||||
|
||||
removeAllListeners (event) {
|
||||
if (event === 'shutdown' && process.platform === 'win32') {
|
||||
this._listeners = []
|
||||
if (this._shutdownCallback) {
|
||||
removeWndProcHook()
|
||||
releaseShutdownBlock()
|
||||
this._shutdownCallback = null
|
||||
}
|
||||
} else {
|
||||
return _powerMonitor.removeAllListeners(event)
|
||||
}
|
||||
}
|
||||
|
||||
on (event, listener) {
|
||||
if (event === 'shutdown' && process.platform === 'win32') {
|
||||
if (!this._shutdownCallback) {
|
||||
this._shutdownCallback = async () => {
|
||||
await Promise.all(this._listeners.map((fn) => fn()))
|
||||
releaseShutdownBlock()
|
||||
}
|
||||
insertWndProcHook(this._shutdownCallback)
|
||||
acquireShutdownBlock('正在停止 DevSidecar 代理')
|
||||
}
|
||||
this._listeners.push(listener)
|
||||
} else {
|
||||
return _powerMonitor.on(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
off (event, listener) {
|
||||
if (event === 'shutdown' && process.platform === 'win32') {
|
||||
this._listeners = this._listeners.filter((fn) => fn !== listener)
|
||||
} else {
|
||||
return _powerMonitor.off(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
once (event, listener) {
|
||||
if (event === 'shutdown' && process.platform === 'win32') {
|
||||
return this.on(event, listener)
|
||||
} else {
|
||||
return _powerMonitor.once(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
emit (event, ...args) {
|
||||
return _powerMonitor.emit(event, ...args)
|
||||
}
|
||||
|
||||
eventNames () {
|
||||
return _powerMonitor.eventNames()
|
||||
}
|
||||
|
||||
getMaxListeners () {
|
||||
return _powerMonitor.getMaxListeners()
|
||||
}
|
||||
|
||||
listeners (event) {
|
||||
return _powerMonitor.listeners(event)
|
||||
}
|
||||
|
||||
rawListeners (event) {
|
||||
return _powerMonitor.rawListeners(event)
|
||||
}
|
||||
|
||||
listenerCount (event, listener) {
|
||||
return _powerMonitor.listenerCount(event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get onBatteryPower () {
|
||||
return _powerMonitor.onBatteryPower
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} idleThreshold
|
||||
* @returns {'active'|'idle'|'locked'|'unknown'}
|
||||
*/
|
||||
getSystemIdleState (idleThreshold) {
|
||||
return _powerMonitor.getSystemIdleState(idleThreshold)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
getSystemIdleTime () {
|
||||
return _powerMonitor.getSystemIdleTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {'unknown'|'nominal'|'fair'|'serious'|'critical'}
|
||||
*/
|
||||
getCurrentThermalState () {
|
||||
return _powerMonitor.getCurrentThermalState()
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOnBatteryPower () {
|
||||
return _powerMonitor.isOnBatteryPower()
|
||||
}
|
||||
}
|
||||
|
||||
export const powerMonitor = new PowerMonitor()
|
|
@ -24,7 +24,7 @@
|
|||
<a-checkbox v-model="config.plugin.overwall.pac.enabled">
|
||||
启用PAC
|
||||
</a-checkbox>
|
||||
<div class="form-help">PAC内收录了常见的被封杀的域名,当里面某些域名你不想被拦截时,可以关闭PAC</div>
|
||||
<div class="form-help">PAC内收录了常见的被封杀的域名<br/>当里面某些域名你不想被拦截时,你可以配置这些域名为<code>禁用</code>,也可以关闭PAC</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="自动更新PAC" :label-col="labelCol" :wrapper-col="wrapperCol">
|
||||
<a-checkbox v-model="config.plugin.overwall.pac.autoUpdate">
|
||||
|
@ -46,16 +46,23 @@
|
|||
<div>
|
||||
<a-row :gutter="10" style="">
|
||||
<a-col :span="22">
|
||||
<span>PAC没有拦截到的域名,可以在此处定义</span>
|
||||
<span>PAC没有拦截到的域名,可以在此处定义;配置为<code>禁用</code>时,将不使用梯子</span>
|
||||
</a-col>
|
||||
<a-col :span="2">
|
||||
<a-button type="primary" icon="plus" @click="addTarget()"/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="10" v-for="(item,index) of targets" :key="index">
|
||||
<a-col :span="22">
|
||||
<a-col :span="18">
|
||||
<a-input v-model="item.key"></a-input>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-select v-model="item.value" style="width:100%">
|
||||
<a-select-option v-for="(item) of overwallOptions" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="2">
|
||||
<a-button type="danger" icon="minus" @click="deleteTarget(item,index)"/>
|
||||
</a-col>
|
||||
|
@ -116,7 +123,17 @@ export default {
|
|||
return {
|
||||
key: 'plugin.overwall',
|
||||
targets: undefined,
|
||||
servers: undefined
|
||||
servers: undefined,
|
||||
overwallOptions: [
|
||||
{
|
||||
value: true,
|
||||
label: '启用'
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
label: '禁用'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
|
|
@ -166,8 +166,11 @@
|
|||
</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item label="自动测试间隔" :label-col="labelCol" :wrapper-col="wrapperCol">
|
||||
<a-input-number id="inputNumber" v-model="getSpeedTestConfig().interval" :step="1000" :min="1"/> ms
|
||||
<a-input-number v-model="getSpeedTestConfig().interval" :step="1000" :min="1"/> ms
|
||||
</a-form-item>
|
||||
<!--<a-form-item label="慢速IP阈值" :label-col="labelCol" :wrapper-col="wrapperCol">
|
||||
<a-input-number v-model="config.server.setting.lowSpeedDelay" :step="10" :min="100"/> ms
|
||||
</a-form-item>-->
|
||||
<div>使用以下DNS获取IP进行测速</div>
|
||||
<a-row style="margin-top:10px">
|
||||
<a-col span="24">
|
||||
|
@ -211,7 +214,7 @@
|
|||
<a-icon v-else type="info-circle"/>
|
||||
</a>
|
||||
<a-tag style="margin:2px;" v-for="(element,index) of item.backupList" :title="element.dns"
|
||||
:color="element.time?'green':'red'" :key='index'>
|
||||
:color="element.time?(element.time>config.server.setting.lowSpeedDelay?'orange':'green'):'red'" :key='index'>
|
||||
{{ element.host }} {{ element.time }}{{ element.time ? 'ms' : '' }} {{ element.dns }}
|
||||
</a-tag>
|
||||
</a-card>
|
||||
|
@ -260,7 +263,7 @@ export default {
|
|||
if (!this.config || !this.config.server || !this.config.server.dns || !this.config.server.dns.providers) {
|
||||
return options
|
||||
}
|
||||
_.forEach(this.config.server.dns.providers, (dnsConf, key) => {
|
||||
_.forEach(this.config.server.dns.providers, (dnsConfig, key) => {
|
||||
options.push({
|
||||
value: key,
|
||||
label: key
|
||||
|
|
|
@ -122,6 +122,12 @@ $dark-input: #777; //输入框:背景色
|
|||
border-color: #505f5f;
|
||||
color: #90cb9f;
|
||||
}
|
||||
/* 标签:警告 */
|
||||
.ant-tag-orange{
|
||||
background: #5a5750;
|
||||
border-color: #5a5750;
|
||||
color: #cfa572;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.ant-btn:not(.ant-btn-danger, .ant-btn-primary){
|
||||
|
|
|
@ -32,6 +32,27 @@ module.exports = {
|
|||
},
|
||||
pluginOptions: {
|
||||
electronBuilder: {
|
||||
externals: [
|
||||
'@mihomo-party/sysproxy',
|
||||
'@mihomo-party/sysproxy-win32-ia32-msvc',
|
||||
'@mihomo-party/sysproxy-win32-x64-msvc',
|
||||
'@mihomo-party/sysproxy-win32-arm64-msvc',
|
||||
'@mihomo-party/sysproxy-linux-x64-gnu',
|
||||
'@mihomo-party/sysproxy-linux-arm64-gnu',
|
||||
'@mihomo-party/sysproxy-darwin-x64',
|
||||
'@mihomo-party/sysproxy-darwin-arm64',
|
||||
'@natmri/platform-napi',
|
||||
"@natmri/platform-napi-win32-x64-msvc",
|
||||
"@natmri/platform-napi-darwin-x64",
|
||||
"@natmri/platform-napi-linux-x64-gnu",
|
||||
"@natmri/platform-napi-darwin-arm64",
|
||||
"@natmri/platform-napi-linux-arm64-gnu",
|
||||
"@natmri/platform-napi-linux-arm64-musl",
|
||||
"@natmri/platform-napi-win32-arm64-msvc",
|
||||
"@natmri/platform-napi-linux-arm-gnueabihf",
|
||||
"@natmri/platform-napi-linux-x64-musl",
|
||||
"@natmri/platform-napi-win32-ia32-msvc"
|
||||
],
|
||||
nodeIntegration: true,
|
||||
// Provide an array of files that, when changed, will recompile the main process and restart Electron
|
||||
// Your main process file will be added by default
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@docmirror/mitmproxy",
|
||||
"version": "1.8.8",
|
||||
"version": "1.8.9",
|
||||
"description": "",
|
||||
"main": "src/index.js",
|
||||
"keywords": [
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
const DNSOverTLS = require('./tls.js')
|
||||
const DNSOverHTTPS = require('./https.js')
|
||||
const DNSOverIpAddress = require('./ipaddress.js')
|
||||
const DNSOverPreSetIpList = require('./preset.js')
|
||||
const matchUtil = require('../../utils/util.match')
|
||||
const log = require('../../utils/util.log')
|
||||
|
||||
module.exports = {
|
||||
initDNS (dnsProviders, preSetIpList) {
|
||||
const dnsMap = {}
|
||||
|
||||
// 创建普通的DNS
|
||||
for (const provider in dnsProviders) {
|
||||
const conf = dnsProviders[provider]
|
||||
|
||||
|
@ -20,30 +22,33 @@ module.exports = {
|
|||
|
||||
// 设置DNS名称到name属性中
|
||||
dnsMap[provider].name = provider
|
||||
dnsMap[provider].type = conf.type
|
||||
}
|
||||
|
||||
// 创建预设IP的DNS
|
||||
dnsMap.PreSet = new DNSOverPreSetIpList(preSetIpList)
|
||||
|
||||
return dnsMap
|
||||
},
|
||||
hasDnsLookup (dnsConfig, hostname) {
|
||||
let providerName = matchUtil.matchHostname(dnsConfig.mapping, hostname, 'get dns providerName')
|
||||
let providerName = null
|
||||
|
||||
// usa已重命名为cloudflare,以下为向下兼容处理
|
||||
if (providerName === 'usa') {
|
||||
providerName = 'cloudflare'
|
||||
// 先匹配 预设IP配置
|
||||
const hostnamePreSetIpList = matchUtil.matchHostname(dnsConfig.preSetIpList, hostname, 'matched preSetIpList')
|
||||
if (hostnamePreSetIpList) {
|
||||
return dnsConfig.dnsMap.PreSet
|
||||
}
|
||||
|
||||
// 如果为空,尝试从预设IP中匹配,如果配置过预设IP,则随便
|
||||
if (providerName == null) {
|
||||
const hostnamePreSetIpList = matchUtil.matchHostname(dnsConfig.preSetIpList, hostname, 'matched preSetIpList')
|
||||
if (hostnamePreSetIpList) {
|
||||
for (const name in dnsConfig.providers) {
|
||||
log.debug(`当前域名未配置过DNS,但配置了预设IP,现返回DNS '${name}' 作为预设IP的使用工具,hostname: ${hostname}, preSetIpList:`, hostnamePreSetIpList)
|
||||
return dnsConfig.providers[name]
|
||||
}
|
||||
}
|
||||
// 再匹配 DNS映射配置
|
||||
providerName = matchUtil.matchHostname(dnsConfig.mapping, hostname, 'get dns providerName')
|
||||
|
||||
// 由于DNS中的usa已重命名为cloudflare,所以做以下处理,为了向下兼容
|
||||
if (providerName === 'usa' && dnsConfig.dnsMap.usa == null && dnsConfig.dnsMap.cloudflare != null) {
|
||||
return dnsConfig.dnsMap.cloudflare
|
||||
}
|
||||
|
||||
if (providerName) {
|
||||
return dnsConfig.providers[providerName]
|
||||
return dnsConfig.dnsMap[providerName]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
const BaseDNS = require('./base')
|
||||
const matchUtil = require('../../utils/util.match')
|
||||
|
||||
function mapToList (ipMap) {
|
||||
const ipList = []
|
||||
for (const key in ipMap) {
|
||||
if (!ipMap[key]) continue
|
||||
ipList.push(ipMap[key])
|
||||
}
|
||||
return ipList
|
||||
}
|
||||
|
||||
module.exports = class DNSOverPreSetIpList extends BaseDNS {
|
||||
constructor (preSetIpList) {
|
||||
super()
|
||||
this.preSetIpList = preSetIpList
|
||||
this.name = 'PreSet'
|
||||
this.type = 'PreSet'
|
||||
}
|
||||
|
||||
async _lookup (hostname) {
|
||||
// 获取当前域名的预设IP列表
|
||||
let hostnamePreSetIpList = matchUtil.matchHostname(this.preSetIpList, hostname, 'matched preSetIpList')
|
||||
if (hostnamePreSetIpList && (hostnamePreSetIpList.length > 0 || hostnamePreSetIpList.length === undefined)) {
|
||||
if (hostnamePreSetIpList.length > 0) {
|
||||
hostnamePreSetIpList = hostnamePreSetIpList.slice()
|
||||
} else {
|
||||
hostnamePreSetIpList = mapToList(hostnamePreSetIpList)
|
||||
}
|
||||
|
||||
if (hostnamePreSetIpList.length > 0) {
|
||||
return hostnamePreSetIpList
|
||||
}
|
||||
}
|
||||
|
||||
// 未预设当前域名的IP列表
|
||||
return []
|
||||
}
|
||||
}
|
|
@ -6,8 +6,25 @@ module.exports = {
|
|||
responseIntercept (context, interceptOpt, req, res, proxyReq, proxyRes, ssl, next) {
|
||||
const { rOptions, log } = context
|
||||
|
||||
// 只有GET请求,且响应码为2xx时才进行缓存
|
||||
if (rOptions.method !== 'GET' || proxyRes.statusCode < 200 || proxyRes.statusCode >= 300) {
|
||||
// 只有GET请求
|
||||
if (rOptions.method !== 'GET') {
|
||||
return
|
||||
}
|
||||
|
||||
// 判断当前响应码是否不使用缓存
|
||||
if (interceptOpt.cacheExcludeStatusCodeList && interceptOpt.cacheExcludeStatusCodeList[proxyRes.statusCode + '']) {
|
||||
return
|
||||
}
|
||||
|
||||
// 响应码为 200~303 时才进行缓存(可通过以下两个参数调整范围)
|
||||
let minStatusCode = interceptOpt.cacheMinStatusCode || 200
|
||||
let maxStatusCode = interceptOpt.cacheMaxStatusCode || 303
|
||||
if (minStatusCode > maxStatusCode) {
|
||||
const temp = minStatusCode
|
||||
minStatusCode = maxStatusCode
|
||||
maxStatusCode = temp
|
||||
}
|
||||
if (proxyRes.statusCode < minStatusCode || proxyRes.statusCode > maxStatusCode) {
|
||||
// res.setHeader('DS-Cache-Response-Interceptor', `skip: 'method' or 'status' not match`)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ function replaceResponseHeaders (newHeaders, res, proxyRes) {
|
|||
const preHeaders = {}
|
||||
|
||||
// 替换响应头
|
||||
const needDeleteKeys = []
|
||||
for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) {
|
||||
const headerKey = proxyRes.rawHeaders[i].toLowerCase()
|
||||
|
||||
|
@ -27,15 +28,19 @@ function replaceResponseHeaders (newHeaders, res, proxyRes) {
|
|||
if (newHeaderValue) {
|
||||
if (newHeaderValue !== proxyRes.rawHeaders[i + 1]) {
|
||||
preHeaders[headerKey] = proxyRes.rawHeaders[i + 1] // 先保存原先响应头
|
||||
if (newHeaderValue === REMOVE) { // 由于拦截配置中不允许配置null,会被删,所以配置一个[remove],当作删除响应头的意思
|
||||
if (newHeaderValue === REMOVE) { // 由于拦截配置中不允许配置null,会被删,所以配置一个 "[remove]",当作删除响应头的意思
|
||||
proxyRes.rawHeaders[i + 1] = ''
|
||||
} else {
|
||||
proxyRes.rawHeaders[i + 1] = newHeaderValue
|
||||
}
|
||||
}
|
||||
delete newHeaders[headerKey]
|
||||
needDeleteKeys.push(headerKey)
|
||||
}
|
||||
}
|
||||
// 处理删除响应头
|
||||
for (const headerKey of needDeleteKeys) {
|
||||
delete newHeaders[headerKey]
|
||||
}
|
||||
// 新增响应头
|
||||
for (const headerKey in newHeaders) {
|
||||
const headerValue = newHeaders[headerKey]
|
||||
|
|
|
@ -10,17 +10,23 @@ const { Buffer } = require('buffer')
|
|||
let pacClient = null
|
||||
|
||||
function matched (hostname, overWallTargetMap) {
|
||||
// 匹配配置文件
|
||||
const ret1 = matchUtil.matchHostname(overWallTargetMap, hostname, 'matched overwall')
|
||||
if (ret1) {
|
||||
return 'overwall config'
|
||||
return 'in config'
|
||||
} else if (ret1 === false || ret1 === 'false') {
|
||||
log.debug(`域名 ${hostname} 的overwall配置为 false,跳过增强功能,即使它在 pac.txt 里`)
|
||||
return null
|
||||
}
|
||||
|
||||
// 匹配 pac.txt
|
||||
if (pacClient == null) {
|
||||
return null
|
||||
}
|
||||
const ret = pacClient.FindProxyForURL('https://' + hostname, hostname)
|
||||
if (ret && ret.indexOf('PROXY ') === 0) {
|
||||
log.info(`matchHostname: matched overwall: '${hostname}' -> '${ret}' in pac.txt`)
|
||||
return 'overwall pac'
|
||||
return 'in pac.txt'
|
||||
} else {
|
||||
log.debug(`matchHostname: matched overwall: Not-Matched '${hostname}' -> '${ret}' in pac.txt`)
|
||||
return null
|
||||
|
@ -148,14 +154,7 @@ function createOverwallMiddleware (overWallConfig) {
|
|||
return {
|
||||
sslConnectInterceptor: (req, cltSocket, head) => {
|
||||
const hostname = req.url.split(':')[0]
|
||||
const ret = matched(hostname, overWallTargetMap)
|
||||
if (ret == null) {
|
||||
return null // 返回 null,由下一个拦截器校验
|
||||
}
|
||||
if (ret === false) {
|
||||
return false // 不拦截,预留这个判断,避免以后修改 matched 方法的代码出BUG
|
||||
}
|
||||
return true // 拦截
|
||||
return matched(hostname, overWallTargetMap)
|
||||
},
|
||||
requestIntercept (context, req, res, ssl, next) {
|
||||
const { rOptions, log, RequestCounter } = context
|
||||
|
@ -164,7 +163,7 @@ function createOverwallMiddleware (overWallConfig) {
|
|||
}
|
||||
const hostname = rOptions.hostname
|
||||
const matchedResult = matched(hostname, overWallTargetMap)
|
||||
if (matchedResult == null || matchedResult === false) {
|
||||
if (matchedResult == null || matchedResult === false || matchedResult === 'false') {
|
||||
return
|
||||
}
|
||||
const cacheKey = '__over_wall_proxy__'
|
||||
|
|
|
@ -9,11 +9,11 @@ const jsonApi = require('../../../json')
|
|||
function isSslConnect (sslConnectInterceptors, req, cltSocket, head) {
|
||||
for (const intercept of sslConnectInterceptors) {
|
||||
const ret = intercept(req, cltSocket, head)
|
||||
log.debug(`拦截判断结果:${ret}, url: ${req.url}, intercept:`, intercept)
|
||||
if (ret === false || ret === true) {
|
||||
return ret
|
||||
log.debug('当前拦截器返回结果:', ret, `, url: ${req.url}, intercept:`, intercept)
|
||||
if (ret == null) {
|
||||
continue
|
||||
}
|
||||
// continue
|
||||
return !(ret === false || ret === 'false')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -58,19 +58,64 @@ function connect (req, cltSocket, head, hostname, port, dnsConfig = null, isDire
|
|||
const isDnsIntercept = {}
|
||||
const hostport = `${hostname}:${port}`
|
||||
try {
|
||||
// 客户端的连接事件监听
|
||||
cltSocket.on('timeout', (e) => {
|
||||
log.error(`cltSocket timeout: ${hostport}, errorMsg: ${e.message}`)
|
||||
})
|
||||
cltSocket.on('error', (e) => {
|
||||
log.error(`cltSocket error: ${hostport}, errorMsg: ${e.message}`)
|
||||
})
|
||||
// 开发过程中,如有需要可以将此参数临时改为true,打印所有事件的日志
|
||||
const printDebugLog = false && process.env.NODE_ENV === 'development'
|
||||
if (printDebugLog) {
|
||||
cltSocket.on('close', (hadError) => {
|
||||
log.debug('【cltSocket close】', hadError)
|
||||
})
|
||||
cltSocket.on('connect', () => {
|
||||
log.debug('【cltSocket connect】')
|
||||
})
|
||||
cltSocket.on('connectionAttempt', (ip, port, family) => {
|
||||
log.debug(`【cltSocket connectionAttempt】${ip}:${port}, family:`, family)
|
||||
})
|
||||
cltSocket.on('connectionAttemptFailed', (ip, port, family) => {
|
||||
log.debug(`【cltSocket connectionAttemptFailed】${ip}:${port}, family:`, family)
|
||||
})
|
||||
cltSocket.on('connectionAttemptTimeout', (ip, port, family) => {
|
||||
log.debug(`【cltSocket connectionAttemptTimeout】${ip}:${port}, family:`, family)
|
||||
})
|
||||
cltSocket.on('data', (data) => {
|
||||
log.debug('【cltSocket data】')
|
||||
})
|
||||
cltSocket.on('drain', () => {
|
||||
log.debug('【cltSocket drain】')
|
||||
})
|
||||
cltSocket.on('end', () => {
|
||||
log.debug('【cltSocket end】')
|
||||
})
|
||||
// cltSocket.on('lookup', (err, address, family, host) => {
|
||||
// })
|
||||
cltSocket.on('ready', () => {
|
||||
log.debug('【cltSocket ready】')
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
const options = {
|
||||
port,
|
||||
host: hostname,
|
||||
connectTimeout: 10000
|
||||
}
|
||||
if (dnsConfig && dnsConfig.providers) {
|
||||
if (dnsConfig && dnsConfig.dnsMap) {
|
||||
const dns = DnsUtil.hasDnsLookup(dnsConfig, hostname)
|
||||
if (dns) {
|
||||
options.lookup = dnsLookup.createLookupFunc(null, dns, 'connect', hostport, isDnsIntercept)
|
||||
}
|
||||
}
|
||||
// 代理连接事件监听
|
||||
const proxySocket = net.connect(options, () => {
|
||||
if (!isDirect) log.info('Proxy connect start:', hostport)
|
||||
else log.debug('Direct connect start:', hostport)
|
||||
|
||||
cltSocket.write('HTTP/1.1 200 Connection Established\r\n' +
|
||||
'Proxy-agent: dev-sidecar\r\n' +
|
||||
|
@ -80,15 +125,9 @@ function connect (req, cltSocket, head, hostname, port, dnsConfig = null, isDire
|
|||
|
||||
cltSocket.pipe(proxySocket)
|
||||
})
|
||||
cltSocket.on('timeout', (e) => {
|
||||
log.error(`cltSocket timeout: ${hostport}, errorMsg: ${e.message}`)
|
||||
})
|
||||
cltSocket.on('error', (e) => {
|
||||
log.error(`cltSocket error: ${hostport}, errorMsg: ${e.message}`)
|
||||
})
|
||||
proxySocket.on('timeout', () => {
|
||||
const cost = new Date() - start
|
||||
const errorMsg = `代理连接超时: ${hostport}, cost: ${cost} ms`
|
||||
const errorMsg = `${isDirect ? '直连' : '代理连接'}超时: ${hostport}, cost: ${cost} ms`
|
||||
log.error(errorMsg)
|
||||
|
||||
cltSocket.destroy()
|
||||
|
@ -102,8 +141,8 @@ function connect (req, cltSocket, head, hostname, port, dnsConfig = null, isDire
|
|||
proxySocket.on('error', (e) => {
|
||||
// 连接失败,可能被GFW拦截,或者服务端拥挤
|
||||
const cost = new Date() - start
|
||||
const errorMsg = `代理连接失败: ${hostport}, cost: ${cost} ms, errorMsg: ${e.message}`
|
||||
log.error(errorMsg)
|
||||
const errorMsg = `${isDirect ? '直连' : '代理连接'}失败: ${hostport}, cost: ${cost} ms, errorMsg: ${e.message}`
|
||||
log.error(`${errorMsg}\r\n`, e)
|
||||
|
||||
cltSocket.destroy()
|
||||
|
||||
|
@ -113,8 +152,41 @@ function connect (req, cltSocket, head, hostname, port, dnsConfig = null, isDire
|
|||
log.error(`记录ip失败次数,用于优选ip! hostname: ${hostname}, ip: ${ip}, reason: ${errorMsg}, dns: ${dns.name}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (printDebugLog) {
|
||||
proxySocket.on('close', (hadError) => {
|
||||
log.debug('【proxySocket close】', hadError)
|
||||
})
|
||||
proxySocket.on('connect', () => {
|
||||
log.debug('【proxySocket connect】')
|
||||
})
|
||||
proxySocket.on('connectionAttempt', (ip, port, family) => {
|
||||
log.debug(`【proxySocket connectionAttempt】${ip}:${port}, family:`, family)
|
||||
})
|
||||
proxySocket.on('connectionAttemptFailed', (ip, port, family) => {
|
||||
log.debug(`【proxySocket connectionAttemptFailed】${ip}:${port}, family:`, family)
|
||||
})
|
||||
proxySocket.on('connectionAttemptTimeout', (ip, port, family) => {
|
||||
log.debug(`【proxySocket connectionAttemptTimeout】${ip}:${port}, family:`, family)
|
||||
})
|
||||
proxySocket.on('data', (data) => {
|
||||
log.debug('【proxySocket data】')
|
||||
})
|
||||
proxySocket.on('drain', () => {
|
||||
log.debug('【proxySocket drain】')
|
||||
})
|
||||
proxySocket.on('end', () => {
|
||||
log.debug('【proxySocket end】')
|
||||
})
|
||||
// proxySocket.on('lookup', (err, address, family, host) => {
|
||||
// })
|
||||
proxySocket.on('ready', () => {
|
||||
log.debug('【proxySocket ready】')
|
||||
})
|
||||
}
|
||||
|
||||
return proxySocket
|
||||
} catch (e) {
|
||||
log.error(`Proxy connect error: ${hostport}, exception:`, e)
|
||||
log.error(`${isDirect ? '直连' : '代理连接'}错误: ${hostport}, error:`, e)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -110,17 +110,23 @@ module.exports = function createRequestHandler (createIntercepts, middlewares, e
|
|||
log.info('发起代理请求:', url, (rOptions.servername ? ', sni: ' + rOptions.servername : ''), ', headers:', jsonApi.stringify2(rOptions.headers))
|
||||
|
||||
const isDnsIntercept = {}
|
||||
if (dnsConfig && dnsConfig.providers) {
|
||||
if (dnsConfig && dnsConfig.dnsMap) {
|
||||
let dns = DnsUtil.hasDnsLookup(dnsConfig, rOptions.hostname)
|
||||
if (!dns && rOptions.servername) {
|
||||
dns = dnsConfig.providers.quad9
|
||||
dns = dnsConfig.dnsMap.quad9
|
||||
if (dns) {
|
||||
log.info(`域名 ${rOptions.hostname} 在dns中未配置,但使用了 sni: ${rOptions.servername}, 必须使用dns,现默认使用 'quad9' DNS.`)
|
||||
}
|
||||
}
|
||||
if (dns) {
|
||||
rOptions.lookup = dnsLookup.createLookupFunc(res, dns, 'request url', url, isDnsIntercept)
|
||||
log.debug(`域名 ${rOptions.hostname} DNS: ${dns.name}`)
|
||||
res.setHeader('DS-DNS', dns.name)
|
||||
} else {
|
||||
log.info(`域名 ${rOptions.hostname} 在DNS中未配置`)
|
||||
}
|
||||
} else {
|
||||
log.info(`域名 ${rOptions.hostname} DNS配置不存在`)
|
||||
}
|
||||
|
||||
// rOptions.sigalgs = 'RSA-PSS+SHA256:RSA-PSS+SHA512:ECDSA+SHA256'
|
||||
|
@ -147,9 +153,9 @@ module.exports = function createRequestHandler (createIntercepts, middlewares, e
|
|||
proxyReq = (rOptions.protocol === 'https:' ? https : http).request(rOptions, (proxyRes) => {
|
||||
const cost = new Date() - start
|
||||
if (rOptions.protocol === 'https:') {
|
||||
log.info(`代理请求返回: ${url}, cost: ${cost} ms`)
|
||||
log.info(`代理请求返回: 【${proxyRes.statusCode}】${url}, cost: ${cost} ms`)
|
||||
} else {
|
||||
log.info(`请求返回: ${url}, cost: ${cost} ms`)
|
||||
log.info(`请求返回: 【${proxyRes.statusCode}】${url}, cost: ${cost} ms`)
|
||||
}
|
||||
// console.log('request:', proxyReq, proxyReq.socket)
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ module.exports = {
|
|||
|
||||
return (hostname, options, callback) => {
|
||||
const tester = speedTest.getSpeedTester(hostname)
|
||||
if (tester && tester.ready) {
|
||||
if (tester) {
|
||||
const aliveIpObj = tester.pickFastAliveIpObj()
|
||||
if (aliveIpObj) {
|
||||
log.info(`----- ${action}: ${hostname}, use alive ip from dns '${aliveIpObj.dns}': ${aliveIpObj.host}${target} -----`)
|
||||
|
@ -16,7 +16,7 @@ module.exports = {
|
|||
callback(null, aliveIpObj.host, 4)
|
||||
return
|
||||
} else {
|
||||
log.info(`----- ${action}: ${hostname}, no alive ip${target}, tester:`, tester)
|
||||
log.info(`----- ${action}: ${hostname}, no alive ip${target}, tester: { "ready": ${tester.ready}, "backupList": ${JSON.stringify(tester.backupList)} }`)
|
||||
}
|
||||
}
|
||||
dns.lookup(hostname).then(ip => {
|
||||
|
|
|
@ -41,7 +41,7 @@ module.exports = {
|
|||
|
||||
port = ~~port
|
||||
const speedTestConfig = dnsConfig.speedTest
|
||||
const dnsMap = dnsConfig.providers
|
||||
const dnsMap = dnsConfig.dnsMap
|
||||
if (speedTestConfig) {
|
||||
const dnsProviders = speedTestConfig.dnsProviders
|
||||
const map = {}
|
||||
|
|
|
@ -95,7 +95,7 @@ module.exports = (serverConfig) => {
|
|||
port: serverConfig.port,
|
||||
dnsConfig: {
|
||||
preSetIpList,
|
||||
providers: dnsUtil.initDNS(serverConfig.dns.providers, preSetIpList),
|
||||
dnsMap: dnsUtil.initDNS(serverConfig.dns.providers, preSetIpList),
|
||||
mapping: matchUtil.domainMapRegexply(dnsMapping),
|
||||
speedTest: serverConfig.dns.speedTest
|
||||
},
|
||||
|
@ -119,10 +119,10 @@ module.exports = (serverConfig) => {
|
|||
const matched = matchUtil.matchHostname(intercepts, hostname, 'matched intercepts')
|
||||
if ((!!matched) === true) {
|
||||
log.debug(`拦截器拦截:${req.url}, matched:`, matched)
|
||||
return true // 拦截
|
||||
return matched // 拦截
|
||||
}
|
||||
|
||||
return null // 未匹配到任何拦截配置,由下一个拦截器判断
|
||||
return null // 不在白名单中,也未配置在拦截功能中,跳过当前拦截器,由下一个拦截器判断
|
||||
},
|
||||
createIntercepts: (context) => {
|
||||
const rOptions = context.rOptions
|
||||
|
|
|
@ -26,11 +26,11 @@ function domainRegexply (target) {
|
|||
}
|
||||
|
||||
function domainMapRegexply (hostMap) {
|
||||
if (hostMap == null) {
|
||||
return { origin: {} }
|
||||
}
|
||||
const regexpMap = {}
|
||||
const origin = {} // 用于快速匹配,见matchHostname、matchHostnameAll方法
|
||||
if (hostMap == null) {
|
||||
return regexpMap
|
||||
}
|
||||
lodash.each(hostMap, (value, domain) => {
|
||||
if (domain.indexOf('*') >= 0 || domain[0] === '^') {
|
||||
const regDomain = domain[0] !== '^' ? domainRegexply(domain) : domain
|
||||
|
@ -61,17 +61,17 @@ function matchHostname (hostMap, hostname, action) {
|
|||
|
||||
// 域名快速匹配:直接匹配 或者 两种前缀通配符匹配
|
||||
let value = hostMap.origin[hostname]
|
||||
if (value) {
|
||||
if (value != null) {
|
||||
log.info(`matchHostname: ${action}: '${hostname}' -> { "${hostname}": ${JSON.stringify(value)} }`)
|
||||
return value // 快速匹配成功
|
||||
}
|
||||
value = hostMap.origin['*' + hostname]
|
||||
if (value) {
|
||||
if (value != null) {
|
||||
log.info(`matchHostname: ${action}: '${hostname}' -> { "*${hostname}": ${JSON.stringify(value)} }`)
|
||||
return value // 快速匹配成功
|
||||
}
|
||||
value = hostMap.origin['*.' + hostname]
|
||||
if (value) {
|
||||
if (value != null) {
|
||||
log.info(`matchHostname: ${action}: '${hostname}' -> { "*.${hostname}": ${JSON.stringify(value)} }`)
|
||||
return value // 快速匹配成功
|
||||
}
|
||||
|
@ -127,8 +127,8 @@ function matchHostnameAll (hostMap, hostname, action) {
|
|||
let value
|
||||
|
||||
// 通配符匹配 或 正则表达式匹配(优先级:1,最低)
|
||||
for (const target in hostMap) {
|
||||
if (target === 'origin') {
|
||||
for (const regexp in hostMap) {
|
||||
if (regexp === 'origin') {
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -136,16 +136,10 @@ function matchHostnameAll (hostMap, hostname, action) {
|
|||
// continue // 不是通配符匹配串,也不是正则表达式,跳过
|
||||
// }
|
||||
|
||||
// 如果是通配符匹配串,转换为正则表达式
|
||||
let regexp = target
|
||||
// if (target[0] !== '^') {
|
||||
// regexp = domainRegexply(regexp)
|
||||
// }
|
||||
|
||||
// 正则表达式匹配
|
||||
if (hostname.match(regexp)) {
|
||||
value = hostMap[target]
|
||||
log.debug(`matchHostname-one: ${action}: '${hostname}' -> { "${target}": ${JSON.stringify(value)} }`)
|
||||
value = hostMap[regexp]
|
||||
log.debug(`matchHostname-one: ${action}: '${hostname}' -> { "${regexp}": ${JSON.stringify(value)} }`)
|
||||
values = merge(values, value)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue