diff --git a/server/config/binaries.js b/server/config/binaries.js index 29c17a817..276e0f424 100644 --- a/server/config/binaries.js +++ b/server/config/binaries.js @@ -38,4 +38,19 @@ module.exports.libreList = [ {os: 'freebsd', arch: 'ia32', suffix: 'freebsd_386.tar.gz'}, {os: 'freebsd', arch: 'arm', suffix: 'freebsd_armv7.tar.gz'}, {os: 'freebsd', arch: 'arm64', suffix: 'freebsd_arm64.tar.gz'} +]; + +module.exports.cloudflareVersion = "1.3.4"; +module.exports.cloudflareList = [ + // MacOS + {os: 'darwin', arch: 'x64', suffix: 'cfspeedtest-x86_64-apple-darwin.tar.gz'}, + {os: 'darwin', arch: 'arm64', suffix: 'cfspeedtest-aarch64-apple-darwin.tar.gz'}, + {os: 'darwin', arch: 'universal', suffix: 'cfspeedtest-universal-apple-darwin.tar.gz'}, + + // Windows + {os: 'win32', arch: 'x64', suffix: 'cfspeedtest-x86_64-pc-windows-msvc.zip'}, + + // Linux + {os: 'linux', arch: 'x64', suffix: 'cfspeedtest-x86_64-unknown-linux-gnu.tar.gz'}, + {os: 'linux', arch: 'arm64', suffix: 'cfspeedtest-aarch64-unknown-linux-gnu.tar.gz'} ] \ No newline at end of file diff --git a/server/tasks/cloudflare.js b/server/tasks/cloudflare.js deleted file mode 100644 index 887b1584d..000000000 --- a/server/tasks/cloudflare.js +++ /dev/null @@ -1,234 +0,0 @@ -/** - * - * This file is part of speed-cloudflare-cli (https://github.com/KNawm/speed-cloudflare-cli), - * which is released under the MIT License. - * - * This file has been modified to be used inside MySpeed. - * - * Copyright (c) 2020 Tomás Arias - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -const { performance } = require("perf_hooks"); -const https = require("https"); -const interfaces = require("../util/loadInterfaces"); -const config = require("../controller/config"); - -function average(values) { - let total = 0; - - for (let i = 0; i < values.length; i += 1) { - total += values[i]; - } - - return total / values.length; -} - -function median(values) { - const half = Math.floor(values.length / 2); - - values.sort((a, b) => a - b); - - if (values.length % 2) return values[half]; - - return (values[half - 1] + values[half]) / 2; -} - -function quartile(values, percentile) { - values.sort((a, b) => a - b); - const pos = (values.length - 1) * percentile; - const base = Math.floor(pos); - const rest = pos - base; - - if (values[base + 1] !== undefined) { - return values[base] + rest * (values[base + 1] - values[base]); - } - - return values[base]; -} - -function jitter(values) { - // Average distance between consecutive latency measurements... - let jitters = []; - - for (let i = 0; i < values.length - 1; i += 1) { - jitters.push(Math.abs(values[i] - values[i+1])); - } - - return average(jitters); -} - -function request(localAddress, options, data = "") { - let started; - let dnsLookup; - let tcpHandshake; - let sslHandshake; - let ttfb; - let ended; - - options.localAddress = localAddress; - options.family = localAddress.includes(":") ? 6 : 4; - options.agent = new https.Agent(options); - - return new Promise((resolve, reject) => { - started = performance.now(); - const req = https.request(options, (res) => { - res.once("readable", () => { - ttfb = performance.now(); - }); - res.on("data", () => {}); - res.on("end", () => { - ended = performance.now(); - resolve([started, dnsLookup, tcpHandshake, sslHandshake, ttfb, ended, parseFloat(res.headers["server-timing"]?.slice(22))]); - }); - }); - - req.on("socket", (socket) => { - socket.on("lookup", () => { - dnsLookup = performance.now(); - }); - socket.on("connect", () => { - tcpHandshake = performance.now(); - }); - socket.on("secureConnect", () => { - sslHandshake = performance.now(); - }); - }); - - req.on("error", (error) => { - reject(error.message); - }); - - req.write(data); - req.end(); - }); -} - -function download(ip, bytes) { - const options = { - hostname: "speed.cloudflare.com", - path: `/__down?bytes=${bytes}`, - method: "GET", - }; - - return request(ip, options); -} - -function upload(ip, bytes) { - const data = "0".repeat(bytes); - const options = { - hostname: "speed.cloudflare.com", - path: "/__up", - method: "POST", - headers: { - "Content-Length": Buffer.byteLength(data), - }, - }; - - return request(ip, options, data); -} - -function measureSpeed(bytes, duration) { - return (bytes * 8) / (duration / 1000) / 1e6; -} - -async function measureLatency(ip) { - const measurements = []; - - for (let i = 0; i < 20; i += 1) { - await download(ip, 1000).then( - (response) => { - // TTFB - Server processing time - measurements.push(response[4] - response[0] - response[6]); - }, - (error) => { - console.log(`Error while pinging: ${error}`); - }, - ); - } - - return [Math.min(...measurements), Math.max(...measurements), average(measurements), median(measurements), jitter(measurements)]; -} - -async function measureDownload(ip, bytes, iterations) { - const measurements = []; - - for (let i = 0; i < iterations; i += 1) { - await download(ip, bytes).then( - (response) => { - const transferTime = response[5] - response[4]; - measurements.push(measureSpeed(bytes, transferTime)); - }, - (error) => { - console.log(`Error while downloading: ${error}`); - }, - ); - } - - return measurements; -} - -async function measureUpload(ip, bytes, iterations) { - const measurements = []; - - for (let i = 0; i < iterations; i += 1) { - await upload(ip, bytes).then( - (response) => { - const transferTime = response[6]; - measurements.push(measureSpeed(bytes, transferTime)); - }, - (error) => { - console.log(`Error while uploading: ${error}`); - }, - ); - } - - return measurements; -} - -module.exports = async function speedTest() { - let result = {}; - try { - const currentInterface = await config.getValue("interface"); - const interfaceIp = interfaces.interfaces[currentInterface]; - if (!interfaceIp) { - throw new Error("Invalid interface"); - } - - result["ping"] = Math.round((await measureLatency(interfaceIp))[3]); - - const testDown1 = await measureDownload(interfaceIp, 101000, 1); - const testDown2 = await measureDownload(interfaceIp, 1001000, 8); - const testDown3 = await measureDownload(interfaceIp, 10001000, 6); - const testDown4 = await measureDownload(interfaceIp, 25001000, 4); - const testDown5 = await measureDownload(interfaceIp, 100001000, 1); - - result["download"] = quartile([...testDown1, ...testDown2, ...testDown3, ...testDown4, ...testDown5], 0.9).toFixed(2); - - const testUp1 = await measureUpload(interfaceIp, 11000, 10); - const testUp2 = await measureUpload(interfaceIp, 101000, 10); - const testUp3 = await measureUpload(interfaceIp, 1001000, 8); - result["upload"] = quartile([...testUp1, ...testUp2, ...testUp3], 0.9).toFixed(2); - } catch (error) { - console.error("Error while using cloudflare speedtest: " + error.message); - result = {error: error.message}; - } - - return result; -} \ No newline at end of file diff --git a/server/tasks/speedtest.js b/server/tasks/speedtest.js index 103bf3aaf..4cebbc4ee 100644 --- a/server/tasks/speedtest.js +++ b/server/tasks/speedtest.js @@ -5,7 +5,6 @@ const controller = require("../controller/recommendations"); const parseData = require('../util/providers/parseData'); let {setState, sendRunning, sendError, sendFinished} = require("./integrations"); const serverController = require("../controller/servers"); -const cloudflareTask = require("./cloudflare"); let isRunning = false; @@ -48,14 +47,7 @@ module.exports.run = async (retryAuto = false) => { if (serverId === "none") serverId = undefined; - let speedtest; - if (mode === "cloudflare") { - const startTime = new Date().getTime(); - speedtest = await cloudflareTask(); - speedtest = {...speedtest, elapsed: (new Date().getTime() - startTime) / 1000}; - } else { - speedtest = await (retryAuto ? speedTest(mode) : speedTest(mode, serverId)); - } + let speedtest = await (retryAuto ? speedTest(mode) : speedTest(mode, serverId)); if (mode === "ookla" && speedtest.server) { if (serverId === undefined) await config.updateValue("ooklaId", speedtest.server?.id); diff --git a/server/util/loadCli.js b/server/util/loadCli.js index aaf3a8ba7..59bf383c6 100644 --- a/server/util/loadCli.js +++ b/server/util/loadCli.js @@ -1,7 +1,9 @@ const libreProvider = require('./providers/loadLibre'); const ooklaProvider = require('./providers/loadOokla'); +const cloudflareProvider = require('./providers/loadCloudflare'); module.exports.load = async () => { await libreProvider.load(); await ooklaProvider.load(); + await cloudflareProvider.load(); } \ No newline at end of file diff --git a/server/util/providers/loadCloudflare.js b/server/util/providers/loadCloudflare.js new file mode 100644 index 000000000..1a60b5225 --- /dev/null +++ b/server/util/providers/loadCloudflare.js @@ -0,0 +1,78 @@ +const fs = require('fs'); +const path = require('path'); +const { get } = require('https'); +const decompress = require("decompress"); +const decompressTarGz = require('decompress-targz'); +const decompressUnzip = require('decompress-unzip'); +const { file } = require("tmp"); +const binaries = require('../../config/binaries'); + +const binaryName = `cfspeedtest${process.platform === "win32" ? ".exe" : ""}`; +const binaryDirectory = path.join(__dirname, "../../../bin"); +const binaryPath = path.join(binaryDirectory, binaryName); +const downloadBaseURL = `https://github.com/code-inflation/cfspeedtest/releases/download/v${binaries.cloudflareVersion}/`; + +const binaryRegex = /cfspeedtest(.exe)?$/; + +module.exports.fileExists = async () => fs.existsSync(binaryPath); + +const downloadToFile = (url, destinationPath) => { + return new Promise((resolve, reject) => { + get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return resolve(downloadToFile(res.headers.location, destinationPath)); + } + + const writeStream = fs.createWriteStream(destinationPath); + res.pipe(writeStream); + + writeStream.on('finish', () => resolve()); + writeStream.on('error', reject); + res.on('error', reject); + }).on('error', reject); + }); +} + +const decompressBinary = async (archivePath) => { + await decompress(archivePath, binaryDirectory, { + plugins: [decompressTarGz(), decompressUnzip()], + filter: file => binaryRegex.test(file.path), + map: file => { + file.path = binaryName; + return file; + } + }); +} + +module.exports.downloadFile = async () => { + let binary = binaries.cloudflareList.find(b => b.os === process.platform && b.arch === process.arch); + + if (!binary && process.platform === 'darwin') { + binary = binaries.cloudflareList.find(b => b.os === 'darwin' && b.arch === 'universal'); + } + + if (!binary) { + throw new Error(`Your platform (${process.platform}-${process.arch}) is not supported by the Cloudflare CLI`); + } + + return new Promise((resolve, reject) => { + file({ postfix: binary.suffix }, async (err, tempPath) => { + if (err) return reject(err); + + try { + const fullUrl = downloadBaseURL + binary.suffix; + await downloadToFile(fullUrl, tempPath); + await decompressBinary(tempPath); + resolve(); + } catch (error) { + reject(new Error(`Failed to download and extract binary: ${error.message}`)); + } + }); + }); +}; + +module.exports.load = async () => { + if (!await module.exports.fileExists()) { + await module.exports.downloadFile(); + } +}; \ No newline at end of file diff --git a/server/util/providers/parseData.js b/server/util/providers/parseData.js index 0e6f4e3e4..c0ae65bce 100644 --- a/server/util/providers/parseData.js +++ b/server/util/providers/parseData.js @@ -14,7 +14,27 @@ module.exports.parseOokla = (test) => { module.exports.parseLibre = (test) => ({...test, ping: Math.round(test.ping), time: Math.round(test.elapsed / 1000), resultId: null}); -module.exports.parseCloudflare = (test) => ({...test, time: Math.round(test.elapsed), resultId: null}); +module.exports.parseCloudflare = (test) => { + if (test && test.latency_measurement && test.speed_measurements) { + const downloadTests = test.speed_measurements.filter(t => t.test_type === "Download"); + const uploadTests = test.speed_measurements.filter(t => t.test_type === "Upload"); + + const downloadSpeeds = downloadTests.map(t => t.max || t.median || 0); + const download = downloadSpeeds.length > 0 ? Math.max(...downloadSpeeds) : 0; + + const uploadSpeeds = uploadTests.map(t => t.max || t.median || 0); + const upload = uploadSpeeds.length > 0 ? Math.max(...uploadSpeeds) : 0; + + const ping = Math.round(test.latency_measurement.avg_latency_ms || 0); + + const time = Math.round((test.elapsed || 30000) / 1000); + + return {ping, download: parseFloat(download.toFixed(2)), + upload: parseFloat(upload.toFixed(2)), time, resultId: null}; + } + + return {ping: 0, download: 0, upload: 0, time: 0, resultId: null}; +}; module.exports.parseData = (provider, data) => { switch (provider) { diff --git a/server/util/speedtest.js b/server/util/speedtest.js index 43f0640fd..91dfd0726 100644 --- a/server/util/speedtest.js +++ b/server/util/speedtest.js @@ -4,7 +4,8 @@ const config = require('../controller/config'); module.exports = async (mode, serverId) => { const binaryPath = mode === "ookla" ? './bin/speedtest' + (process.platform === "win32" ? ".exe" : "") - : './bin/librespeed-cli' + (process.platform === "win32" ? ".exe" : ""); + : mode === "libre" ? './bin/librespeed-cli' + (process.platform === "win32" ? ".exe" : "") + : './bin/cfspeedtest' + (process.platform === "win32" ? ".exe" : ""); if (!interfaces.interfaces) throw new Error("No interfaces found"); @@ -24,12 +25,21 @@ module.exports = async (mode, serverId) => { } if (serverId) args.push(`--server-id=${serverId}`); - } else { + } else if (mode === "libre") { args = ['--json', '--duration=5', '--source=' + interfaceIp]; if (serverId) args.push(`--server=${serverId}`); + } else if (mode === "cloudflare") { + args = ['--output-format=json']; + + if (interfaceIp.includes(':')) { + args.push('--ipv6=' + interfaceIp); + } else { + args.push('--ipv4=' + interfaceIp); + } } let result = {}; + let stdout = ''; const testProcess = spawn(binaryPath, args, {windowsHide: true}); @@ -41,25 +51,36 @@ module.exports = async (mode, serverId) => { }); testProcess.stdout.on('data', (buffer) => { - const line = buffer.toString().replace("\n", ""); - if (!(line.startsWith("{") || line.startsWith("["))) return; - - let data = {}; - try { - data = JSON.parse(line); - if (line.startsWith("[")) data = data[0]; - } catch (e) { - data.error = e.message; - } - - if (data.error) result.error = data.error; - - if ((mode === "ookla" && data.type === "result") || mode === "libre") result = data; + stdout += buffer.toString(); }); await new Promise((resolve, reject) => { testProcess.on('error', e => reject({message: e})); - testProcess.on('exit', resolve); + testProcess.on('exit', () => { + if (stdout.trim()) { + const lines = stdout.trim().split('\n'); + for (const line of lines) { + if (!(line.startsWith("{") || line.startsWith("["))) continue; + + let data = {}; + try { + data = JSON.parse(line); + if (line.startsWith("[") && mode !== "cloudflare") data = data[0]; + } catch (e) { + data.error = e.message; + console.error("JSON parse error:", e.message, "Line:", line); + continue; + } + + if (data.error) result.error = data.error; + + if ((mode === "ookla" && data.type === "result") || mode === "libre" || mode === "cloudflare") { + result = data; + } + } + } + resolve(); + }); }); if (result.error) throw new Error(result.error);