Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.DS_Store
/data/
/logs/
/node_modules/
/out/
/package-lock.json
/public/dist/
/public/electron/
/run/
224 changes: 224 additions & 0 deletions electron/class/device_probe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
'use strict';

const net = require('net');
const http = require('http');
const https = require('https');
const { SocksClient } = require('socks');
const { pub } = require('./public.js');

const ONLINE_CACHE_TIME = 30 * 1000;
const UNKNOWN_CACHE_TIME = 60 * 1000;
const MAX_OFFLINE_CACHE_TIME = 5 * 60 * 1000;

/**
* 低频设备可达性探测。面板 API 正常时不发起 TCP 请求,仅在网络级失败时补充探测。
*/
class DeviceProbe {
constructor(options = {}) {
this.timeout = options.timeout || 2500;
this.maxConcurrent = options.maxConcurrent || 10;
this.activeCount = 0;
this.queue = [];
this.states = new Map();
}

getKey(panel) {
return String(panel.panel_id || panel.url);
}

getState(panel) {
const key = this.getKey(panel);
if (!this.states.has(key)) {
this.states.set(key, {
status: 'unknown',
failures: 0,
nextProbeAt: 0,
inFlight: false,
callbacks: [],
version: 0
});
}
return this.states.get(key);
}

markReachable(panel) {
const state = this.getState(panel);
state.version++;
state.status = 'online';
state.failures = 0;
state.nextProbeAt = Date.now() + ONLINE_CACHE_TIME;
// 新的 API 成功结果已经替代旧失败请求,不再回放旧探测回调。
state.callbacks = state.callbacks.filter((item) => item.version >= state.version);
}

probe(panel, callback) {
const state = this.getState(panel);
const now = Date.now();

if (!state.inFlight && state.nextProbeAt > now) {
return setImmediate(() => callback(state.status));
}

state.callbacks.push({ callback: callback, version: state.version });
if (state.inFlight) return;

state.inFlight = true;
this.queue.push({ panel: Object.assign({}, panel), state: state, version: state.version });
this.drain();
}

drain() {
while (this.activeCount < this.maxConcurrent && this.queue.length > 0) {
const task = this.queue.shift();
if (task.version !== task.state.version && task.state.status === 'online') {
this.finish(task, 'online');
continue;
}
this.activeCount++;
this.connect(task.panel, (status) => {
this.finish(task, status);
this.activeCount--;
this.drain();
});
}
}

finish(task, status) {
const state = task.state;
if (task.version !== state.version) {
state.inFlight = false;
state.callbacks = state.callbacks.filter((item) => item.version > task.version);
if (state.callbacks.some((item) => item.version === state.version)) {
state.inFlight = true;
this.queue.push({ panel: task.panel, state: state, version: state.version });
}
return;
}

state.status = status;
state.inFlight = false;
if (status === 'online') {
state.failures = 0;
state.nextProbeAt = Date.now() + ONLINE_CACHE_TIME;
} else if (status === 'offline') {
state.failures++;
const retryDelay = Math.min(
ONLINE_CACHE_TIME * Math.pow(2, Math.max(0, state.failures - 1)),
MAX_OFFLINE_CACHE_TIME
);
state.nextProbeAt = Date.now() + retryDelay;
} else {
state.nextProbeAt = Date.now() + UNKNOWN_CACHE_TIME;
}

const callbacks = state.callbacks.filter((item) => item.version === task.version);
state.callbacks = state.callbacks.filter((item) => item.version !== task.version);
callbacks.forEach((item) => item.callback(state.status));
}

connect(panel, callback) {
let target;
try {
const panelUrl = new URL(panel.url);
target = {
host: panelUrl.hostname.replace(/^\[|\]$/g, ''),
port: Number(panelUrl.port || (panelUrl.protocol === 'https:' ? 443 : 80))
};
} catch (error) {
return callback('unknown');
}

let proxy = null;
try {
proxy = panel.proxy_id
? pub.M('proxy_info').where('proxy_id=?', panel.proxy_id).find()
: null;
} catch (error) {
return callback('unknown');
}

if (!proxy) return this.connectDirect(target, callback);
if (Number(proxy.proxy_type) === 2) return this.connectSocks(target, proxy, callback);
if (Number(proxy.proxy_type) === 0 || Number(proxy.proxy_type) === 1) {
return this.connectHttpProxy(target, proxy, callback);
}
return callback('unknown');
}

connectDirect(target, callback) {
let settled = false;
const socket = net.createConnection(target);
const finish = (status) => {
if (settled) return;
settled = true;
socket.destroy();
callback(status);
};
socket.setTimeout(this.timeout);
socket.once('connect', () => finish('online'));
socket.once('timeout', () => finish('offline'));
socket.once('error', (error) => {
const status = error && ['ENOTFOUND', 'EAI_AGAIN'].includes(error.code)
? 'unknown'
: 'offline';
finish(status);
});
}

connectSocks(target, proxy, callback) {
const options = {
proxy: {
host: proxy.proxy_ip,
port: Number(proxy.proxy_port),
type: 5,
userId: proxy.proxy_username || undefined,
password: proxy.proxy_password || undefined
},
command: 'connect',
destination: target,
timeout: this.timeout
};

SocksClient.createConnection(options, (error, info) => {
if (error || !info || !info.socket) return callback('unknown');
info.socket.destroy();
callback('online');
});
}

connectHttpProxy(target, proxy, callback) {
const headers = {};
if (proxy.proxy_username && proxy.proxy_password) {
const credentials = Buffer.from(`${proxy.proxy_username}:${proxy.proxy_password}`).toString('base64');
headers['Proxy-Authorization'] = `Basic ${credentials}`;
}

const transport = Number(proxy.proxy_type) === 1 ? https : http;
const request = transport.request({
host: proxy.proxy_ip,
port: Number(proxy.proxy_port),
method: 'CONNECT',
path: `${target.host}:${target.port}`,
headers: headers,
timeout: this.timeout,
rejectUnauthorized: false
});

let settled = false;
const finish = (status) => {
if (settled) return;
settled = true;
request.destroy();
callback(status);
};
request.once('connect', (response, socket) => {
socket.destroy();
finish(response.statusCode === 200 ? 'online' : 'unknown');
});
request.once('timeout', () => finish('unknown'));
request.once('error', () => finish('unknown'));
request.end();
}
}

