Merge pull request #1347 from gnmyt/features/custom-librespeed-server

⚙️ Custom Librespeed server
This commit is contained in:
Mathias Wagner
2026-01-20 22:54:37 +01:00
committed by GitHub
8 changed files with 92 additions and 18 deletions

View File

@@ -37,6 +37,8 @@
"interface": "Schnittstelle",
"server": "Server",
"server_id": "Server-ID",
"custom_url": "Benutzerdefinierte Server-URL",
"custom_url_placeholder": "https://speed.test/backend/",
"choose_automatically": "Automatisch wählen",
"ookla_license": "Ich habe die <Eula>EULA</Eula>, die <GDPR>Datenschutzrichtlinie</GDPR> und die <TOS>Nutzungsbedingungen</TOS> von Ookla gelesen und akzeptiere sie."
}

View File

@@ -39,6 +39,8 @@
"interface": "Interface",
"server": "Server",
"server_id": "Server ID",
"custom_url": "Custom Server URL",
"custom_url_placeholder": "https://speed.test/backend/",
"choose_automatically": "Choose automatically",
"ookla_license": "I have read and accept the <Eula>EULA</Eula>, <GDPR>privacy policy</GDPR> and <TOS>terms of service</TOS> of Ookla.",
"ookla_notice": "By using Ookla, you agree to their <Eula>EULA</Eula>, <GDPR>privacy policy</GDPR> and <TOS>terms of service</TOS>.",

View File

