From 810218b67ea078e939cf284bd3f0212ef1767bf8 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 02:17:01 -0700 Subject: [PATCH 01/14] Add speedtest dependencies --- app/js/speedtestUI.js | 189 +++++ dist/speedtest/backend/empty.php | 14 + dist/speedtest/backend/garbage.php | 66 ++ dist/speedtest/backend/getIP.php | 325 ++++++++ .../speedtest/backend/getIP_ipInfo_apikey.php | 4 + .../backend/getIP_serverLocation.php | 3 + dist/speedtest/backend/getIP_util.php | 21 + dist/speedtest/speedtest.css | 231 ++++++ dist/speedtest/speedtest.js | 327 ++++++++ dist/speedtest/speedtest_worker.js | 725 ++++++++++++++++++ 10 files changed, 1905 insertions(+) create mode 100644 app/js/speedtestUI.js create mode 100755 dist/speedtest/backend/empty.php create mode 100755 dist/speedtest/backend/garbage.php create mode 100755 dist/speedtest/backend/getIP.php create mode 100755 dist/speedtest/backend/getIP_ipInfo_apikey.php create mode 100644 dist/speedtest/backend/getIP_serverLocation.php create mode 100755 dist/speedtest/backend/getIP_util.php create mode 100644 dist/speedtest/speedtest.css create mode 100755 dist/speedtest/speedtest.js create mode 100755 dist/speedtest/speedtest_worker.js diff --git a/app/js/speedtestUI.js b/app/js/speedtestUI.js new file mode 100644 index 00000000..e97b7c09 --- /dev/null +++ b/app/js/speedtestUI.js @@ -0,0 +1,189 @@ +function I(i){return document.getElementById(i);} + +const origin=window.location.origin; +const host=window.location.host; +var SPEEDTEST_SERVERS=[ + { + "name":"RaspAP Speedtest server (US)", + "server":"https://speedtest.raspap.com/", + "dlURL":"backend/garbage.php", + "ulURL":"backend/empty.php", + "pingURL":"backend/empty.php", + "getIpURL":"backend/getIP.php" + }, + { + "name":"RaspAP ("+host+")", + "server":origin, + "dlURL":"dist/speedtest/backend/garbage.php", + "ulURL":"dist/speedtest/backend/empty.php", + "pingURL":"dist/speedtest/backend/empty.php", + "getIpURL":"dist/speedtest/backend/getIP.php" + } +]; + +//INITIALIZE SPEEDTEST +var s=new Speedtest(); //create speedtest object +s.setParameter("telemetry_level","basic"); //enable telemetry + +//SERVER AUTO SELECTION +function initServers(){ + var noServersAvailable=function(){ + I("message").innerHTML="No servers available"; + } + var runServerSelect=function(){ + s.selectServer(function(server){ + if(server!=null){ //at least 1 server is available + I("loading").className="hidden"; //hide loading message + //populate server list for manual selection + for(var i=0;i 1024) { + return 1024; + } + + return (int) $_GET['ckSize']; +} + +/** + * @return void + */ +function sendHeaders() +{ + header('HTTP/1.1 200 OK'); + + if (isset($_GET['cors'])) { + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: GET, POST'); + } + + // Indicate a file download + header('Content-Description: File Transfer'); + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename=random.dat'); + header('Content-Transfer-Encoding: binary'); + + // Cache settings: never cache this request + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0, s-maxage=0'); + header('Cache-Control: post-check=0, pre-check=0', false); + header('Pragma: no-cache'); +} + +// Determine how much data we should send +$chunks = getChunkCount(); + +// Generate data +if (function_exists('random_bytes')) { + $data = random_bytes(1048576); +} else { + $data = openssl_random_pseudo_bytes(1048576); +} + +// Deliver chunks of 1048576 bytes +sendHeaders(); +for ($i = 0; $i < $chunks; $i++) { + echo $data; + flush(); +} diff --git a/dist/speedtest/backend/getIP.php b/dist/speedtest/backend/getIP.php new file mode 100755 index 00000000..c5147400 --- /dev/null +++ b/dist/speedtest/backend/getIP.php @@ -0,0 +1,325 @@ + $processedString, + 'rawIspInfo' => $rawIspInfo ?: '', + ] + ); +} + +$ip = getClientIp(); + +$localIpInfo = getLocalOrPrivateIpInfo($ip); +// local ip, no need to fetch further information +if (is_string($localIpInfo)) { + sendResponse($ip, $localIpInfo); + exit; +} + +if (!isset($_GET['isp'])) { + sendResponse($ip); + exit; +} + +$rawIspInfo = getIspInfo($ip); +$isp = getIsp($rawIspInfo); +$distance = getDistance($rawIspInfo); + +sendResponse($ip, $isp, $distance, $rawIspInfo); diff --git a/dist/speedtest/backend/getIP_ipInfo_apikey.php b/dist/speedtest/backend/getIP_ipInfo_apikey.php new file mode 100755 index 00000000..e200c3f1 --- /dev/null +++ b/dist/speedtest/backend/getIP_ipInfo_apikey.php @@ -0,0 +1,4 @@ +* { + display: block; + width: 100%; + height: auto; + margin: 0.25em 0; +} + +#privacyPolicy { + position: fixed; + top: 2em; + bottom: 2em; + left: 2em; + right: 2em; + overflow-y: auto; + margin: 0 auto; + width: 50%; + height: auto; + box-shadow: 0 0 3em 1em #333; + z-index: 999999; + text-align: left; + background-color: #FFFFFF; + padding: 1em; + border-radius: 0.3em; + color: #858796; +} + +#privacyPolicy h4, h5 { + color: #212529; +} + +a.privacy { + text-align: center; + font-size: 0.8em; + color: #808080; + display: block; +} + +@media all and (max-width:40em) { + body { + font-size: 0.8em; + } +} + +div.visible { + animation: fadeIn 0.4s; + display: block; +} + +div.hidden { + animation: fadeOut 0.4s; + display: none; +} + +div.centered { + margin: 0 auto; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes fadeOut { + 0% { + display: block; + opacity: 1; + } + + 100% { + display: block; + opacity: 0; + } +} diff --git a/dist/speedtest/speedtest.js b/dist/speedtest/speedtest.js new file mode 100755 index 00000000..1197cb4f --- /dev/null +++ b/dist/speedtest/speedtest.js @@ -0,0 +1,327 @@ +/* + LibreSpeed - Main + by Federico Dossena + https://github.com/librespeed/speedtest/ + GNU LGPLv3 License +*/ + +function Speedtest() { + this._serverList = []; //when using multiple points of test, this is a list of test points + this._selectedServer = null; //when using multiple points of test, this is the selected server + this._settings = {}; //settings for the speedtest worker + this._state = 0; //0=adding settings, 1=adding servers, 2=server selection done, 3=test running, 4=done +} + +Speedtest.prototype = { + constructor: Speedtest, + /** + * Returns the state of the test: 0=adding settings, 1=adding servers, 2=server selection done, 3=test running, 4=done + */ + getState: function() { + return this._state; + }, + /** + * Change one of the test settings from their defaults. + * - parameter: string with the name of the parameter that you want to set + * - value: new value for the parameter + * + * Invalid values or nonexistant parameters will be ignored by the speedtest worker. + */ + setParameter: function(parameter, value) { + if (this._state == 3) + throw "You cannot change the test settings while running the test"; + this._settings[parameter] = value; + if(parameter === "telemetry_extra"){ + this._originalExtra=this._settings.telemetry_extra; + } + }, + /** + * Used internally to check if a server object contains all the required elements. + * Also fixes the server URL if needed. + */ + _checkServerDefinition: function(server) { + try { + if (typeof server.name !== "string") + throw "Name string missing from server definition (name)"; + if (typeof server.server !== "string") + throw "Server address string missing from server definition (server)"; + if (server.server.charAt(server.server.length - 1) != "/") + server.server += "/"; + if (server.server.indexOf("//") == 0) + server.server = location.protocol + server.server; + if (typeof server.dlURL !== "string") + throw "Download URL string missing from server definition (dlURL)"; + if (typeof server.ulURL !== "string") + throw "Upload URL string missing from server definition (ulURL)"; + if (typeof server.pingURL !== "string") + throw "Ping URL string missing from server definition (pingURL)"; + if (typeof server.getIpURL !== "string") + throw "GetIP URL string missing from server definition (getIpURL)"; + } catch (e) { + throw "Invalid server definition"; + } + }, + /** + * Add a test point (multiple points of test) + * server: the server to be added as an object. Defined in app/js/speedtestUI.js + */ + addTestPoint: function(server) { + this._checkServerDefinition(server); + if (this._state == 0) this._state = 1; + if (this._state != 1) throw "You can't add a server after server selection"; + this._settings.mpot = true; + this._serverList.push(server); + }, + /** + * Same as addTestPoint, but you can pass an array of servers + */ + addTestPoints: function(list) { + for (var i = 0; i < list.length; i++) this.addTestPoint(list[i]); + }, + /** + * Load a JSON server list from URL (multiple points of test) + * url: the url where the server list can be fetched. Must be an array with objects containing the following elements: + * result: callback to be called when the list is loaded correctly. An array with the loaded servers will be passed to this function, or null if it failed + */ + loadServerList: function(url,result) { + if (this._state == 0) this._state = 1; + if (this._state != 1) throw "You can't add a server after server selection"; + this._settings.mpot = true; + var xhr = new XMLHttpRequest(); + xhr.onload = function(){ + try{ + var servers=JSON.parse(xhr.responseText); + for(var i=0;i= 3) + throw "You can't select a server while the test is running"; + } + if (this._selectServerCalled) throw "selectServer already called"; else this._selectServerCalled=true; + /*this function goes through a list of servers. For each server, the ping is measured, then the server with the function selected is called with the best server, or null if all the servers were down. + */ + var select = function(serverList, selected) { + //pings the specified URL, then calls the function result. Result will receive a parameter which is either the time it took to ping the URL, or -1 if something went wrong. + var PING_TIMEOUT = 2000; + var USE_PING_TIMEOUT = true; //will be disabled on unsupported browsers + if (/MSIE.(\d+\.\d+)/i.test(navigator.userAgent)) { + //IE11 doesn't support XHR timeout + USE_PING_TIMEOUT = false; + } + var ping = function(url, rtt) { + url += (url.match(/\?/) ? "&" : "?") + "cors=true"; + var xhr = new XMLHttpRequest(); + var t = new Date().getTime(); + xhr.onload = function() { + if (xhr.responseText.length == 0) { + //we expect an empty response + var instspd = new Date().getTime() - t; //rough timing estimate + try { + //try to get more accurate timing using performance API + var p = performance.getEntriesByName(url); + p = p[p.length - 1]; + var d = p.responseStart - p.requestStart; + if (d <= 0) d = p.duration; + if (d > 0 && d < instspd) instspd = d; + } catch (e) {} + rtt(instspd); + } else rtt(-1); + }.bind(this); + xhr.onerror = function() { + rtt(-1); + }.bind(this); + xhr.open("GET", url); + if (USE_PING_TIMEOUT) { + try { + xhr.timeout = PING_TIMEOUT; + xhr.ontimeout = xhr.onerror; + } catch (e) {} + } + xhr.send(); + }.bind(this); + + /** + * This function repeatedly pings a server to get a good estimate of the ping. When it's done, it calls the done function without parameters. + * At the end of the execution, the server will have a new parameter called pingT, which is either the best ping we got from the server or -1 if something went wrong. + */ + var PINGS = 3, //up to 3 pings are performed, unless the server is down... + SLOW_THRESHOLD = 500; //...or one of the pings is above this threshold + var checkServer = function(server, done) { + var i = 0; + server.pingT = -1; + if (server.server.indexOf(location.protocol) == -1) done(); + else { + var nextPing = function() { + if (i++ == PINGS) { + done(); + return; + } + ping( + server.server + server.pingURL, + function(t) { + if (t >= 0) { + if (t < server.pingT || server.pingT == -1) server.pingT = t; + if (t < SLOW_THRESHOLD) nextPing(); + else done(); + } else done(); + }.bind(this) + ); + }.bind(this); + nextPing(); + } + }.bind(this); + //check servers in list, one by one + var i = 0; + var done = function() { + var bestServer = null; + for (var i = 0; i < serverList.length; i++) { + if ( + serverList[i].pingT != -1 && + (bestServer == null || serverList[i].pingT < bestServer.pingT) + ) + bestServer = serverList[i]; + } + selected(bestServer); + }.bind(this); + var nextServer = function() { + if (i == serverList.length) { + done(); + return; + } + checkServer(serverList[i++], nextServer); + }.bind(this); + nextServer(); + }.bind(this); + + //parallel server selection + var CONCURRENCY = 6; + var serverLists = []; + for (var i = 0; i < CONCURRENCY; i++) { + serverLists[i] = []; + } + for (var i = 0; i < this._serverList.length; i++) { + serverLists[i % CONCURRENCY].push(this._serverList[i]); + } + var completed = 0; + var bestServer = null; + for (var i = 0; i < CONCURRENCY; i++) { + select( + serverLists[i], + function(server) { + if (server != null) { + if (bestServer == null || server.pingT < bestServer.pingT) + bestServer = server; + } + completed++; + if (completed == CONCURRENCY) { + this._selectedServer = bestServer; + this._state = 2; + if (result) result(bestServer); + } + }.bind(this) + ); + } + }, + /** + * Starts the test. + * During the test, the onupdate(data) callback function will be called periodically with data from the worker. + * At the end of the test, the onend(aborted) function will be called with a boolean telling you if the test was aborted or if it ended normally. + */ + start: function() { + if (this._state == 3) throw "Test already running"; + this.worker = new Worker("dist/speedtest/speedtest_worker.js?r=" + Math.random()); + this.worker.onmessage = function(e) { + if (e.data === this._prevData) return; + else this._prevData = e.data; + var data = JSON.parse(e.data); + try { + if (this.onupdate) this.onupdate(data); + } catch (e) { + console.error("Speedtest onupdate event threw exception: " + e); + } + if (data.testState >= 4) { + clearInterval(this.updater); + this._state = 4; + try { + if (this.onend) this.onend(data.testState == 5); + } catch (e) { + console.error("Speedtest onend event threw exception: " + e); + } + } + }.bind(this); + this.updater = setInterval( + function() { + this.worker.postMessage("status"); + }.bind(this), + 200 + ); + + if (this._state == 1) + throw "When using multiple points of test, you must call selectServer before starting the test"; + if (this._state == 2) { + this._settings.url_dl = + this._selectedServer.server + this._selectedServer.dlURL; + this._settings.url_ul = + this._selectedServer.server + this._selectedServer.ulURL; + this._settings.url_ping = + this._selectedServer.server + this._selectedServer.pingURL; + this._settings.url_getIp = + this._selectedServer.server + this._selectedServer.getIpURL; + if (typeof this._originalExtra !== "undefined") { + this._settings.telemetry_extra = JSON.stringify({ + server: this._selectedServer.name, + extra: this._originalExtra + }); + } else + this._settings.telemetry_extra = JSON.stringify({ + server: this._selectedServer.name + }); + } + this._state = 3; + this.worker.postMessage("start " + JSON.stringify(this._settings)); + }, + /** + * Aborts the test while it's running. + */ + abort: function() { + if (this._state < 3) throw "You cannot abort a test that's not started yet"; + if (this._state < 4) this.worker.postMessage("abort"); + } +}; + diff --git a/dist/speedtest/speedtest_worker.js b/dist/speedtest/speedtest_worker.js new file mode 100755 index 00000000..a575ba96 --- /dev/null +++ b/dist/speedtest/speedtest_worker.js @@ -0,0 +1,725 @@ +/* + LibreSpeed - Worker + by Federico Dossena + https://github.com/librespeed/speedtest/ + GNU LGPLv3 License + */ + +// data reported to main thread +var testState = -1; // -1=not started, 0=starting, 1=download test, 2=ping+jitter test, 3=upload test, 4=finished, 5=abort +var dlStatus = ""; // download speed in megabit/s with 2 decimal digits +var ulStatus = ""; // upload speed in megabit/s with 2 decimal digits +var pingStatus = ""; // ping in milliseconds with 2 decimal digits +var jitterStatus = ""; // jitter in milliseconds with 2 decimal digits +var clientIp = ""; // client's IP address as reported by getIP.php +var dlProgress = 0; //progress of download test 0-1 +var ulProgress = 0; //progress of upload test 0-1 +var pingProgress = 0; //progress of ping+jitter test 0-1 +var testId = null; //test ID (sent back by telemetry if used, null otherwise) + +var log = ""; //telemetry log +function tlog(s) { + if (settings.telemetry_level >= 2) { + log += Date.now() + ": " + s + "\n"; + } +} +function tverb(s) { + if (settings.telemetry_level >= 3) { + log += Date.now() + ": " + s + "\n"; + } +} +function twarn(s) { + if (settings.telemetry_level >= 2) { + log += Date.now() + " WARN: " + s + "\n"; + } + console.warn(s); +} + +// test settings. can be overridden by sending specific values with the start command +var settings = { + mpot: true, //set to true when in MPOT mode + test_order: "IP_D_U", //order in which tests will be performed as a string. D=Download, U=Upload, P=Ping+Jitter, I=IP, _=1 second delay + time_ul_max: 15, // max duration of upload test in seconds + time_dl_max: 15, // max duration of download test in seconds + time_auto: true, // if set to true, tests will take less time on faster connections + time_ulGraceTime: 3, //time to wait in seconds before actually measuring ul speed (wait for buffers to fill) + time_dlGraceTime: 1.5, //time to wait in seconds before actually measuring dl speed (wait for TCP window to increase) + count_ping: 10, // number of pings to perform in ping test + url_dl: "backend/garbage.php", // path to a large file or garbage.php, used for download test. must be relative to this js file + url_ul: "backend/empty.php", // path to an empty file, used for upload test. must be relative to this js file + url_ping: "backend/empty.php", // path to an empty file, used for ping test. must be relative to this js file + url_getIp: "backend/getIP.php", // path to getIP.php relative to this js file, or a similar thing that outputs the client's ip + getIp_ispInfo: true, //if set to true, the server will include ISP info with the IP address + getIp_ispInfo_distance: "km", //km or mi=estimate distance from server in km/mi; set to false to disable distance estimation. getIp_ispInfo must be enabled in order for this to work + xhr_dlMultistream: 6, // number of download streams to use (can be different if enable_quirks is active) + xhr_ulMultistream: 3, // number of upload streams to use (can be different if enable_quirks is active) + xhr_multistreamDelay: 300, //how much concurrent requests should be delayed + xhr_ignoreErrors: 1, // 0=fail on errors, 1=attempt to restart a stream if it fails, 2=ignore all errors + xhr_dlUseBlob: false, // if set to true, it reduces ram usage but uses the hard drive (useful with large garbagePhp_chunkSize and/or high xhr_dlMultistream) + xhr_ul_blob_megabytes: 20, //size in megabytes of the upload blobs sent in the upload test (forced to 4 on chrome mobile) + garbagePhp_chunkSize: 100, // size of chunks sent by garbage.php (can be different if enable_quirks is active) + enable_quirks: true, // enable quirks for specific browsers. currently it overrides settings to optimize for specific browsers, unless they are already being overridden with the start command + ping_allowPerformanceApi: true, // if enabled, the ping test will attempt to calculate the ping more precisely using the Performance API. Currently works perfectly in Chrome, badly in Edge, and not at all in Firefox. If Performance API is not supported or the result is obviously wrong, a fallback is provided. + overheadCompensationFactor: 1.06, //can be changed to compensatie for transport overhead. (see doc.md for some other values) + useMebibits: false, //if set to true, speed will be reported in mebibits/s instead of megabits/s + telemetry_level: 1, // 0=disabled, 1=basic (results only), 2=full (results and timing) 3=debug (results+log) + url_telemetry: "https://speedtest.raspap.com/results/telemetry.php", // path to the script that adds telemetry data to the database + telemetry_extra: "", //extra data that can be passed to the telemetry through the settings + forceIE11Workaround: false //when set to true, it will foce the IE11 upload test on all browsers. Debug only +}; + +var xhr = null; // array of currently active xhr requests +var interval = null; // timer used in tests +var test_pointer = 0; //pointer to the next test to run inside settings.test_order + +/* + this function is used on URLs passed in the settings to determine whether we need a ? or an & as a separator + */ +function url_sep(url) { + return url.match(/\?/) ? "&" : "?"; +} + +/* + listener for commands from main thread to this worker. + commands: + -status: returns the current status as a JSON string containing testState, dlStatus, ulStatus, pingStatus, clientIp, jitterStatus, dlProgress, ulProgress, pingProgress + -abort: aborts the current test + -start: starts the test. optionally, settings can be passed as JSON. + example: start {"time_ul_max":"10", "time_dl_max":"10", "count_ping":"50"} + */ +this.addEventListener("message", function(e) { + var params = e.data.split(" "); + if (params[0] === "status") { + // return status + postMessage( + JSON.stringify({ + testState: testState, + dlStatus: dlStatus, + ulStatus: ulStatus, + pingStatus: pingStatus, + clientIp: clientIp, + jitterStatus: jitterStatus, + dlProgress: dlProgress, + ulProgress: ulProgress, + pingProgress: pingProgress, + testId: testId + }) + ); + } + if (params[0] === "start" && testState === -1) { + // start new test + testState = 0; + try { + // parse settings, if present + var s = {}; + try { + var ss = e.data.substring(5); + if (ss) s = JSON.parse(ss); + } catch (e) { + twarn("Error parsing custom settings JSON. Please check your syntax"); + } + //copy custom settings + for (var key in s) { + if (typeof settings[key] !== "undefined") settings[key] = s[key]; + else twarn("Unknown setting ignored: " + key); + } + var ua = navigator.userAgent; + // quirks for specific browsers. apply only if not overridden. more may be added in future releases + if (settings.enable_quirks || (typeof s.enable_quirks !== "undefined" && s.enable_quirks)) { + if (/Firefox.(\d+\.\d+)/i.test(ua)) { + if (typeof s.ping_allowPerformanceApi === "undefined") { + // ff performance API sucks + settings.ping_allowPerformanceApi = false; + } + } + if (/Edge.(\d+\.\d+)/i.test(ua)) { + if (typeof s.xhr_dlMultistream === "undefined") { + // edge more precise with 3 download streams + settings.xhr_dlMultistream = 3; + } + } + if (/Chrome.(\d+)/i.test(ua) && !!self.fetch) { + if (typeof s.xhr_dlMultistream === "undefined") { + // chrome more precise with 5 streams + settings.xhr_dlMultistream = 5; + } + } + } + if (/Edge.(\d+\.\d+)/i.test(ua)) { + //Edge 15 introduced a bug that causes onprogress events to not get fired, we have to use the "small chunks" workaround that reduces accuracy + settings.forceIE11Workaround = true; + } + if (/PlayStation 4.(\d+\.\d+)/i.test(ua)) { + //PS4 browser has the same bug as IE11/Edge + settings.forceIE11Workaround = true; + } + if (/Chrome.(\d+)/i.test(ua) && /Android|iPhone|iPad|iPod|Windows Phone/i.test(ua)) { + //cheap af + //Chrome mobile introduced a limitation somewhere around version 65, we have to limit XHR upload size to 4 megabytes + settings.xhr_ul_blob_megabytes = 4; + } + if (/^((?!chrome|android|crios|fxios).)*safari/i.test(ua)) { + //Safari also needs the IE11 workaround but only for the MPOT version + settings.forceIE11Workaround = true; + } + //telemetry_level has to be parsed and not just copied + if (typeof s.telemetry_level !== "undefined") settings.telemetry_level = s.telemetry_level === "basic" ? 1 : s.telemetry_level === "full" ? 2 : s.telemetry_level === "debug" ? 3 : 0; // telemetry level + //transform test_order to uppercase, just in case + settings.test_order = settings.test_order.toUpperCase(); + } catch (e) { + twarn("Possible error in custom test settings. Some settings might not have been applied. Exception: " + e); + } + // run the tests + tverb(JSON.stringify(settings)); + test_pointer = 0; + var iRun = false, + dRun = false, + uRun = false, + pRun = false; + var runNextTest = function() { + if (testState == 5) return; + if (test_pointer >= settings.test_order.length) { + //test is finished + if (settings.telemetry_level > 0) + sendTelemetry(function(id) { + testState = 4; + if (id != null) testId = id; + }); + else testState = 4; + return; + } + switch (settings.test_order.charAt(test_pointer)) { + case "I": + { + test_pointer++; + if (iRun) { + runNextTest(); + return; + } else iRun = true; + getIp(runNextTest); + } + break; + case "D": + { + test_pointer++; + if (dRun) { + runNextTest(); + return; + } else dRun = true; + testState = 1; + dlTest(runNextTest); + } + break; + case "U": + { + test_pointer++; + if (uRun) { + runNextTest(); + return; + } else uRun = true; + testState = 3; + ulTest(runNextTest); + } + break; + case "P": + { + test_pointer++; + if (pRun) { + runNextTest(); + return; + } else pRun = true; + testState = 2; + pingTest(runNextTest); + } + break; + case "_": + { + test_pointer++; + setTimeout(runNextTest, 1000); + } + break; + default: + test_pointer++; + } + }; + runNextTest(); + } + if (params[0] === "abort") { + // abort command + if (testState >= 4) return; + tlog("manually aborted"); + clearRequests(); // stop all xhr activity + runNextTest = null; + if (interval) clearInterval(interval); // clear timer if present + if (settings.telemetry_level > 1) sendTelemetry(function() {}); + testState = 5; //set test as aborted + dlStatus = ""; + ulStatus = ""; + pingStatus = ""; + jitterStatus = ""; + clientIp = ""; + dlProgress = 0; + ulProgress = 0; + pingProgress = 0; + } +}); +// stops all XHR activity, aggressively +function clearRequests() { + tverb("stopping pending XHRs"); + if (xhr) { + for (var i = 0; i < xhr.length; i++) { + try { + xhr[i].onprogress = null; + xhr[i].onload = null; + xhr[i].onerror = null; + } catch (e) {} + try { + xhr[i].upload.onprogress = null; + xhr[i].upload.onload = null; + xhr[i].upload.onerror = null; + } catch (e) {} + try { + xhr[i].abort(); + } catch (e) {} + try { + delete xhr[i]; + } catch (e) {} + } + xhr = null; + } +} +// gets client's IP using url_getIp, then calls the done function +var ipCalled = false; // used to prevent multiple accidental calls to getIp +var ispInfo = ""; //used for telemetry +function getIp(done) { + tverb("getIp"); + if (ipCalled) return; + else ipCalled = true; // getIp already called? + var startT = new Date().getTime(); + xhr = new XMLHttpRequest(); + xhr.onload = function() { + tlog("IP: " + xhr.responseText + ", took " + (new Date().getTime() - startT) + "ms"); + try { + var data = JSON.parse(xhr.responseText); + clientIp = data.processedString; + ispInfo = data.rawIspInfo; + } catch (e) { + clientIp = xhr.responseText; + ispInfo = ""; + } + done(); + }; + xhr.onerror = function() { + tlog("getIp failed, took " + (new Date().getTime() - startT) + "ms"); + done(); + }; + xhr.open("GET", settings.url_getIp + url_sep(settings.url_getIp) + (settings.mpot ? "cors=true&" : "") + (settings.getIp_ispInfo ? "isp=true" + (settings.getIp_ispInfo_distance ? "&distance=" + settings.getIp_ispInfo_distance + "&" : "&") : "&") + "r=" + Math.random(), true); + xhr.send(); +} +// download test, calls done function when it's over +var dlCalled = false; // used to prevent multiple accidental calls to dlTest +function dlTest(done) { + tverb("dlTest"); + if (dlCalled) return; + else dlCalled = true; // dlTest already called? + var totLoaded = 0.0, // total number of loaded bytes + startT = new Date().getTime(), // timestamp when test was started + bonusT = 0, //how many milliseconds the test has been shortened by (higher on faster connections) + graceTimeDone = false, //set to true after the grace time is past + failed = false; // set to true if a stream fails + xhr = []; + // function to create a download stream. streams are slightly delayed so that they will not end at the same time + var testStream = function(i, delay) { + setTimeout( + function() { + if (testState !== 1) return; // delayed stream ended up starting after the end of the download test + tverb("dl test stream started " + i + " " + delay); + var prevLoaded = 0; // number of bytes loaded last time onprogress was called + var x = new XMLHttpRequest(); + xhr[i] = x; + xhr[i].onprogress = function(event) { + tverb("dl stream progress event " + i + " " + event.loaded); + if (testState !== 1) { + try { + x.abort(); + } catch (e) {} + } // just in case this XHR is still running after the download test + // progress event, add number of new loaded bytes to totLoaded + var loadDiff = event.loaded <= 0 ? 0 : event.loaded - prevLoaded; + if (isNaN(loadDiff) || !isFinite(loadDiff) || loadDiff < 0) return; // just in case + totLoaded += loadDiff; + prevLoaded = event.loaded; + }.bind(this); + xhr[i].onload = function() { + // the large file has been loaded entirely, start again + tverb("dl stream finished " + i); + try { + xhr[i].abort(); + } catch (e) {} // reset the stream data to empty ram + testStream(i, 0); + }.bind(this); + xhr[i].onerror = function() { + // error + tverb("dl stream failed " + i); + if (settings.xhr_ignoreErrors === 0) failed = true; //abort + try { + xhr[i].abort(); + } catch (e) {} + delete xhr[i]; + if (settings.xhr_ignoreErrors === 1) testStream(i, 0); //restart stream + }.bind(this); + // send xhr + try { + if (settings.xhr_dlUseBlob) xhr[i].responseType = "blob"; + else xhr[i].responseType = "arraybuffer"; + } catch (e) {} + xhr[i].open("GET", settings.url_dl + url_sep(settings.url_dl) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random() + "&ckSize=" + settings.garbagePhp_chunkSize, true); // random string to prevent caching + xhr[i].send(); + }.bind(this), + 1 + delay + ); + }.bind(this); + // open streams + for (var i = 0; i < settings.xhr_dlMultistream; i++) { + testStream(i, settings.xhr_multistreamDelay * i); + } + // every 200ms, update dlStatus + interval = setInterval( + function() { + tverb("DL: " + dlStatus + (graceTimeDone ? "" : " (in grace time)")); + var t = new Date().getTime() - startT; + if (graceTimeDone) dlProgress = (t + bonusT) / (settings.time_dl_max * 1000); + if (t < 200) return; + if (!graceTimeDone) { + if (t > 1000 * settings.time_dlGraceTime) { + if (totLoaded > 0) { + // if the connection is so slow that we didn't get a single chunk yet, do not reset + startT = new Date().getTime(); + bonusT = 0; + totLoaded = 0.0; + } + graceTimeDone = true; + } + } else { + var speed = totLoaded / (t / 1000.0); + if (settings.time_auto) { + //decide how much to shorten the test. Every 200ms, the test is shortened by the bonusT calculated here + var bonus = (5.0 * speed) / 100000; + bonusT += bonus > 400 ? 400 : bonus; + } + //update status + dlStatus = ((speed * 8 * settings.overheadCompensationFactor) / (settings.useMebibits ? 1048576 : 1000000)).toFixed(2); // speed is multiplied by 8 to go from bytes to bits, overhead compensation is applied, then everything is divided by 1048576 or 1000000 to go to megabits/mebibits + if ((t + bonusT) / 1000.0 > settings.time_dl_max || failed) { + // test is over, stop streams and timer + if (failed || isNaN(dlStatus)) dlStatus = "Fail"; + clearRequests(); + clearInterval(interval); + dlProgress = 1; + tlog("dlTest: " + dlStatus + ", took " + (new Date().getTime() - startT) + "ms"); + done(); + } + } + }.bind(this), + 200 + ); +} +// upload test, calls done function whent it's over +var ulCalled = false; // used to prevent multiple accidental calls to ulTest +function ulTest(done) { + tverb("ulTest"); + if (ulCalled) return; + else ulCalled = true; // ulTest already called? + // garbage data for upload test + var r = new ArrayBuffer(1048576); + var maxInt = Math.pow(2, 32) - 1; + try { + r = new Uint32Array(r); + for (var i = 0; i < r.length; i++) r[i] = Math.random() * maxInt; + } catch (e) {} + var req = []; + var reqsmall = []; + for (var i = 0; i < settings.xhr_ul_blob_megabytes; i++) req.push(r); + req = new Blob(req); + r = new ArrayBuffer(262144); + try { + r = new Uint32Array(r); + for (var i = 0; i < r.length; i++) r[i] = Math.random() * maxInt; + } catch (e) {} + reqsmall.push(r); + reqsmall = new Blob(reqsmall); + var testFunction = function() { + var totLoaded = 0.0, // total number of transmitted bytes + startT = new Date().getTime(), // timestamp when test was started + bonusT = 0, //how many milliseconds the test has been shortened by (higher on faster connections) + graceTimeDone = false, //set to true after the grace time is past + failed = false; // set to true if a stream fails + xhr = []; + // function to create an upload stream. streams are slightly delayed so that they will not end at the same time + var testStream = function(i, delay) { + setTimeout( + function() { + if (testState !== 3) return; // delayed stream ended up starting after the end of the upload test + tverb("ul test stream started " + i + " " + delay); + var prevLoaded = 0; // number of bytes transmitted last time onprogress was called + var x = new XMLHttpRequest(); + xhr[i] = x; + var ie11workaround; + if (settings.forceIE11Workaround) ie11workaround = true; + else { + try { + xhr[i].upload.onprogress; + ie11workaround = false; + } catch (e) { + ie11workaround = true; + } + } + if (ie11workaround) { + // IE11 workarond: xhr.upload does not work properly, therefore we send a bunch of small 256k requests and use the onload event as progress. This is not precise, especially on fast connections + xhr[i].onload = xhr[i].onerror = function() { + tverb("ul stream progress event (ie11wa)"); + totLoaded += reqsmall.size; + testStream(i, 0); + }; + xhr[i].open("POST", settings.url_ul + url_sep(settings.url_ul) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true); // random string to prevent caching + try { + xhr[i].setRequestHeader("Content-Encoding", "identity"); // disable compression (some browsers may refuse it, but data is incompressible anyway) + } catch (e) {} + //No Content-Type header in MPOT branch because it triggers bugs in some browsers + xhr[i].send(reqsmall); + } else { + // REGULAR version, no workaround + xhr[i].upload.onprogress = function(event) { + tverb("ul stream progress event " + i + " " + event.loaded); + if (testState !== 3) { + try { + x.abort(); + } catch (e) {} + } // just in case this XHR is still running after the upload test + // progress event, add number of new loaded bytes to totLoaded + var loadDiff = event.loaded <= 0 ? 0 : event.loaded - prevLoaded; + if (isNaN(loadDiff) || !isFinite(loadDiff) || loadDiff < 0) return; // just in case + totLoaded += loadDiff; + prevLoaded = event.loaded; + }.bind(this); + xhr[i].upload.onload = function() { + // this stream sent all the garbage data, start again + tverb("ul stream finished " + i); + testStream(i, 0); + }.bind(this); + xhr[i].upload.onerror = function() { + tverb("ul stream failed " + i); + if (settings.xhr_ignoreErrors === 0) failed = true; //abort + try { + xhr[i].abort(); + } catch (e) {} + delete xhr[i]; + if (settings.xhr_ignoreErrors === 1) testStream(i, 0); //restart stream + }.bind(this); + // send xhr + xhr[i].open("POST", settings.url_ul + url_sep(settings.url_ul) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true); // random string to prevent caching + try { + xhr[i].setRequestHeader("Content-Encoding", "identity"); // disable compression (some browsers may refuse it, but data is incompressible anyway) + } catch (e) {} + //No Content-Type header in MPOT branch because it triggers bugs in some browsers + xhr[i].send(req); + } + }.bind(this), + delay + ); + }.bind(this); + // open streams + for (var i = 0; i < settings.xhr_ulMultistream; i++) { + testStream(i, settings.xhr_multistreamDelay * i); + } + // every 200ms, update ulStatus + interval = setInterval( + function() { + tverb("UL: " + ulStatus + (graceTimeDone ? "" : " (in grace time)")); + var t = new Date().getTime() - startT; + if (graceTimeDone) ulProgress = (t + bonusT) / (settings.time_ul_max * 1000); + if (t < 200) return; + if (!graceTimeDone) { + if (t > 1000 * settings.time_ulGraceTime) { + if (totLoaded > 0) { + // if the connection is so slow that we didn't get a single chunk yet, do not reset + startT = new Date().getTime(); + bonusT = 0; + totLoaded = 0.0; + } + graceTimeDone = true; + } + } else { + var speed = totLoaded / (t / 1000.0); + if (settings.time_auto) { + //decide how much to shorten the test. Every 200ms, the test is shortened by the bonusT calculated here + var bonus = (5.0 * speed) / 100000; + bonusT += bonus > 400 ? 400 : bonus; + } + //update status + ulStatus = ((speed * 8 * settings.overheadCompensationFactor) / (settings.useMebibits ? 1048576 : 1000000)).toFixed(2); // speed is multiplied by 8 to go from bytes to bits, overhead compensation is applied, then everything is divided by 1048576 or 1000000 to go to megabits/mebibits + if ((t + bonusT) / 1000.0 > settings.time_ul_max || failed) { + // test is over, stop streams and timer + if (failed || isNaN(ulStatus)) ulStatus = "Fail"; + clearRequests(); + clearInterval(interval); + ulProgress = 1; + tlog("ulTest: " + ulStatus + ", took " + (new Date().getTime() - startT) + "ms"); + done(); + } + } + }.bind(this), + 200 + ); + }.bind(this); + if (settings.mpot) { + tverb("Sending POST request before performing upload test"); + xhr = []; + xhr[0] = new XMLHttpRequest(); + xhr[0].onload = xhr[0].onerror = function() { + tverb("POST request sent, starting upload test"); + testFunction(); + }.bind(this); + xhr[0].open("POST", settings.url_ul) + (settings.mpot ? "cors=true&" : ""); + xhr[0].send(); + } else testFunction(); +} +// ping+jitter test, function done is called when it's over +var ptCalled = false; // used to prevent multiple accidental calls to pingTest +function pingTest(done) { + tverb("pingTest"); + if (ptCalled) return; + else ptCalled = true; // pingTest already called? + var startT = new Date().getTime(); //when the test was started + var prevT = null; // last time a pong was received + var ping = 0.0; // current ping value + var jitter = 0.0; // current jitter value + var i = 0; // counter of pongs received + var prevInstspd = 0; // last ping time, used for jitter calculation + xhr = []; + // ping function + var doPing = function() { + tverb("ping"); + pingProgress = i / settings.count_ping; + prevT = new Date().getTime(); + xhr[0] = new XMLHttpRequest(); + xhr[0].onload = function() { + // pong + tverb("pong"); + if (i === 0) { + prevT = new Date().getTime(); // first pong + } else { + var instspd = new Date().getTime() - prevT; + if (settings.ping_allowPerformanceApi) { + try { + //try to get accurate performance timing using performance api + var p = performance.getEntries(); + p = p[p.length - 1]; + var d = p.responseStart - p.requestStart; + if (d <= 0) d = p.duration; + if (d > 0 && d < instspd) instspd = d; + } catch (e) { + //if not possible, keep the estimate + tverb("Performance API not supported, using estimate"); + } + } + //noticed that some browsers randomly have 0ms ping + if (instspd < 1) instspd = prevInstspd; + if (instspd < 1) instspd = 1; + var instjitter = Math.abs(instspd - prevInstspd); + if (i === 1) ping = instspd; + /* first ping, can't tell jitter yet*/ else { + if (instspd < ping) ping = instspd; // update ping, if the instant ping is lower + if (i === 2) jitter = instjitter; + //discard the first jitter measurement because it might be much higher than it should be + else jitter = instjitter > jitter ? jitter * 0.3 + instjitter * 0.7 : jitter * 0.8 + instjitter * 0.2; // update jitter, weighted average. spikes in ping values are given more weight. + } + prevInstspd = instspd; + } + pingStatus = ping.toFixed(2); + jitterStatus = jitter.toFixed(2); + i++; + tverb("ping: " + pingStatus + " jitter: " + jitterStatus); + if (i < settings.count_ping) doPing(); + else { + // more pings to do? + pingProgress = 1; + tlog("ping: " + pingStatus + " jitter: " + jitterStatus + ", took " + (new Date().getTime() - startT) + "ms"); + done(); + } + }.bind(this); + xhr[0].onerror = function() { + // a ping failed, cancel test + tverb("ping failed"); + if (settings.xhr_ignoreErrors === 0) { + //abort + pingStatus = "Fail"; + jitterStatus = "Fail"; + clearRequests(); + tlog("ping test failed, took " + (new Date().getTime() - startT) + "ms"); + pingProgress = 1; + done(); + } + if (settings.xhr_ignoreErrors === 1) doPing(); //retry ping + if (settings.xhr_ignoreErrors === 2) { + //ignore failed ping + i++; + if (i < settings.count_ping) doPing(); + else { + // more pings to do? + pingProgress = 1; + tlog("ping: " + pingStatus + " jitter: " + jitterStatus + ", took " + (new Date().getTime() - startT) + "ms"); + done(); + } + } + }.bind(this); + // send xhr + xhr[0].open("GET", settings.url_ping + url_sep(settings.url_ping) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true); // random string to prevent caching + xhr[0].send(); + }.bind(this); + doPing(); // start first ping +} +// telemetry +function sendTelemetry(done) { + if (settings.telemetry_level < 1) return; + xhr = new XMLHttpRequest(); + xhr.onload = function() { + try { + var parts = xhr.responseText.split(" "); + if (parts[0] == "id") { + try { + var id = parts[1]; + done(id); + } catch (e) { + done(null); + } + } else done(null); + } catch (e) { + done(null); + } + }; + xhr.onerror = function() { + console.log("TELEMETRY ERROR " + xhr.status); + done(null); + }; + xhr.open("POST", settings.url_telemetry + url_sep(settings.url_telemetry) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true); + var telemetryIspInfo = { + processedString: clientIp, + rawIspInfo: typeof ispInfo === "object" ? ispInfo : "" + }; + try { + var fd = new FormData(); + fd.append("ispinfo", JSON.stringify(telemetryIspInfo)); + fd.append("dl", dlStatus); + fd.append("ul", ulStatus); + fd.append("ping", pingStatus); + fd.append("jitter", jitterStatus); + fd.append("log", settings.telemetry_level > 1 ? log : ""); + fd.append("extra", settings.telemetry_extra); + xhr.send(fd); + } catch (ex) { + var postData = "extra=" + encodeURIComponent(settings.telemetry_extra) + "&ispinfo=" + encodeURIComponent(JSON.stringify(telemetryIspInfo)) + "&dl=" + encodeURIComponent(dlStatus) + "&ul=" + encodeURIComponent(ulStatus) + "&ping=" + encodeURIComponent(pingStatus) + "&jitter=" + encodeURIComponent(jitterStatus) + "&log=" + encodeURIComponent(settings.telemetry_level > 1 ? log : ""); + xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + xhr.send(postData); + } +} + From b5e79b9148d15dfc632d80f2d4bde365ae73f7c1 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 02:18:56 -0700 Subject: [PATCH 02/14] Enable wg kill switch --- includes/wireguard.php | 99 ++++++++++++++++++++++++++++++---------- templates/wg/general.php | 11 ++++- 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/includes/wireguard.php b/includes/wireguard.php index 233dd33b..ea2b1fe9 100755 --- a/includes/wireguard.php +++ b/includes/wireguard.php @@ -1,28 +1,33 @@ addMessage('Attempting to start WireGuard', 'info'); + exec('sudo /bin/systemctl enable wg-quick@wg0', $return); exec('sudo /bin/systemctl start wg-quick@wg0', $return); foreach ($return as $line) { $status->addMessage($line, 'info'); @@ -30,6 +35,7 @@ function DisplayWireGuardConfig() } elseif (isset($_POST['stopwg'])) { $status->addMessage('Attempting to stop WireGuard', 'info'); exec('sudo /bin/systemctl stop wg-quick@wg0', $return); + exec('sudo /bin/systemctl disable wg-quick@wg0', $return); foreach ($return as $line) { $status->addMessage($line, 'info'); } @@ -70,11 +76,18 @@ function DisplayWireGuardConfig() $wg_state = ($wgstatus[0] == 'active' ? true : false ); $public_ip = get_public_ip(); - // retrieve wg log - $wg_log = ""; + // fetch uploaded file configs + exec("sudo ls ".RASPI_WIREGUARD_PATH, $clist); + $configs = preg_grep('/^((?!wg0).)*\.conf/', $clist); + exec("sudo readlink ".RASPI_WIREGUARD_CONFIG." | xargs basename", $ret); + $conf_default = empty($ret) ? "none" : $ret[0]; + + // fetch wg log + exec('sudo chmod o+r /tmp/wireguard.log'); if (file_exists('/tmp/wireguard.log')) { - exec('sudo chmod o+r /tmp/wireguard.log'); - $wg_log = file_get_contents('/tmp/wireguard.log'); + $log = file_get_contents('/tmp/wireguard.log'); + } else { + $log = ''; } $peer_id = $peer_id ?? "1"; @@ -90,6 +103,7 @@ function DisplayWireGuardConfig() "public_ip", "interfaces", "optRules", + "optKSwitch", "optLogEnable", "peer_id", "wg_srvpubkey", @@ -104,7 +118,9 @@ function DisplayWireGuardConfig() "wg_pendpoint", "wg_pallowedips", "wg_pkeepalive", - "wg_log" + "configs", + "conf_default", + "log" ) ); } @@ -116,10 +132,11 @@ function DisplayWireGuardConfig() * @param object $status * @param object $file * @param boolean $optRules + * @param boolean $optKSwitch * @param string $optInterface * @return object $status */ -function SaveWireGuardUpload($status, $file, $optRules, $optInterface) +function SaveWireGuardUpload($status, $file, $optRules, $optKSwitch, $optInterface) { define('KB', 1024); $tmp_destdir = '/tmp/'; @@ -148,19 +165,56 @@ function SaveWireGuardUpload($status, $file, $optRules, $optInterface) $tmp_wgconfig = $results['full_path']; $tmp_contents = file_get_contents($tmp_wgconfig); - // Set iptables rules - if (isset($optRules) && !preg_match('/PostUp|PostDown/m',$tmp_contents)) { - $rules[] = 'PostUp = '.getDefaultNetValue('wireguard','server','PostUp'); - $rules[] = 'PostDown = '.getDefaultNetValue('wireguard','server','PostDown'); - $rules[] = ''; - $rules = join(PHP_EOL, $rules); - $rules = preg_replace('/wlan0/m', $optInterface, $rules); - $tmp_contents = preg_replace('/^\s*$/ms', $rules, $tmp_contents, 1); - file_put_contents($tmp_wgconfig, $tmp_contents); + // Check for existing iptables rules + if ((isset($optRules) || isset($optKSwitch)) && preg_match('/PostUp|PostDown|PreDown/m',$tmp_contents)) { + $status->addMessage('Existing iptables rules found in WireGuard configuration - not added', 'info'); + } else { + // Set rules from default config + if (isset($optRules)) { + $rules[] = 'PostUp = '.getDefaultNetValue('wireguard','server','PostUp'); + $rules[] = 'PostDown = '.getDefaultNetValue('wireguard','server','PostDown'); + $rules = preg_replace('/wlan0/m', $optInterface, $rules); + } + if (isset($optKSwitch)) { + // Get ap static ip_addr from system config, fallback to default if undefined + $jsonData = json_decode(getNetConfig($optInterface), true); + $ip_addr = ($jsonData['StaticIP'] == '') ? getDefaultNetValue('dhcp', $optInterface, 'static ip_address') : $jsonData['StaticIP']; + $mask = ($jsonData['SubnetMask'] == '') ? getDefaultNetValue('dhcp', $optInterface, 'subnetmask') : $jsonData['SubnetMask']; + + // if empty, try to detect IP/mask from system + if (empty($ip_addr) || empty($mask)) { + $ipDetails = shell_exec("ip -4 -o addr show dev " . escapeshellarg($optInterface)); + if (preg_match('/inet (\d+\.\d+\.\d+\.\d+)\/(\d+)/', $ipDetails, $matches)) { + $ip_addr = $matches[1]; + $cidr = $matches[2]; + } else { + $ip_addr = '0.0.0.0'; + $cidr = '24'; + } + } else { + $cidr = mask2cidr($mask); + } + $cidr_ip = strpos($ip_addr, '/') === false ? "$ip_addr/$cidr" : $ip_addr; + + $rules[] = 'PostUp = '.getDefaultNetValue('wireguard','server','PostUpEx'); + $rules[] = 'PreDown = '.getDefaultNetValue('wireguard','server','PreDown'); + $rules = preg_replace('/%s/m', $cidr_ip, $rules); + } + if ((isset($rules) && count($rules) > 0)) { + $rules[] = ''; + $rules = join(PHP_EOL, $rules); + $tmp_contents = preg_replace('/^\s*$/ms', $rules, $tmp_contents, 1); + file_put_contents($tmp_wgconfig, $tmp_contents); + $status->addMessage('iptables rules added to WireGuard configuration', 'info'); + } } - // Move processed file from tmp to destination - system("sudo mv $tmp_wgconfig ". RASPI_WIREGUARD_CONFIG, $return); + // Move processed file from /tmp and create symlink + $client_wg = RASPI_WIREGUARD_PATH.pathinfo($file['name'], PATHINFO_FILENAME).'.conf'; + chmod($tmp_wgconfig, 0644); + system("sudo mv $tmp_wgconfig $client_wg", $return); + system("sudo rm ".RASPI_WIREGUARD_CONFIG, $return); + system("sudo ln -s $client_wg ".RASPI_WIREGUARD_CONFIG, $return); if ($return ==0) { $status->addMessage('WireGuard configuration uploaded successfully', 'info'); @@ -225,7 +279,7 @@ function SaveWireGuardConfig($status) $wg_pendpoint_seg = substr($_POST['wg_pendpoint'],0,strpos($_POST['wg_pendpoint'],':')); $host_port = explode(':', $wg_pendpoint_seg); $hostname = $host_port[0]; - if (!filter_var($hostname, FILTER_VALIDATE_IP) && + if (!filter_var($hostname, FILTER_VALIDATE_IP) && !filter_var($hostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { $status->addMessage('Invalid value for endpoint address', 'danger'); $good_input = false; @@ -295,11 +349,10 @@ function SaveWireGuardConfig($status) } $config[] = ''; $config = join(PHP_EOL, $config); - file_put_contents("/tmp/wgdata", $config); system('sudo cp /tmp/wgdata '.RASPI_WIREGUARD_PATH.'client.conf', $return); } else { - # remove selected conf + keys + # remove selected conf + keys system('sudo rm '. RASPI_WIREGUARD_PATH .'wg-peer-private.key', $return); system('sudo rm '. RASPI_WIREGUARD_PATH .'wg-peer-public.key', $return); system('sudo rm '. RASPI_WIREGUARD_PATH.'client.conf', $return); diff --git a/templates/wg/general.php b/templates/wg/general.php index 9b8b8246..e7e0399a 100644 --- a/templates/wg/general.php +++ b/templates/wg/general.php @@ -39,9 +39,18 @@ ">