module.exports = { DeviceProbe };
6 changes: 5 additions & 1 deletion electron/class/panel_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ PanelApi.prototype.request = function(uri,data,callback){
this.request_to_panel(uri,data,function(response,error){
if(error){
return callback(null,error);
}else if(response.statusCode >= 300 && response.statusCode < 400){
let redirect_error = new Error(response.statusMessage || `HTTP ${response.statusCode}`);
redirect_error.code = 'PANEL_HTTP_REDIRECT';
return callback(response.body,redirect_error);
}else if(response.statusCode != 200){
return callback(response.body,response.statusMessage);
}else{
Expand Down Expand Up @@ -144,4 +148,4 @@ module.exports = { PanelApi };
* console.log(res);
*
*
*/
*/
14 changes: 10 additions & 4 deletions electron/class/panel_app.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,20 +79,26 @@ class PanelApp {
return callback(null, err);
}

if (res.statusCode >= 300 && res.statusCode < 400) {
let redirect_error = new Error(res.statusMessage || `HTTP ${res.statusCode}`);
redirect_error.code = 'PANEL_HTTP_REDIRECT';
return callback(null, redirect_error);
}

if (res.body[0] == '{') {
let res_body = JSON.parse(res.body);
// pub.debug(res_body);
err = new Error(res_body.msg);
return callback(null, err);
}

let de_crypt_data = '';
let data;
try {
de_crypt_data = pub.aes_decrypt_ecb(res.body, that.KEY);
let de_crypt_data = pub.aes_decrypt_ecb(res.body, that.KEY);
data = JSON.parse(de_crypt_data);
} catch (e) {
return callback(null, e);
}
let data = JSON.parse(de_crypt_data);
if (data.status && data.data) data = data.data;
callback(data, err);
}, 6000);
Expand Down Expand Up @@ -158,4 +164,4 @@ class PanelApp {
}
}

module.exports = { PanelApp };
module.exports = { PanelApp };
4 changes: 3 additions & 1 deletion electron/class/sqlite.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class Sqlite {
this.checkField('ssh_info', 'os_name', 'TEXT', '"Linux"')
this.checkField('ssh_info', 'mstsc_options', 'TEXT', '"{}"')
this.checkField('ssh_info', 'sort', 'INTEGER', '0')
this.checkField('panel_info', 'sort', 'INTEGER', '0')
this.checkField('panel_info', 'ov', 'INTEGER', '-1')
this.checkField('panel_info', 'server_id', 'TEXT', '""')
this.checkField('panel_info', 'current_disk', 'TEXT', '""')
Expand Down Expand Up @@ -253,6 +254,7 @@ class Sqlite {
\`server_id\` TEXT DEFAULT "", -- server_id
\`proxy_id\` INTEGER DEFAULT 0, -- 代理ID
\`common_use\` INTEGER DEFAULT 0, -- 常用显示状态 1=显示 0=隐藏
\`sort\` INTEGER DEFAULT 0, -- 排序值
\`area\` TEXT DEFAULT "" -- 服务器归属区域
)`;

Expand Down Expand Up @@ -1210,4 +1212,4 @@ class Sqlite {
}
}

module.exports = { Sqlite }
module.exports = { Sqlite }
Loading