@@ -1,7 +1,7 @@
import {Dialog, DialogHeader, DialogBody, DialogFooter} from "@/common/contexts/Dialog";
import {t} from "i18next";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faCheck, faServer, faNetworkWired} from "@fortawesome/free-solid-svg-icons";
import {faCheck, faServer, faNetworkWired, faLink} from "@fortawesome/free-solid-svg-icons";
import "./styles.sass";
import React, {useContext, useEffect, useState} from "react";
import OoklaImage from "./assets/img/ookla.webp";
@@ -27,6 +27,7 @@ export const ProviderDialog = ({open, onClose}) => {
const [ooklaServers, setOoklaServers] = useState({});
const [libreServers, setLibreServers] = useState({});
const [serverId, setServerId] = useState("none");
const [libreUrl, setLibreUrl] = useState(config.libreUrl || "none");
useEffect(() => {
if (!open) return;
@@ -37,17 +38,35 @@ export const ProviderDialog = ({open, onClose}) => {
useEffect(() => {
if (config[provider + "Id"]) setServerId(config[provider + "Id"]);
if (config.libreUrl) setLibreUrl(config.libreUrl);
}, [provider, config]);
useEffect(() => {
if (serverId === "") setServerId("none");
}, [serverId]);
useEffect(() => {
if (libreUrl === "") setLibreUrl("none");
}, [libreUrl]);
const handleLibreUrlChange = (value) => {
setLibreUrl(value);
if (value && value !== "none") setServerId("none");
};
const handleServerIdChange = (value) => {
setServerId(value);
if (provider === "libre" && value && value !== "none") setLibreUrl("none");
};
const update = async (close) => {
await patchRequest("/config/provider", {value: provider});
if (serverId !== config[provider + "Id"] && provider !== "cloudflare") {
await patchRequest("/config/" + provider + "Id", {value: serverId});
}
if (provider === "libre" && libreUrl !== config.libreUrl) {
await patchRequest("/config/libreUrl", {value: libreUrl});
}
if (currentInterface !== config.interface) {
await patchRequest("/config/interface", {value: currentInterface});
}
@@ -56,8 +75,10 @@ export const ProviderDialog = ({open, onClose}) => {
close();
};
const isUsingCustomUrl = provider === "libre" && libreUrl && libreUrl !== "none";
return (
<Dialog open={open} onClose={onClose} className="provider-dialog">
<Dialog open={open} onClose={onClose} className="provider-dialog-wrapper">
{({close}) => (
<>
<DialogHeader onClose={close}>{t("update.provider_title")}</DialogHeader>
@@ -87,14 +108,14 @@ export const ProviderDialog = ({open, onClose}) => {
</select>
</div>
{provider !== "cloudflare" && (
{provider !== "cloudflare" && !isUsingCustomUrl && (
<div className="provider-setting">
<div className="provider-setting-label">
<FontAwesomeIcon icon={faServer}/>
<h3>{t("dialog.provider.server")}</h3>
</div>
<select className="dialog-input provider-input" value={serverId}
onChange={(e) => setServerId(e.target.value)}>
onChange={(e) => handleServerIdChange(e.target.value)}>
<option value="none">{t("dialog.provider.choose_automatically")}</option>
{provider === "ookla" && Object.keys(ooklaServers).map((current, index) => (
<option key={index} value={current}>{ooklaServers[current]}</option>
@@ -106,14 +127,27 @@ export const ProviderDialog = ({open, onClose}) => {
</div>
)}
{provider !== "cloudflare" && serverId !== "none" && (
{provider !== "cloudflare" && serverId !== "none" && !isUsingCustomUrl && (
<div className="provider-setting">
<div className="provider-setting-label">
<h3>{t("dialog.provider.server_id")}</h3>
</div>
<input type="text" className="dialog-input provider-input"
value={serverId === "none" ? "" : serverId}
onChange={(e) => setServerId(e.target.value)}/>
onChange={(e) => handleServerIdChange(e.target.value)}/>
</div>
)}
{provider === "libre" && (
<div className="provider-setting">
<div className="provider-setting-label">
<FontAwesomeIcon icon={faLink}/>
<h3>{t("dialog.provider.custom_url")}</h3>
</div>
<input type="text" className="dialog-input provider-input"
placeholder={t("dialog.provider.custom_url_placeholder")}
value={libreUrl === "none" ? "" : libreUrl}
onChange={(e) => handleLibreUrlChange(e.target.value || "none")}/>
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
@use "@/common/styles/colors" as *
.provider-dialog
.provider-dialog-wrapper
width: 28rem
max-width: 95vw
@@ -13,12 +13,14 @@
.provider-list
display: flex
flex-wrap: wrap
gap: 0.5rem
margin-bottom: 1rem
.provider-item
display: flex
flex: 1
flex: 1 1 auto
min-width: 7rem
align-items: center
justify-content: center
gap: 0.5rem

View File

@@ -20,6 +20,7 @@ const configDefaults = {
provider: "none",
ooklaId: "none",
libreId: "none",
libreUrl: "none",
password: "none",
passwordLevel: "none",
interface: "none"
@@ -89,6 +90,14 @@ module.exports.validateInput = async (key, value) => {
if ((key === "ooklaId" || key === "libreId") && (/[^0-9]/.test(value) && value !== "none"))
return "You need to provide a number in order to change this";
if (key === "libreUrl" && value !== "none") {
try {
new URL(value);
} catch (e) {
return "You need to provide a valid URL";
}
}
if (key === "passwordLevel" && !["none", "read"].includes(value))
return "You need to provide either none or read-access";

View File

@@ -6,7 +6,7 @@ const password = require('../middlewares/password');
app.get("/", password(true), async (req, res) => {
let configValues = {};
(await config.listAll()).forEach(row => {
if (row.key !== "password" && !(req.viewMode && ["ooklaId", "libreId", "cron", "passwordLevel"].includes(row.key)))
if (row.key !== "password" && !(req.viewMode && ["ooklaId", "libreId", "libreUrl", "cron", "passwordLevel"].includes(row.key)))
configValues[row.key] = row.value;
});
configValues['viewMode'] = req.viewMode;

View File

@@ -43,24 +43,31 @@ module.exports.run = async (retryAuto = false) => {
}
let serverId = mode === "cloudflare" ? 0 : await config.getValue(mode + "Id");
let serverUrl = mode === "libre" ? await config.getValue("libreUrl") : undefined;
if (serverId === "none")
serverId = undefined;
if (serverUrl === "none")
serverUrl = undefined;
let speedtest = await (retryAuto ? speedTest(mode) : speedTest(mode, serverId));
if (mode === "libre" && serverUrl)
serverId = undefined;
let speedtest = await (retryAuto ? speedTest(mode) : speedTest(mode, serverId, serverUrl));
if (mode === "ookla" && speedtest.server) {
if (serverId === undefined) await config.updateValue("ooklaId", speedtest.server?.id);
serverId = speedtest.server?.id;
}
if (mode === "libre" && speedtest.server) {
let server = Object.entries(serverController.getLibreServers())
.filter(([, value]) => value === speedtest.server.name)[0][0];
if (mode === "libre" && speedtest.server && !serverUrl) {
let serverEntry = Object.entries(serverController.getLibreServers())
.filter(([, value]) => value === speedtest.server.name)[0];
if (server) {
if (serverId === undefined) await config.updateValue("libreId", server);
serverId = parseInt(server);
if (serverEntry) {
if (serverId === undefined) await config.updateValue("libreId", serverEntry[0]);
serverId = parseInt(serverEntry[0]);
}
}

View File

@@ -1,8 +1,10 @@
const {spawn} = require('child_process');
const interfaces = require('../util/loadInterfaces');
const config = require('../controller/config');
const fs = require('fs');
const path = require('path');
module.exports = async (mode, serverId) => {
module.exports = async (mode, serverId, serverUrl) => {
const binaryPath = mode === "ookla" ? './bin/speedtest' + (process.platform === "win32" ? ".exe" : "")
: mode === "libre" ? './bin/librespeed-cli' + (process.platform === "win32" ? ".exe" : "")
: './bin/cfspeedtest' + (process.platform === "win32" ? ".exe" : "");
@@ -27,7 +29,23 @@ module.exports = async (mode, serverId) => {
if (serverId) args.push(`--server-id=${serverId}`);
} else if (mode === "libre") {
args = ['--json', '--duration=5', '--source=' + interfaceIp];
if (serverId) args.push(`--server=${serverId}`);
if (serverUrl) {
const customServerConfig = [{
id: 1,
name: "Custom Server",
server: serverUrl,
dlURL: "garbage.php",
ulURL: "empty.php",
pingURL: "empty.php",
getIpURL: "getIP.php"
}];
const tempJsonPath = path.join('data', 'servers', 'libre_custom.json');
fs.writeFileSync(tempJsonPath, JSON.stringify(customServerConfig));
args.push(`--local-json=${tempJsonPath}`);
args.push('--server=1');
} else if (serverId) {
args.push(`--server=${serverId}`);
}
} else if (mode === "cloudflare") {
args = ['--output-format=json'];