iptables Postup and PostDown rules for the interface selected below."); ?> -

+

+
+
+
+ + /> + + "> +

+ iptables PostUp and PreDown rules for the configured interface."); ?>

From 698b8bf80976409cd32bf1d3b9da124df04e64f6 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 02:19:50 -0700 Subject: [PATCH 03/14] Update w/ versioned Librespeed CSS + JS --- index.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/index.php b/index.php index 5958cf8e..3ca02507 100755 --- a/index.php +++ b/index.php @@ -79,6 +79,9 @@ initializeApp(); + + + @@ -141,6 +144,9 @@ initializeApp(); + + + From 51d528fd42b6218e75b7daf2808e713ab588daa2 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 02:20:22 -0700 Subject: [PATCH 04/14] Add $extraFooterScripts param to DisplayNetworkingConfig() --- includes/page_actions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/page_actions.php b/includes/page_actions.php index c9649792..4d218850 100755 --- a/includes/page_actions.php +++ b/includes/page_actions.php @@ -32,7 +32,7 @@ function handleCorePageAction(string $page, array &$extraFooterScripts): void DisplayWPAConfig(); break; case "/network_conf": - DisplayNetworkingConfig(); + DisplayNetworkingConfig($extraFooterScripts); break; case "/hostapd_conf": DisplayHostAPDConfig(); From 5c979424f372c4aa4d9d8b6f6f6e9bb7e78b1698 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 02:22:42 -0700 Subject: [PATCH 05/14] Initial commit (source: raspap-insiders) --- app/lib/signprint.php | 78 +++++++++++++++++ src/RaspAP/Networking/DeviceScanner.php | 109 ++++++++++++++++++++++++ templates/networking/diagnostics.php | 95 +++++++++++++++++++++ templates/networking/general.php | 92 ++++++++++++++++++++ 4 files changed, 374 insertions(+) create mode 100644 app/lib/signprint.php create mode 100644 src/RaspAP/Networking/DeviceScanner.php create mode 100755 templates/networking/diagnostics.php create mode 100755 templates/networking/general.php diff --git a/app/lib/signprint.php b/app/lib/signprint.php new file mode 100644 index 00000000..d5f326b9 --- /dev/null +++ b/app/lib/signprint.php @@ -0,0 +1,78 @@ + + + + + <?php echo _("Printable Wi-Fi sign"); ?> + + + + + + + + + + +
+
+
+
+
+
+

+
+
+
+ +
+
+
+ RaspAP Wifi QR code +
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +

+ +

+
+
+
+
+ +
+
+
+ + diff --git a/src/RaspAP/Networking/DeviceScanner.php b/src/RaspAP/Networking/DeviceScanner.php new file mode 100644 index 00000000..d3a812f3 --- /dev/null +++ b/src/RaspAP/Networking/DeviceScanner.php @@ -0,0 +1,109 @@ + + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE + */ + +namespace RaspAP\Networking; + +class DeviceScanner +{ + public function listDevices(): array + { + $devices = []; + + foreach (glob('/sys/class/net/*') as $ifacePath) { + $iface = basename($ifacePath); + if ($iface === 'lo') { + continue; // skip loopback + } + + $device = [ + 'name' => $iface, + 'mac' => $this->readFile("$ifacePath/address"), + 'ipaddress' => $this->getIPAddress($iface), + 'vendor' => '', + 'model' => '', + 'vid' => '', + 'pid' => '', + 'driver' => '', + 'type' => $this->getInterfaceType($iface), + 'isAP' => false, + 'connected' => 'y', // placeholder + 'signal' => '0 dB (100%)' // placeholder + ]; + + $udev = $this->getUdevAttributes($iface); + $device['vendor'] = $this->getVendorName($udev); + $device['model'] = $udev['ID_MODEL_FROM_DATABASE'] ?? $udev['ID_MODEL'] ?? ''; + $device['vid'] = $udev['ID_VENDOR_ID'] ?? ''; + $device['pid'] = $udev['ID_MODEL_ID'] ?? ''; + $device['driver'] = $udev['ID_NET_DRIVER'] ?? ''; + + $devices[] = $device; + } + + return $devices; + } + + private function readFile(string $path): string + { + return is_readable($path) ? trim(file_get_contents($path)) : ''; + } + + private function getIPAddress(string $iface): string + { + $cmd = "ip -4 -o addr show dev " . escapeshellarg($iface) . " | awk '{print $4}' | cut -d/ -f1"; + $result = []; + exec($cmd, $result); + return $result[0] ?? ''; + } + + private function getInterfaceType(string $iface): string + { + $wirelessPath = "/sys/class/net/{$iface}/wireless"; + if (is_dir($wirelessPath)) { + return 'wlan'; + } + + $typeFile = "/sys/class/net/{$iface}/type"; + $type = $this->readFile($typeFile); + + return match ($type) { + '1' => 'eth', // ARPHRD_ETHER + '772' => 'loopback', + '512' => 'ppp', + default => 'unknown' + }; + } + + private function getUdevAttributes(string $iface): array + { + $attributes = []; + $output = []; + $path = escapeshellarg("/sys/class/net/{$iface}"); + + exec("udevadm info {$path}", $output); + + foreach ($output as $line) { + if (preg_match('/E: (\w+)=([^\n]+)/', $line, $matches)) { + $attributes[$matches[1]] = $matches[2]; + } + } + + return $attributes; + } + + private function getVendorName(array $udev): ?string + { + return $udev['ID_VENDOR_FROM_DATABASE'] + ?? $udev['ID_VENDOR'] + ?? $udev['ID_OUI_FROM_DATABASE'] + ?? null; + } +} + diff --git a/templates/networking/diagnostics.php b/templates/networking/diagnostics.php new file mode 100755 index 00000000..011ae9c2 --- /dev/null +++ b/templates/networking/diagnostics.php @@ -0,0 +1,95 @@ +
+ +

+
+
+ +
+

...

+
+ + + + +
+
+
+ diff --git a/templates/networking/general.php b/templates/networking/general.php new file mode 100755 index 00000000..41b1d823 --- /dev/null +++ b/templates/networking/general.php @@ -0,0 +1,92 @@ +
+

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
No route to the internet found

+

Access point

+
+

"> + " > +

+

"> + " > +

+

+ " > +

+
+
+
+
+ +

+
+
+
+
+
+
+ + + + +
+ + + +
+
+
+
+
+
+ +

+
+ + + +
+
+
+
+

+              
+
+
+ + +
+ +
+ From 4f57e259dd6ac641292c772157c67fe8c7f4c1e6 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 02:23:48 -0700 Subject: [PATCH 06/14] Define RASPI_IPTABLES_CONF, ACCESS_CHECK_* --- config/config.php | 5 +++++ includes/defaults.php | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/config/config.php b/config/config.php index 178081b1..2dd0223c 100755 --- a/config/config.php +++ b/config/config.php @@ -32,6 +32,7 @@ define('RASPI_OPENVPN_CLIENT_CONFIG', '/etc/openvpn/client/client.conf'); define('RASPI_OPENVPN_CLIENT_LOGIN', '/etc/openvpn/client/login.conf'); define('RASPI_WIREGUARD_PATH', '/etc/wireguard/'); define('RASPI_WIREGUARD_CONFIG', RASPI_WIREGUARD_PATH.'wg0.conf'); +define('RASPI_IPTABLES_CONF', RASPI_CONFIG.'/networking/iptables_rules.json'); define('RASPI_TORPROXY_CONFIG', '/etc/tor/torrc'); define('RASPI_LIGHTTPD_CONFIG', '/etc/lighttpd/lighttpd.conf'); define('RASPI_ACCESS_CHECK_IP', '1.1.1.1'); @@ -40,6 +41,10 @@ define('RASPI_ACCESS_CHECK_DNS', 'one.one.one.one'); // Constant for the GitHub API latest release endpoint define('RASPI_API_ENDPOINT', 'https://api.github.com/repos/RaspAP/raspap-webgui/releases/latest'); +// Captive portal detection - returns 204 or 200 is successful +define('RASPI_ACCESS_CHECK_URL', 'http://detectportal.firefox.com'); +define('RASPI_ACCESS_CHECK_URL_CODE', 200); + // Constant for the 5GHz wireless regulatory domain define("RASPI_5GHZ_CHANNEL_MIN", 100); define("RASPI_5GHZ_CHANNEL_MAX", 192); diff --git a/includes/defaults.php b/includes/defaults.php index f658a51e..dc7683ec 100755 --- a/includes/defaults.php +++ b/includes/defaults.php @@ -37,11 +37,17 @@ $defaults = [ 'RASPI_OPENVPN_CLIENT_LOGIN' => '/etc/openvpn/client/login.conf', 'RASPI_WIREGUARD_PATH' => '/etc/wireguard/', 'RASPI_WIREGUARD_CONFIG' => RASPI_WIREGUARD_PATH.'wg0.conf', + 'RASPI_IPTABLES_CONF' => RASPI_CONFIG.'/networking/iptables_rules.json', + 'RASPI_TORPROXY_ENABLED' => false, 'RASPI_TORPROXY_CONFIG' => '/etc/tor/torrc', 'RASPI_LIGHTTPD_CONFIG' => '/etc/lighttpd/lighttpd.conf', 'RASPI_ACCESS_CHECK_IP' => '1.1.1.1', 'RASPI_ACCESS_CHECK_DNS' => 'one.one.one.one', + // Captive portal detection - returns 204 or 200 is successful + 'RASPI_ACCESS_CHECK_URL' => 'http://detectportal.firefox.com', + 'RASPI_ACCESS_CHECK_URL_CODE' => 200, + // Constants for the 5GHz wireless regulatory domain 'RASPI_5GHZ_CHANNEL_MIN' => 100, 'RASPI_5GHZ_CHANNEL_MAX' => 192, @@ -58,7 +64,6 @@ $defaults = [ 'RASPI_OPENVPN_ENABLED' => false, 'RASPI_VPN_PROVIDER_ENABLED' => false, 'RASPI_WIREGUARD_ENABLED' => false, - 'RASPI_TORPROXY_ENABLED' => false, 'RASPI_CONFAUTH_ENABLED' => true, 'RASPI_CHANGETHEME_ENABLED' => true, 'RASPI_VNSTAT_ENABLED' => true, From c21d5a1790ec42afbb50df02151cbdc19b01b7c0 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 02:24:49 -0700 Subject: [PATCH 07/14] Update w/ checkHTTPAccess() --- includes/internetRoute.php | 76 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/includes/internetRoute.php b/includes/internetRoute.php index 3c39ae22..e9aaf7ac 100755 --- a/includes/internetRoute.php +++ b/includes/internetRoute.php @@ -1,5 +1,4 @@ $route) { $prop = explode(' ', $route); - $rInfo[$i]["interface"] = $prop[0]; + $rInfo[$i]["interface"] = $dev = $prop[0]; $rInfo[$i]["ip-address"] = $prop[1]; $rInfo[$i]["gateway"] = $prop[2]; // resolve the name of the gateway (if possible) unset($host); exec('host ' . $prop[2] . ' | sed -rn "s/.*domain name pointer (.*)\./\1/p" | head -n 1', $host); $rInfo[$i]["gw-name"] = empty($host) ? "*" : $host[0]; - if (isset($checkAccess) && $checkAccess) { + // check if AP + unset($isAP); + exec("iwconfig $dev 2> /dev/null | sed -rn 's/.*(mode:master).*/1/ip'", $isAP); + $isAP = !empty($isAP); + $rInfo[$i]["isAP"] = $isAP; + if (isset($checkAccess) && $checkAccess && !$isAP) { // check internet connectivity w/ and w/o DNS resolution unset($okip); exec('ping -W1 -c 1 -I ' . $prop[0] . ' ' . RASPI_ACCESS_CHECK_IP . ' | sed -rn "s/.*icmp_seq=1.*time=.*/OK/p"', $okip); @@ -47,6 +51,7 @@ function getRouteInfo($checkAccess) unset($okdns); exec('ping -W1 -c 1 -I ' . $prop[0] . ' ' . RASPI_ACCESS_CHECK_DNS . ' | sed -rn "s/.*icmp_seq=1.*time=.*/OK/p"', $okdns); $rInfo[$i]["access-dns"] = empty($okdns) ? false : true; + $rInfo[$i]["access-url"] = preg_match('/OK.*/',checkHTTPAccess($prop[0])); } } } else { @@ -55,6 +60,70 @@ function getRouteInfo($checkAccess) return $rInfo; } +function detectCaptivePortal($iface) { + $result=checkHTTPAccess($iface, true); + $checkConnect=array( "state"=>"FAILED", "URL"=>"", "interface"=> $iface, "url" => "" ); + if ( !empty($result) && !preg_match('/FAILED/i',$result) ) { + $checkConnect["state"]=preg_match('/(PORTAL|OK)/i',$result); + if ( preg_match('/PORTAL (.*)/i',$result ,$url) && !empty($url) ) { + $checkConnect["URL"]=$url[1]; + } + } + return $checkConnect; +} + +function checkHTTPAccess($iface, $detectPortal=false) { + + $ret="FAILED no HTTP access"; + exec('timeout 5 curl -is ' . RASPI_ACCESS_CHECK_URL . ' --interface ' . $iface, $rcurl); + if ( !empty($rcurl) && preg_match("/^HTTP\/[0-9\.]+ ([0-9]+)/m",$rcurl=implode("\n",$rcurl),$code) ) { + $code = $code[1]; + if ( $code == 200 ) { + if ( preg_match("//", $rcurl, $url) ) { + $code = 302; + $rcurl = "Location: " . $url[1]; + unset($url); + } + } + switch($code) { + case 302: + case 307: + if ( $detectPortal ) { + if ( preg_match("/^Location:\s*(https?:\/\/[^?[:space:]]+)/m", $rcurl, $url) ) { + $url=$url[1]; + if ( preg_match('/^https?:\/\/([^:\/]*).*/i', $url, $srv) && isset($srv[1]) ) { + $srv=$srv[1]; + if ( preg_match('/^(([0-9]{1,3}\.){3}[0-9]{1,3}).*/', $srv, $ip) && isset($ip[1]) ) { + $ret="PORTAL " . $url; + } + else { + exec('timeout 7 sudo nmap --script=broadcast-dhcp-discover -e ' . $iface . ' 2> /dev/null | sed -rn "s/.*Domain Name Server:\s*(([0-9]{1,3}\.){3}[0-9]{1,3}).*/\1/pi"', $nameserver); + if ( !empty($nameserver) ) { + $nameserver=$nameserver[0]; + exec('host ' . $srv . ' ' . $nameserver . ' | sed -rn "s/.*has address ((([0-9]{1,3}\.){3}[0-9]{1,3})).*/\1/p"', $ip2); + if ( !empty($ip2) ) { + $ip2=$ip2[0]; + $url=preg_replace("/" . $srv . "/",$ip2,$url); + $ret="PORTAL " . $url; + } + else $ret="FAILED name " . $srv . " could not be resolved"; + } + else $ret="FAILED no name server"; + } + } + } + } + break; + case RASPI_ACCESS_CHECK_URL_CODE: + $ret="OK internet access"; + break; + default: + $ret="FAILED unexpected response " . $code[0]; + break; + } + } + return $ret; +} /* * Fetches raw output of ip route * @@ -65,4 +134,3 @@ function getRouteInfoRaw() exec('ip route list', $routes); return $routes; } - From 7a0b93a0e8488293a9303aade9cdb81e8d6fd16c Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 02:26:30 -0700 Subject: [PATCH 08/14] Update networking w/ speedtestUI.js, diagnostics tab --- includes/networking.php | 7 +-- templates/networking.php | 100 ++++++--------------------------------- 2 files changed, 18 insertions(+), 89 deletions(-) diff --git a/includes/networking.php b/includes/networking.php index 9669fca4..83c587ed 100755 --- a/includes/networking.php +++ b/includes/networking.php @@ -1,12 +1,12 @@ 'app/js/speedtestUI.js', 'defer'=>false); } diff --git a/templates/networking.php b/templates/networking.php index f1cd45c3..1767878d 100755 --- a/templates/networking.php +++ b/templates/networking.php @@ -1,7 +1,7 @@ +
-
@@ -9,96 +9,24 @@
-
+ showMessages(); ?> +
+
+ + +
-
-

-
-
-
- - - - - - - - - - - - - - - - - - - - - - - -
No route to the internet found

-

- "> -

-

- "> -

-
-
-
-
- -

-
-
-
-
-
-
- - - - -
- - - -
-
-
-
-
-
- -

-
- - - -
-
-
-
-

-                      
-
-
- - -
- -
-
+ + +
From 7fceaf536ccf798fd3d6e14f00648109b1a69473 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 02:27:01 -0700 Subject: [PATCH 09/14] Suppress call to loadCurrentSettings (deprecated) --- app/js/custom.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/js/custom.js b/app/js/custom.js index 3e8a7c58..85babd07 100644 --- a/app/js/custom.js +++ b/app/js/custom.js @@ -43,7 +43,7 @@ function setupTabs() { var target = $(e.target).attr('href'); if(!target.match('summary')) { var int = target.replace("#",""); - loadCurrentSettings(int); + // loadCurrentSettings(int); } }); } From 7994fa3c33f04f971498bd48a89ca8edaa84cf1e Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 02:28:36 -0700 Subject: [PATCH 10/14] Update security options w/ 802.11w, printable sign link --- templates/hostapd/security.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/templates/hostapd/security.php b/templates/hostapd/security.php index 1decef3d..cc7eb141 100644 --- a/templates/hostapd/security.php +++ b/templates/hostapd/security.php @@ -4,15 +4,21 @@
- +
- +
+ + + +
+ +
- +
@@ -22,7 +28,12 @@
RaspAP Wifi QR code -
+
+ ', + '', + ''); ?> +
From c0df273c3640e1a6d4467360ebe9c47f2a8d88ee Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 02:29:44 -0700 Subject: [PATCH 11/14] Refactor hostapd cfg parse/write (source: raspap-insiders) --- includes/hostapd.php | 324 ++++++++++++++++++++++++++++++------------- 1 file changed, 229 insertions(+), 95 deletions(-) diff --git a/includes/hostapd.php b/includes/hostapd.php index e4c7e69c..bf44a020 100755 --- a/includes/hostapd.php +++ b/includes/hostapd.php @@ -25,15 +25,18 @@ function DisplayHostAPDConfig() $languageCode = strtok($_SESSION['locale'], '_'); $countryCodes = getCountryCodes($languageCode); - $arrSecurity = array(1 => 'WPA', 2 => 'WPA2', 3 => 'WPA+WPA2', 'none' => _("None")); + $arrSecurity = array(1 => 'WPA', 2 => 'WPA2', 3 => _("WPA and WPA2")); + $arrSecurity += [4 => _("WPA2 and WPA3-Personal (transitional mode)")]; + $arrSecurity += [5 => 'WPA3-Personal (required)']; + $arrSecurity += ['none' => _("None")]; $arrEncType = array('TKIP' => 'TKIP', 'CCMP' => 'CCMP', 'TKIP CCMP' => 'TKIP+CCMP'); + $arr80211w = array(3 => _("Disabled"), 1 => _("Enabled (for supported clients)"), 2 => _("Required (for supported clients)")); $arrTxPower = getDefaultNetOpts('txpower','dbm'); $managedModeEnabled = false; exec("ip -o link show | awk -F': ' '{print $2}'", $interfaces); sort($interfaces); $reg_domain = shell_exec("iw reg get | grep -o 'country [A-Z]\{2\}' | awk 'NR==1{print $2}'"); - $cmd = "iw dev ".escapeshellarg($_SESSION['ap_interface'])." info | awk '$1==\"txpower\" {print $2}'"; exec($cmd, $txpower); $txpower = intval($txpower[0]); @@ -41,6 +44,7 @@ function DisplayHostAPDConfig() if (isset($_POST['interface'])) { $interface = escapeshellarg($_POST['interface']); } + if (!RASPI_MONITOR_ENABLED) { if (isset($_POST['SaveHostAPDSettings'])) { SaveHostAPDConfig($arrSecurity, $arrEncType, $arr80211Standard, $interfaces, $reg_domain, $status); @@ -61,7 +65,7 @@ function DisplayHostAPDConfig() } elseif ($arrHostapdConf['WifiAPEnable'] == 1) { exec('sudo '.RASPI_CONFIG.'/hostapd/servicestart.sh --interface uap0 --seconds 1', $return); } else { - // systemctl expects a unit name like raspap-network-activity@wlan0.service, no extra quotes + // systemctl expects a unit name like raspap-network-activity@wlan0.service $iface_nonescaped = $_POST['interface']; if (preg_match('/^[a-zA-Z0-9_-]+$/', $iface_nonescaped)) { // validate interface name exec('sudo '.RASPI_CONFIG.'/hostapd/servicestart.sh --interface ' .$iface_nonescaped. ' --seconds 1', $return); @@ -110,12 +114,10 @@ function DisplayHostAPDConfig() } else { $arrConfig['disassoc_low_ack_bool'] = 0; } - // assign country_code from iw reg if not set in config if (empty($arrConfig['country_code']) && isset($country_code[0])) { $arrConfig['country_code'] = $country_code[0]; } - // set txpower with iw if value is non-default ('auto') if (isset($_POST['txpower'])) { if ($_POST['txpower'] != 'auto') { @@ -129,6 +131,12 @@ function DisplayHostAPDConfig() $status->addMessage('Setting transmit power to '.$_POST['txpower'].'.', 'success'); $txpower = $_POST['txpower']; } + } + // map wpa_key_mgmt to security types + if ($arrConfig['wpa_key_mgmt'] == 'WPA-PSK WPA-PSK-SHA256 SAE') { + $arrConfig['wpa'] = 4; + } elseif ($arrConfig['wpa_key_mgmt'] == 'SAE') { + $arrConfig['wpa'] = 5; } $selectedHwMode = $arrConfig['hw_mode']; @@ -166,6 +174,7 @@ function DisplayHostAPDConfig() "selectedHwMode", "arrSecurity", "arrEncType", + "arr80211w", "arrTxPower", "txpower", "arrHostapdConf", @@ -226,6 +235,19 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom $bridgedEnable = 1; } } + // Check for WiFi repeater mode checkbox + $repeaterEnable = 0; + if ($bridgedEnable == 0) { // enable client mode actions when not bridged + if ($arrHostapdConf['RepeaterEnable'] == 0) { + if (isset($_POST['repeaterEnable'])) { + $repeaterEnable = 1; + } + } else { + if (isset($_POST['repeaterEnable'])) { + $repeaterEnable = 1; + } + } + } // Check for WiFi client AP mode checkbox $wifiAPEnable = 0; if ($bridgedEnable == 0) { // enable client mode actions when not bridged @@ -277,6 +299,7 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom // Save previous Client mode status when Bridged $cfg['WifiAPEnable'] = ($bridgedEnable == 1 ? $arrHostapdConf['WifiAPEnable'] : $wifiAPEnable); $cfg['BridgedEnable'] = $bridgedEnable; + $cfg['RepeaterEnable'] = $repeaterEnable; $cfg['WifiManaged'] = $cli_iface; write_php_ini($cfg, RASPI_CONFIG.'/hostapd.ini'); $_SESSION['ap_interface'] = $session_iface; @@ -305,7 +328,6 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom $status->addMessage('Parameter hiddenSSID contains an invalid configuration value.', 'danger'); $good_input = false; } - if (! in_array($_POST['interface'], $interfaces)) { $status->addMessage('Unknown interface '.htmlspecialchars($_POST['interface'], ENT_QUOTES), 'danger'); $good_input = false; @@ -330,7 +352,29 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom $_POST['max_num_sta'] = $_POST['max_num_sta'] < 1 ? null : $_POST['max_num_sta']; if ($good_input) { - $return = updateHostapdConfig($ignore_broadcast_ssid,$wifiAPEnable,$bridgedEnable); + $config = buildHostapdConfig([ + 'interface' => $_POST['interface'], + 'ssid' => $_POST['ssid'], + 'channel' => $_POST['channel'], + 'wpa' => $_POST['wpa'], + '80211w' => $_POST['80211w'] ?? 0, + 'wpa_passphrase' => $_POST['wpa_passphrase'], + 'wpa_pairwise' => $_POST['wpa_pairwise'], + 'hw_mode' => $_POST['hw_mode'], + 'country_code' => $_POST['country_code'], + 'hiddenSSID' => $_POST['hiddenSSID'], + 'max_num_sta' => $_POST['max_num_sta'] ?? null, + 'beacon_interval' => $_POST['beacon_interval'] ?? null, + 'disassoc_low_ack' => $_POST['disassoc_low_ackEnable'] ?? null, + 'bridge' => $bridgedEnable ? 'br0' : null + ]); + + file_put_contents('/tmp/hostapddata', $config); + if ($dualAPEnable) { + system("sudo cp /tmp/hostapddata " . '/etc/hostapd/hostapd-'.$_POST['interface'].'.conf', $result); + } else { + system("sudo cp /tmp/hostapddata " . RASPI_HOSTAPD_CONFIG, $result); + } if (trim($country_code) != trim($reg_domain)) { $return = iwRegSet($country_code, $status); @@ -377,21 +421,46 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom $jsonData = json_decode(getNetConfig($ap_iface), true); $ip_address = empty($jsonData['StaticIP']) ? getDefaultNetValue('dhcp', $ap_iface, 'static ip_address') : $jsonData['StaticIP']; - $domain_name_server = empty($jsonData['StaticDNS']) + $domain_name_server = empty($jsonData['StaticDNS']) ? getDefaultNetValue('dhcp', $ap_iface, 'static domain_name_server') : $jsonData['StaticDNS']; - $routers = empty($jsonData['StaticRouters']) + $routers = empty($jsonData['StaticRouters']) ? getDefaultNetValue('dhcp', $ap_iface, 'static routers') : $jsonData['StaticRouters']; - $netmask = (empty($jsonData['SubnetMask']) || $jsonData['SubnetMask'] === '0.0.0.0') + $netmask = (empty($jsonData['SubnetMask']) || $jsonData['SubnetMask'] === '0.0.0.0') ? getDefaultNetValue('dhcp', $ap_iface, 'subnetmask') : $jsonData['SubnetMask']; if (isset($ip_address) && !preg_match('/.*\/\d+/', $ip_address)) { $ip_address.='/'.mask2cidr($netmask); } + $hasDefaults = !( + empty($ip_address) || + empty($domain_name_server) || + empty($routers) || + empty($netmask) || + $netmask === '0.0.0.0' + ); + if (!$hasDefaults) { + $status->addMessage(sprintf(_('Interface %s has no default settings.'), $ap_iface), 'warning'); + $status->addMessage(('Configure settings in DHCP Server before starting AP.'), 'warning'); + } if ($bridgedEnable == 1) { $config = array_keys(getDefaultNetOpts('dhcp','options')); $config[] = PHP_EOL.'# RaspAP br0 configuration'; $config[] = 'denyinterfaces eth0 wlan0'; $config[] = 'interface br0'; $config[] = PHP_EOL; + } elseif ($repeaterEnable == 1) { + $config = [ '# RaspAP '.$ap_iface.' configuration' ]; + $config[] = 'interface '.$ap_iface; + $config[] = 'static ip_address='.$ip_address; + $config[] = 'static routers='.$routers; + $config[] = 'static domain_name_server='.$domain_name_server; + $client_metric = getIfaceMetric($_SESSION['wifi_client_interface']); + if (is_int($client_metric)) { + $ap_metric = (int)$client_metric + 1; + $config[] = 'metric '.$ap_metric; + } else { + $status->addMessage('Unable to obtain metric value for client interface. Repeater mode inactive.', 'warning'); + $repeaterEnable = false; + } } elseif ($wifiAPEnable == 1) { $config = array_keys(getDefaultNetOpts('dhcp','options')); $config[] = PHP_EOL.'# RaspAP uap0 configuration'; @@ -399,15 +468,12 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom $config[] = 'static ip_address='.$ip_address; $config[] = 'nohook wpa_supplicant'; $config[] = PHP_EOL; - } else { $config = updateDhcpcdConfig($ap_iface, $jsonData, $ip_address, $routers, $domain_name_server); } - $dhcp_cfg = file_get_contents(RASPI_DHCPCD_CONFIG); - $skip_dhcp = false; - if (preg_match('/wlan[2-9]\d*|wlan[1-9]\d+/', $ap_iface)) { + if (preg_match('/wlan[3-9]\d*|wlan[1-9]\d+/', $ap_iface)) { $skip_dhcp = true; } elseif ($bridgedEnable == 1 || $wifiAPEnable == 1) { $dhcp_cfg = join(PHP_EOL, $config); @@ -418,114 +484,183 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $reg_dom $dhcp_cfg = removeDHCPIface($dhcp_cfg,'br0'); $dhcp_cfg = removeDHCPIface($dhcp_cfg,'uap0'); $dhcp_cfg .= $config; - $status->addMessage(sprintf(_('DHCP configuration for %s added.'), $ap_iface), 'success'); } else { $config = join(PHP_EOL, $config); $dhcp_cfg = removeDHCPIface($dhcp_cfg,'br0'); $dhcp_cfg = removeDHCPIface($dhcp_cfg,'uap0'); - $dhcp_cfg = preg_replace('/^#\sRaspAP\s'.$ap_iface.'\s.*?(?=\s*^\s*$)/ms', $config, $dhcp_cfg, 1); - $status->addMessage(sprintf(_('DHCP configuration for %s updated.'), $ap_iface), 'success'); - } - if (!$skip_dhcp) { - file_put_contents("/tmp/dhcpddata", $dhcp_cfg); - system('sudo cp /tmp/dhcpddata '.RASPI_DHCPCD_CONFIG, $return); - if ($return == 0) { - $status->addMessage('Wifi Hotspot settings saved', 'success'); + if (!strpos($dhcp_cfg, 'metric')) { + $dhcp_cfg = preg_replace('/^#\sRaspAP\s'.$ap_iface.'\s.*?(?=(?:\s*^\s*$|\s*nogateway))/ms', $config, $dhcp_cfg, 1); } else { - $status->addMessage('Unable to save wifi hotspot settings', 'danger'); + $metrics = true; } + } + if ($repeaterEnable && $metrics) { + $status->addMessage(_('WiFi repeater mode: A metric value is already defined for DHCP.'), 'warning'); + } else if ($repeaterEnable && !$metrics) { + $status->addMessage(sprintf(_('Metric value configured for the %s interface.'), $ap_iface), 'success'); + $status->addMessage('Restart hotspot to enable WiFi repeater mode.', 'success'); + persistDHCPConfig($dhcp_cfg, $ap_iface, $status); + } elseif (!$skip_dhcp) { + persistDHCPConfig($dhcp_cfg, $ap_iface, $status); } else { - $status->addMessage(sprintf(_('Interface %s has no default settings.'), $ap_iface), 'warning'); - $status->addMessage(('Configure settings in DHCP Server before starting AP.'), 'warning'); - $status->addMessage('Wifi Hotspot settings saved', 'success'); + $status->addMessage('WiFi hotspot settings saved.', 'success'); } } else { - $status->addMessage('Unable to save wifi hotspot settings', 'danger'); + $status->addMessage('Unable to save WiFi hotspot settings', 'danger'); return false; } return true; } /** - * Updates a hostapd configuration + * Persists a DHCP configuration * - * @return boolean $result + * @param string $dhcp_cfg + * @param string $ap_iface + * @param object $status + * @return $status */ -function updateHostapdConfig($ignore_broadcast_ssid,$wifiAPEnable,$bridgedEnable) +function persistDHCPConfig($dhcp_cfg, $ap_iface, $status) { - // Fixed values - $country_code = $_POST['country_code']; - $config = 'driver=nl80211'.PHP_EOL; - $config.= 'ctrl_interface='.RASPI_HOSTAPD_CTRL_INTERFACE.PHP_EOL; - $config.= 'ctrl_interface_group=0'.PHP_EOL; - $config.= 'auth_algs=1'.PHP_EOL; - $config.= 'wpa_key_mgmt=WPA-PSK'.PHP_EOL; - if (isset($_POST['beaconintervalEnable'])) { - $config.= 'beacon_int='.$_POST['beacon_interval'].PHP_EOL; - } - if (isset($_POST['disassoc_low_ackEnable'])) { - $config.= 'disassoc_low_ack=0'.PHP_EOL; - } - $config.= 'ssid='.$_POST['ssid'].PHP_EOL; - $config.= 'channel='.$_POST['channel'].PHP_EOL; - - // Set VHT center frequency segment value - if ((int)$_POST['channel'] < RASPI_5GHZ_CHANNEL_MIN) { - $vht_freq_idx = 42; + file_put_contents("/tmp/dhcpddata", $dhcp_cfg); + system('sudo cp /tmp/dhcpddata '.RASPI_DHCPCD_CONFIG, $return); + if ($return == 0) { + $status->addMessage(sprintf(_('DHCP configuration for %s updated.'), $ap_iface), 'success'); + $status->addMessage('WiFi hotspot settings saved.', 'success'); } else { - $vht_freq_idx = 155; + $status->addMessage('Unable to save WiFi hotspot settings.', 'danger'); } + return $status; +} - if ($_POST['hw_mode'] === 'n') { - $config.= 'hw_mode=g'.PHP_EOL; - $config.= 'ieee80211n=1'.PHP_EOL; - // Enable basic Quality of service - $config.= 'wmm_enabled=1'.PHP_EOL; - } elseif ($_POST['hw_mode'] === 'ac') { - $config.= 'hw_mode=a'.PHP_EOL.PHP_EOL; - $config.= '# N'.PHP_EOL; - $config.= 'ieee80211n=1'.PHP_EOL; - $config.= 'require_ht=1'.PHP_EOL; - $config.= 'ht_capab=[MAX-AMSDU-3839][HT40+][SHORT-GI-20][SHORT-GI-40][DSSS_CCK-40]'.PHP_EOL.PHP_EOL; - $config.= '# AC'.PHP_EOL; - $config.= 'ieee80211ac=1'.PHP_EOL; - $config.= 'require_vht=1'.PHP_EOL; - $config.= 'ieee80211d=0'.PHP_EOL; - $config.= 'ieee80211h=0'.PHP_EOL; - $config.= 'vht_capab=[MAX-AMSDU-3839][SHORT-GI-80]'.PHP_EOL; - $config.= 'vht_oper_chwidth=1'.PHP_EOL; - $config.= 'vht_oper_centr_freq_seg0_idx='.$vht_freq_idx.PHP_EOL.PHP_EOL; - } elseif ($_POST['hw_mode'] === 'w') { - $config.= 'ieee80211w=2'.PHP_EOL; - $config.= 'wpa_key_mgmt=WPA-EAP-SHA256'.PHP_EOL; +/** + * Returns a count of hostapd-.conf files + * + * @return int + */ +function countHostapdConfigs(): int +{ + $configs = glob('/etc/hostapd/hostapd-*.conf'); + return is_array($configs) ? count($configs) : 0; +} + +/** + * Retrieves the metric value for a given interface + * + * @param string $iface + * @return int $metric + */ +function getIfaceMetric($iface) +{ + $metric = shell_exec("ip -o -4 route show dev ".$iface." | awk '/metric/ {print \$NF; exit}'"); + if (isset($metric)) { + $metric = (int)$metric; + return $metric; } else { - $config.= 'hw_mode='.$_POST['hw_mode'].PHP_EOL; - $config.= 'ieee80211n=0'.PHP_EOL; + return false; } - if ($_POST['wpa'] !== 'none') { - $config.= 'wpa_passphrase='.$_POST['wpa_passphrase'].PHP_EOL; +} + +/** + * Builds a hostapd configuration string for a given interface + * + * @param array $params Associative array of config values + * @return string + */ +function buildHostapdConfig(array $params): string +{ + $config = []; + $config[] = 'driver=nl80211'; + $config[] = 'ctrl_interface=' . RASPI_HOSTAPD_CTRL_INTERFACE; + $config[] = 'ctrl_interface_group=0'; + $config[] = 'auth_algs=1'; + + $wpa = $params['wpa']; + $wpa_key_mgmt = 'WPA-PSK'; + + if ($wpa == 4) { + $config[] = 'ieee80211w=1'; + $wpa_key_mgmt = 'WPA-PSK WPA-PSK-SHA256 SAE'; + $wpa = 2; + } elseif ($wpa == 5) { + $config[] = 'ieee80211w=2'; + $wpa_key_mgmt = 'SAE'; + $wpa = 2; } - if ($wifiAPEnable == 1) { - $config.= 'interface=uap0'.PHP_EOL; - } elseif ($bridgedEnable == 1) { - $config.='interface='.$_POST['interface'].PHP_EOL; - $config.= 'bridge=br0'.PHP_EOL; + + if ($params['80211w'] == 1) { + $config[] = 'ieee80211w=1'; + $wpa_key_mgmt = 'WPA-PSK'; + } elseif ($params['80211w'] == 2) { + $config[] = 'ieee80211w=2'; + $wpa_key_mgmt = 'WPA-PSK-SHA256'; + } + + $config[] = 'wpa_key_mgmt=' . $wpa_key_mgmt; + + if (!empty($params['beacon_interval'])) { + $config[] = 'beacon_int=' . $params['beacon_interval']; + } + + if (!empty($params['disassoc_low_ack'])) { + $config[] = 'disassoc_low_ack=0'; + } + + $config[] = 'ssid=' . $params['ssid']; + $config[] = 'channel=' . $params['channel']; + + // Choose VHT segment index (fallback only if required) + $vht_freq_idx = ($params['channel'] < RASPI_5GHZ_CHANNEL_MIN) ? 42 : 155; + + switch ($params['hw_mode']) { + case 'n': + $config[] = 'hw_mode=g'; + $config[] = 'ieee80211n=1'; + $config[] = 'wmm_enabled=1'; + break; + case 'ac': + $config[] = 'hw_mode=a'; + $config[] = '# N'; + $config[] = 'ieee80211n=1'; + $config[] = 'require_ht=1'; + $config[] = 'ht_capab=[MAX-AMSDU-3839][HT40+][SHORT-GI-20][SHORT-GI-40][DSSS_CCK-40]'; + $config[] = '# AC'; + $config[] = 'ieee80211ac=1'; + $config[] = 'require_vht=1'; + $config[] = 'ieee80211d=0'; + $config[] = 'ieee80211h=0'; + $config[] = 'vht_capab=[MAX-AMSDU-3839][SHORT-GI-80]'; + $config[] = 'vht_oper_chwidth=1'; + $config[] = 'vht_oper_centr_freq_seg0_idx=' . $vht_freq_idx; + break; + default: + $config[] = 'hw_mode=' . $params['hw_mode']; + $config[] = 'ieee80211n=0'; + } + + if ($params['wpa'] !== 'none') { + $config[] = 'wpa_passphrase=' . $params['wpa_passphrase']; + } + + if (!empty($params['bridge'])) { + $config[] = 'interface=' . $params['interface']; + $config[] = 'bridge=' . $params['bridge']; } else { - $config.= 'interface='.$_SESSION['ap_interface'].PHP_EOL; - } - $config.= 'wpa='.$_POST['wpa'].PHP_EOL; - $config.= 'wpa_pairwise='.$_POST['wpa_pairwise'].PHP_EOL; - $config.= 'country_code='.$_POST['country_code'].PHP_EOL; - $config.= 'ignore_broadcast_ssid='.$ignore_broadcast_ssid.PHP_EOL; - if (isset($_POST['max_num_sta'])) { - $config.= 'max_num_sta='.$_POST['max_num_sta'].PHP_EOL; + $config[] = 'interface=' . $params['interface']; } - $config.= parseUserHostapdCfg(); + $config[] = 'wpa=' . $wpa; + $config[] = 'wpa_pairwise=' . $params['wpa_pairwise']; + $config[] = 'country_code=' . $params['country_code']; + $config[] = 'ignore_broadcast_ssid=' . $params['hiddenSSID']; + if (!empty($params['max_num_sta'])) { + $config[] = 'max_num_sta=' . (int)$params['max_num_sta']; + } + + // Optional additional user config + $config[] = parseUserHostapdCfg(); - file_put_contents("/tmp/hostapddata", $config); - system("sudo cp /tmp/hostapddata " . RASPI_HOSTAPD_CONFIG, $result); - return $result; + return implode(PHP_EOL, $config) . PHP_EOL; } /** @@ -628,4 +763,3 @@ function parseUserHostapdCfg() return $tmp; } } - From e126f3f664d5e2db55a0797bb9962d5c60b630fb Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 05:50:43 -0700 Subject: [PATCH 12/14] Update social link --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a74c1a0..627d24dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ RaspAP is made possible by a strong [community of developers](https://github.com * [GitHub discussions](https://github.com/RaspAP/raspap-webgui/discussions) * [Discord chat](https://discord.gg/KVAsaAR) -* [Twitter](https://twitter.com/rasp_ap) +* [X](https://x.com/rasp_ap) * [Reddit](https://www.reddit.com/r/RaspAP/) If you enjoy using RaspAP and would like to support our work financially, consider becoming an [Insider](https://github.com/sponsors/RaspAP). From c33522b0151a171eedcd614cfc6a0a05fca833b9 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 06:31:17 -0700 Subject: [PATCH 13/14] Clarify gateway/nogateway option w/ tooltip, update en_US messages --- locale/en_US/LC_MESSAGES/messages.mo | Bin 65161 -> 65371 bytes locale/en_US/LC_MESSAGES/messages.po | 7 +++++-- templates/dhcp/general.php | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/locale/en_US/LC_MESSAGES/messages.mo b/locale/en_US/LC_MESSAGES/messages.mo index 039abaa18cf40e8b04f2d23c530aef1fe778fb23..e2720b14d0f3c1a1664497ab06c8a0808894ac39 100644 GIT binary patch delta 15518 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 delta 15279 zcmY+~2V7Ux|Htu*fPkPVh=72gD2f8^y~C|wDww;>J(JuczoM48HB574;>?j-bDNnH zXPSGK<5!wHZK?m)n{#?R{NKmp^!$9zIrrXk&OP@6{(hHVxlerQ?z$G>@wvnCV-Cj& z$2A2U=a9SO)QDHDMnCce7>p~?ANOG;JdNJyUE8$h!)D~M_%;r~CU^(~Frbd( z_+l{(!?LIWB%wR^cTx%RQPCEIun+3S>8Or#F$OcS7#>Cz$$5Z*=vmhcC=_*_A{dJm zuoQN}>Np8Ck%L$d&tW9@cf#H>Bdm`3$s1xL?0_23GK|EXm>VynmiSlHl7=L5Q!I{} zVH2!|-BAP1#Nv1etKvgjAIm6QQB*W1D1-x0GoOwTxB_*-1E>Mqw7FY-vjPzqPJIO| zh%Hdp>1*@Jr~xiVU3ZK1IOZh3U7z(=aGwfKe2O~ZFVs?bH!zPN1obSVY+fGKULBLM zDPF;O=#E1hnnyAcbCQp@`DE1InuRf#*^u>Dhr_nvBu0^6!2$RZy|6#eBQFj?s+I5ydAX^8|-rndXjD8&_auyo<##l9yhOrXl9RHmCvju&zaI(m${l2DC6MISQjRrB4a8 zge$GbP)qa>wV6Cxnwff|mNXF69*UZIf^DyYdR-GS8r$M+{0KF%F0FW6I1shx{=^_H z>1%@W7?fg`t}*(Ow?-}9`=}FoVEg_5$ND!Wc+!rIgEe@Es^I6=6PQNs)xkW9;TTDtj+$8}YM>i1 zFYd=MJd0YvN2rwv>zKXUouXKpyajf`bQeJo!41?5pQ9du53RazG-_ZKu@Fu}zQWEo zsHJ>@+H5)BHQ$kvs1->;4d^}8gwl}L%~^%I{&@^W*JFYL1U~PXnMI*)R0lQkcTqR$ ziJI{!%#Tx0k17+jsn(!Y>ISM`%g$zxypMYJy-+`t2BIdq7Wq!NoP8#6PM}ueGJ4=+ z)WDvjE|B|ulLwUGOReV7iR_QFHdiu!irYltOL18;`D+}}wh z&Zt3ygL*68#}+uS7wfMZ9iT!3IFDMwyXc9}Q5X6bbpxN?=EmXZPF@G~y4AD3jasn| z=z&8}10QMI$DvkyI%|s1pXG9?c}ojdM}2)e6jk z2hk6Ypq}|TjKe3WO&ZzX45TvZezh?`?|*9oZHivj5vY}zjQV2DN4;j}P%H5-YNlQv zn3?BA^$)__SPb)EdGy7%P!mW-eUQ4?`r+uI_y1#paGZpZxWak}i<95QSo9xYUcai= z4yc*BQ0LFbQn(GH@D@g(XPU{QQLk@3)Bt;+OPghgZJ2?Y!6wu*+lP9#M^H03Vg1Fn z|A`~1cRn=zN1<*w9<@S?k*Ddb#sCZ%XjZxuYGrB+Wc{NE8d1?0d!cscCajD*P)q&< zHR6|;7ySnDJ3L0@JV>L`dZNg7cn`|RK!fTj-C)o&^*(=P40i(=~isHxQv4nYKAi~3b&(XehCZXb<~91Mw=DQg?dy~ zP%BU${q_E*5R9RsE9%C#aV|bWADl!VJ@Z+pna#7VvF<^w)F}+YYpC;{p$7a4HS;`U zOkNn(zpSLqsZF5QAqlmFZBZBKhq~cV)Cm(&1Du1}3yZCrP&4}hwU5z3Jv5#3_}-c>E_$|)u;h(L(M1)wL+I{ehYP-r?%d0oVkuK zwx_;0R>g6cjJvTd`i*D(KP2cr-u!WS5u1>QvSk}!SFDe#u_ZpnrdaP2;|$b*u3&Av zhkB%?Ch(IES7RMa{nUKmW@Bga8<>P|xh9%_5SW3w(FNoUatci{n{pnuA`hF)KRVzC zcpG0}J-j-_47A8p^9MyQ)C>>fKn$M7#|tN8YkZDnFloA(kZUwSb1F__M=U(UJVO_H zlFzjH91J91iFt50YO@_ff4qsl_!tY~ON_>lndUXEfd$CBVN)DsbUB9!VyVdgnfWnW z3nR$8Vm=&;IdK;1M)NTMS7R>RgJtmu=EZ+d{rxh`W(!0OEFLxBs;Ks4Y^H~mO3;sr z#n=P$&N8phFx0M1N6lysy5mNyh+EMM?_(Q$hPrUl=jMZyg4)!Bu^f)YlDGv!@CL?c zv;0M%5k}26-~2e#OzWX;kb*I8{8EX_$j8kwU%J>Y%y|hINPRU7z+}{B?2LL%GqDKn z!RC0==4JWHx#FluA&}{)*KaLq07o%DUc%h?5JT}LvX7kLdFF4w<*0#PLY@CJmcdu3 z^GeS*d!{CCC2xiv=)QpU*H1a`1!m?^sD>)&gEg@xL)U)4)y77K%7OLMF ztjLO;L-q4pXs#QKY7fVk7`c%3FF_FTmHF4}2ADv;1+{x`p-%8#WM<%tx?w?#!Xl`* zB+=R!^~{^2Ce{J9l7p}kjz@3YiE97BMbM1k3cii8ndSpD0`)8pp!UWE)Y9HU4ZvA! zHlsJ{E%C!3EQGpn1ysLg7=tNT3`b%C%tY-O*ERx;=osokKcQa7yI2bIEiwNtSQ9mq z;iwPKM2y4}r~y7e?Ulc=5&ADR18Rfn*B`YPrlD4N0p`)iGK-)H6_-#me2LXCl&!82 zr(kg$fmLy_t-pd%QSDt`5jdI15D=r&MSf|nDn)I zrX^RJN0NZLQDvLgLT#>w7=tOceWa})i&4~1#{sw=y|C~q^JR?1hUC>S3CE&KU#3$8 zS}OO|W@#d^33(+{$8_|;lc)<^M4eZ7joB+Hs2Pn$^_zyd@CzJ@OVAg?zA^oZqaH!! zZ&-gl`(!HGV;X8Hk6X{6W^fU;gf~%}>=|nJ`m8le9)Z=#r6!raM3zyHR9D&X!mbHKRkr~cosE)Tekkb^)HO0KIeL~xyqwIc`K}oT~HsYWjF+X z!P?kvgZaZ|9u_D6(M6D#;BU->z8lSm3tBs4IQ3s+FFb%+%F>(6v#yR>!46gzYK0b~ zJAR9r=}y$j9<=SpP!o6Ev>ooDJ|NFA8vQn#Uo^^~W>#PeUoVV7ZOYZC{u{A89!9O) zzo-HFY&9zvgz6uGm9QwPy*;wBE~f`UAu2ww4bw1`d>$6V&8QW+gu2l^)CVi_Tk}CF zfn~@mq0Z}pB{3Zf;2I3T{iwZg4zmZeP50&b7bVaI6EOsvp+?vZHS;voQl(=cPDTy* zD~!TDsD9T`OZ^b@;olgHe%s9eilRPDl~Ip66|?{SuPZ@rDh6On9E~mTG&*iPjveN2 zzM?x>ZmQ6k{o1GS0c_L$9D0X5(hbZMkr3ADsRFbpT4 zmT(DbWlo@W`*|#l9(&C%n-x%dAOp3;D=`#zp{{!lHL(9-A*}nI`PuOvY9*I_$NFot zZKom@ucDUTYo8fVAZkWMQ3L3Ry1^vW(k?|k<6RhuXHYkKiW+#p_vS`ns2P_=y&bht zk1FMRm)TVBQlX{FupK@3n@tjgdiLR{A4)N(nRZ6)_CeNk)JjZ44_t~G*hinCi0X}mPL=n71J)=TDm>b8SE*Os*X(H-|$=3EbmAogm!snB8p z2BXN2+5C5_EB8TjLOd3uLkbqh5vaYe7`3E(urOXljr=w0QF$LSGt7@g$qQi#Ohm0f zU(~?Hq4vZ=)QYS}?&orL5NM`1P_M;v)HCutY%Wj`bz)i6GfcGgtx%h&H+th}jKK-0 zy|EUxR}Nwcyn#i~=ZJaD<1kq7e|>^z8akpznvOYf0s7z)n}37a{hQGfZ=pB-h8oyY z)QsJangJI?J-Rsb!pf)t)<)g0JH~T=XA*%n#WvJRWTBq*P1GZJfVnVNmU%0JumyPx zYGsC@1~3V=g7Z)VT#35QI@I-dp>BK<-SH{9dM0d6=(rg`Wz-2RP&e*?8c%wp7aHlrWzMLptU7=?F_v;MlU_X+bXbE8IF0QIcmQ8!9K zjkp79a}Kb6jOsrRtKtUK65qoR^gn6(m%u#aH8D3fL%mh)Tm(4?hNDI}3iZqH;rN11xyP?3GwleIjZC-7$M_pawV!HGy>NY}>vXN9z6GWGhOa zwLhOxOO%Rzu@eSh7Usoks1LxM>rvq8udQ!LS5*4)LU@cdI1B;ucHS36r=E8?1m8+O#gAH>rBE% zI0Li){qG_{A{BQ~OIPxu$;+WeJOp*)38)**wywhbD^d(Etn3-t&lpz5cgW|WC~ z<{NCj3pMj&HouCggDe%%@^u{0udt&8Q{ajoK6UPy_oDbwjTkjx)w# zxluR%0_Wlq^ud}p%`2{f~zsLeDUHS@WsnJz|MXoJo7qx%17y^b2- zebfzJ+xlF;n1KajN$R7q5GG?d_RH4A>p-AgzXElmb*N{%4|U!w20w1D&?1nrwOIX@E5@V@9gvIbNMquzWbK{Dble{77 zKFJt>oiG=sxd_S{l5y0G)Ye2~0QySfCH!-`lEdtwM?U<|H74e$&G<8{~{2Oy%$yesbc|7Xb_dzecZbKB{XjI2fkQK{5dZ~irPg|i! zp<^leeA?n{{XX=i@aAT3)Mv!AX|HM9bb@b?|F8Tz;^wwZ?cb7TQJT1AZ!J}^`{^)+ z%Kr|&8QH7y2@axOo3T6A$v9m;FgBWqm)&upu6z&gQbtk6QlSUc2ajfCCWLu@NbQ1* zeF=dbLy3A*QfNCu$)M<{V90*ii&58wK02;Y!m}g(eV#g=y%RwD*Tj8@*HUf}Ct~4@ zvZW(kJUr(siVi<}b;Ze)Ta;ClBb>1V4`43(e2%;E9qI;Bbo@ZR8z0c7qbC+ZZJqas zd*UXFj`rm1ux<`EbUl01(^URL*+RoY>V{ML6Mv6hlvv6W+7qbjPkaP_qZ}r#j}aJ2 z`)tW&6(|KVf6FZKkv(pM@u}w{2UdM-ak}%TtsYm->$}Q>|P}iNf z7;z>JqUZ>t{stDv=v*#1w52^$Enn+D_V_nrX1PF@8|UiiMtMYhj8d66n6q}Ofg^#s z@|1qmy~Zza7v)=uj#SD>$~f}&s^CbY4505K?16nK=ZS03Cz064oa=HfljxX9#bV-p z_y=ZC_&Mb?!Z!FNdQoQ3J`xw|B#xfs?~?0?q%rXs6UQjUDEBDU-?;E0d)}9C)a@W1L%*q%CB)lopAp3V z#Hp11v~?%1M4ap*&~Xe`V?W9Z8q#h3PsBRzSeFt{r!CO72a$KB?I*lu`w%+AC|&5& z*49z`*4JG^vc_JlA)X}voyPu@BE%i>BI?Mby$EF~r9SoV;iin66~bI4 z$$q7LL(x&t`j1Jo|69t|^(O1cnL6}K!&O_q$*TJI--z#!zgDF^MxZC{oiWK=$K||3 z(1VImSea6S4uvRJ?MYLp`wT78ZM)}PpL)61(XRC9fPUU5kiTjuMU3G%03#9Z>RkiG9Tid zwtWnB6>Y4J)9f`SQdfmMi2Mf^$vBdq)q|rMVTlxE~Qu2ZL@C|1Q!a`1CG zi6EWQG~;+>wkKV0()=O*h@a8&2SrCG@;o>gE6`rTUbYu?Lu{N$TO)Pm2q0cbdGk0$ z&K_`nryQiTApe+Bkn`)f@xQgLd*g)W#Pcba?G>sK*L$N~)m=HUaK@KaqFq&pPEg}d zX>5BG`jV7%^0L^J@}2E>l(;6PxXq{0XFT~$N(gZs z48(X!Bio0n?Ei&yh&Jk4N;qX(c4J0zwE_{h)q$fMRT=md%{pe_z>M^2fi6E=^ES0F ziAUHxyBq(xZS&!{oU)oyk@|=Kb-*;+r!RLIMsa_mEx&CWisNnElJ=e1gJd_JCMaSX zqKG@22B$V=y>X%Aw*HMOCz`r$oZF7diNrl9`G_A-HWSySt%JR`&bv!FNuEM^ixQfH zFG?~&IXbVUc+%k@xd&dOtf2gNoS=RtNiRH0DNp}L^**9s3Ym^P#G|kw>ey`ENZf$3f_yn=>j)+eqs%4WG-~0QuuY5F ZG^v!)?Q!s?e2<&t+0^h$?}-0j^?z6vp4R{X diff --git a/locale/en_US/LC_MESSAGES/messages.po b/locale/en_US/LC_MESSAGES/messages.po index 70a5c706..5986aa27 100644 --- a/locale/en_US/LC_MESSAGES/messages.po +++ b/locale/en_US/LC_MESSAGES/messages.po @@ -581,12 +581,15 @@ msgstr "Clients with a particular hardware MAC address can always be allocated t msgid "This option adds dhcp-host entries to the dnsmasq configuration." msgstr "This option adds dhcp-host entries to the dnsmasq configuration." -msgid "This toggles the gateway/nogateway option for this interface in the DHCPCD configuration." -msgstr "This toggles the gateway/nogateway option for this interface in the DHCPCD configuration." +msgid "This toggles the gateway/nogateway option for this interface in the dhcpcd.conf file." +msgstr "This toggles the gateway/nogateway option for this interface in the dhcpcd.conf file." msgid "This toggles the nohook wpa_supplicant option for this interface in the DHCPCD configuration." msgstr "This toggles the nohook wpa_supplicant option for this interface in the DHCPCD configuration." +msgid "Enable this only if you want your device to use this interface as its primary route to the internet." +msgstr "Enable this only if you want your device to use this interface as its primary route to the internet." + msgid "Disable wpa_supplicant dhcp hook for this interface" msgstr "Disable wpa_supplicant dhcp hook for this interface" diff --git a/templates/dhcp/general.php b/templates/dhcp/general.php index b788c87a..13bc7b52 100644 --- a/templates/dhcp/general.php +++ b/templates/dhcp/general.php @@ -69,9 +69,10 @@
+ ">

- gateway/nogateway option for this interface in the DHCPCD configuration.") ?> + gateway/nogateway option for this interface in the dhcpcd.conf file.") ?>

@@ -84,7 +85,7 @@ ">

- nohook wpa_supplicant option for this interface in the DHCPCD configuration.") ?> + nohook wpa_supplicant option for this interface in the dhcpcd.conf file.") ?>

From 6f380299dbcf21c4376ec34772986fd8c8489ed4 Mon Sep 17 00:00:00 2001 From: billz Date: Mon, 14 Jul 2025 07:08:35 -0700 Subject: [PATCH 14/14] Update sudoers w/ wireguard permissions --- installers/raspap.sudoers | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/installers/raspap.sudoers b/installers/raspap.sudoers index 9933cf18..3b7fd055 100644 --- a/installers/raspap.sudoers +++ b/installers/raspap.sudoers @@ -59,10 +59,13 @@ www-data ALL=(ALL) NOPASSWD:/bin/chmod o+r /var/log/dnsmasq.log www-data ALL=(ALL) NOPASSWD:/bin/chmod o+r /tmp/wireguard.log www-data ALL=(ALL) NOPASSWD:/bin/cp /tmp/dnsmasqdata /etc/dnsmasq.d/090_adblock.conf www-data ALL=(ALL) NOPASSWD:/bin/cp /tmp/dnsmasq_custom /etc/raspap/adblock/custom.txt +www-data ALL=(ALL) NOPASSWD:/etc/raspap/adblock/update_blocklist.sh www-data ALL=(ALL) NOPASSWD:/bin/cp /tmp/wgdata /etc/wireguard/*.conf www-data ALL=(ALL) NOPASSWD:/bin/mv /tmp/wg-*.key /etc/wireguard/wg-*.key www-data ALL=(ALL) NOPASSWD:/bin/mv /tmp/wg/* /etc/wireguard/*.conf -www-data ALL=(ALL) NOPASSWD:/etc/raspap/adblock/update_blocklist.sh +www-data ALL=(ALL) NOPASSWD:/bin/rm /etc/wireguard/wg0.conf +www-data ALL=(ALL) NOPASSWD:/usr/bin/ls /etc/wireguard/ +www-data ALL=(ALL) NOPASSWD:/usr/bin/ln -s /etc/wireguard/*.conf /etc/wireguard/*.conf www-data ALL=(ALL) NOPASSWD:/usr/bin/socat - /dev/ttyUSB[0-9] www-data ALL=(ALL) NOPASSWD:/usr/local/sbin/onoff_huawei_hilink.sh * www-data ALL=(ALL) NOPASSWD:/bin/sed -i * /etc/wvdial.conf @@ -75,8 +78,10 @@ www-data ALL=(ALL) NOPASSWD:/bin/cat /etc/wireguard/*.conf www-data ALL=(ALL) NOPASSWD:/bin/cat /etc/wireguard/wg-*.key www-data ALL=(ALL) NOPASSWD:/bin/rm /etc/wireguard/*.conf www-data ALL=(ALL) NOPASSWD:/bin/rm /etc/wireguard/wg-*.key +www-data ALL=(ALL) NOPASSWD:/usr/bin/readlink /etc/wireguard/*.conf www-data ALL=(ALL) NOPASSWD:/usr/sbin/netplan www-data ALL=(ALL) NOPASSWD:/bin/truncate -s 0 /tmp/*.log,/bin/truncate -s 0 /var/log/dnsmasq.log +www-data ALL=(ALL) NOPASSWD:/usr/bin/nmap --script=broadcast-dhcp-discover -e [a-zA-Z0-9]* www-data ALL=(ALL) NOPASSWD:/usr/bin/vnstat * www-data ALL=(ALL) NOPASSWD:/usr/sbin/visudo -cf * www-data ALL=(ALL) NOPASSWD:/etc/raspap/plugins/plugin_helper.sh