From db661eb60a1ba85e2db6c519fcb36d459587973b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 25 Jul 2026 20:22:21 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=85=20Sync=202026-07-25=2020:22:21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gowebdav/Makefile | 4 +- .../luci-static/resources/fchomo/listeners.js | 6 - .../root/usr/share/ucode/fchomo.uc | 3 +- luci-app-qosmate/Makefile | 2 +- .../luci-static/resources/view/settings.js | 69 ++- luci-theme-aurora/.dev/docs/DEVELOPMENT.md | 21 +- .../.dev/scripts/mock-capture.client.js | 75 +++ .../.dev/scripts/mock-nav.client.js | 289 ++++++++++++ luci-theme-aurora/.dev/src/media/login.css | 5 + luci-theme-aurora/.dev/src/media/main.css | 7 + luci-theme-aurora/.dev/vite.config.ts | 428 +++++++++++++++++- luci-theme-aurora/.gitignore | 3 + luci-theme-aurora/Makefile | 2 +- .../aurora/patches/admin-modem-qmodem.css | 2 + qosmate/Makefile | 2 +- qosmate/README.md | 30 +- qosmate/etc/config/qosmate | 20 +- qosmate/etc/init.d/qosmate | 180 ++++++-- qosmate/etc/qosmate.sh | 21 +- 19 files changed, 1051 insertions(+), 118 deletions(-) create mode 100644 luci-theme-aurora/.dev/scripts/mock-capture.client.js create mode 100644 luci-theme-aurora/.dev/scripts/mock-nav.client.js create mode 100644 luci-theme-aurora/htdocs/luci-static/aurora/patches/admin-modem-qmodem.css diff --git a/gowebdav/Makefile b/gowebdav/Makefile index 99d84a3bf..cd5a19f66 100644 --- a/gowebdav/Makefile +++ b/gowebdav/Makefile @@ -6,8 +6,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gowebdav -PKG_VERSION:=5.14.0 -PKG_RELEASE:=17 +PKG_VERSION:=5.14.1 +PKG_RELEASE:=18 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/hacdias/webdav/tar.gz/v$(PKG_VERSION)? diff --git a/luci-app-fchomo/htdocs/luci-static/resources/fchomo/listeners.js b/luci-app-fchomo/htdocs/luci-static/resources/fchomo/listeners.js index 968bb6ebe..d60571f91 100644 --- a/luci-app-fchomo/htdocs/luci-static/resources/fchomo/listeners.js +++ b/luci-app-fchomo/htdocs/luci-static/resources/fchomo/listeners.js @@ -773,12 +773,6 @@ function renderListeners(s, uciconfig, isClient) { o.depends({type: 'shadowquic'}); o.modalonly = true; - o = s.taboption('field_plugin', form.Flag, 'plugin_opts_quic_version_probe', _('QUIC version probe'), - _('Probe the QUIC version of the handshake target during the first connection.')); - o.default = o.disabled; - o.depends({type: 'shadowquic'}); - o.modalonly = true; - /* Vless Encryption fields */ o = s.taboption('field_general', form.Flag, 'vless_decryption', _('decryption')); o.default = o.disabled; diff --git a/luci-app-fchomo/root/usr/share/ucode/fchomo.uc b/luci-app-fchomo/root/usr/share/ucode/fchomo.uc index cefb04334..c5f48306e 100644 --- a/luci-app-fchomo/root/usr/share/ucode/fchomo.uc +++ b/luci-app-fchomo/root/usr/share/ucode/fchomo.uc @@ -340,8 +340,7 @@ export function parseListener(cfg) { addr: cfg.plugin_opts_handshake_dest, sni: cfg.plugin_opts_host, proxy: cfg.plugin_opts_dest_proxy, // raw data need post-processing - "rate-limit": strToInt(cfg.plugin_opts_rate_limit), - "quic-version-probe": strToBool(cfg.plugin_opts_quic_version_probe) + "rate-limit": strToInt(cfg.plugin_opts_rate_limit) } : null, cwnd: strToInt(cfg.shadowquic_cwnd), "max-datagram-frame-size": strToInt(cfg.shadowquic_max_datagram_frame_size), diff --git a/luci-app-qosmate/Makefile b/luci-app-qosmate/Makefile index 7dca335ba..d2c4e16f9 100644 --- a/luci-app-qosmate/Makefile +++ b/luci-app-qosmate/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-qosmate PKG_VERSION:=1.0.14 -PKG_RELEASE:=1 +PKG_RELEASE:=2 PKG_MAINTAINER:=Markus Hütter PKG_LICENSE:=GPL-3.0-or-later diff --git a/luci-app-qosmate/htdocs/luci-static/resources/view/settings.js b/luci-app-qosmate/htdocs/luci-static/resources/view/settings.js index c1e26380b..c7ab299cb 100644 --- a/luci-app-qosmate/htdocs/luci-static/resources/view/settings.js +++ b/luci-app-qosmate/htdocs/luci-static/resources/view/settings.js @@ -50,6 +50,31 @@ function isSfoEnabled() { return uci.get('firewall', '@defaults[0]', 'flow_offloading') === '1'; } +// The auto-setup runs detached, because a speed test regularly takes longer than uhttpd's +// script_timeout (60s by default) and a request waiting for the whole run gets killed. Progress is +// followed in the output file, which the backend ends with the AUTO_SETUP_DONE marker. +function pollAutoSetupOutput(outputFile, tries, lastError) { + // read_direct, because ubus file.read stops at the first NUL byte and the output file can + // contain NUL padding while it is still being written + return fs.read_direct(outputFile).catch(function(err) { lastError = err; return ''; }).then(function(output) { + if (/^AUTO_SETUP_DONE/m.test(output)) + return output; + + if (tries <= 0) + return Promise.reject(new Error(lastError + ? _('Auto setup did not finish, last error: ') + lastError + : _('Auto setup did not finish within 5 minutes.'))); + + var status = document.getElementById('qosmate-auto-setup-status'); + var lines = output.replace(/\r/g, '\n').trim().split('\n'); + if (status && lines[lines.length - 1]) + status.textContent = lines[lines.length - 1]; + + return new Promise(function(resolve) { window.setTimeout(resolve, 2000); }) + .then(function() { return pollAutoSetupOutput(outputFile, tries - 1, lastError); }); + }); +} + function fetchVersionInfo() { return fs.exec_direct('/etc/init.d/qosmate', ['check_version']) .then(function(output) { @@ -615,35 +640,52 @@ return view.extend({ 'click': ui.createHandlerFn(this, function() { var gamingIp = document.getElementById('gaming_ip').value; ui.showModal(_('Running Auto Setup'), [ - E('p', { 'class': 'spinning' }, _('Please wait while the auto setup is in progress...')), + E('p', { 'class': 'spinning', 'id': 'qosmate-auto-setup-status' }, _('Please wait while the auto setup is in progress...')), E('div', { 'style': 'margin-top: 1em; border-top: 1px solid #ccc; padding-top: 1em;' }, [ E('p', { 'style': 'font-weight: bold;' }, _('Note:')), E('p', _('Router-based speed tests may underestimate actual speeds. These results serve as a starting point and may require manual adjustment for optimal performance.')) ]) ]); - return fs.exec_direct('/etc/init.d/qosmate', ['auto_setup_noninteractive', gamingIp]) + return fs.exec_direct('/etc/init.d/qosmate', ['auto_setup_detached', gamingIp]) .then(function(res) { - var outputFile = res.trim(); - return fs.read(outputFile).then(function(output) { + var outputFile = (res || '').trim(); + + // a backend without auto_setup_detached prints its usage instead of a path + var run = (outputFile.charAt(0) === '/') + ? pollAutoSetupOutput(outputFile, 150) + : fs.exec_direct('/etc/init.d/qosmate', ['auto_setup_noninteractive', gamingIp]) + .then(function(res2) { return fs.read_direct((res2 || '').trim()); }); + + return run.then(function(output) { ui.hideModal(); var wanInterface = output.match(/Detected WAN interface: (.+)/); - var downloadSpeed = output.match(/Download speed: (.+) Mbit\/s/); - var uploadSpeed = output.match(/Upload speed: (.+) Mbit\/s/); - var downrate = output.match(/DOWNRATE: (.+) kbps/); - var uprate = output.match(/UPRATE: (.+) kbps/); + var downloadSpeed = output.match(/Download speed: ([\d.]+) Mbit\/s/); + var uploadSpeed = output.match(/Upload speed: ([\d.]+) Mbit\/s/); + var downrate = output.match(/DOWNRATE: (\d+) kbps/); + var uprate = output.match(/UPRATE: (\d+) kbps/); if (!downloadSpeed || !uploadSpeed || parseFloat(downloadSpeed[1]) <= 0 || parseFloat(uploadSpeed[1]) <= 0 || !downrate || !uprate || parseInt(downrate[1]) <= 0 || parseInt(uprate[1]) <= 0) { - ui.addNotification(null, E('p', _('Invalid speed test results. Please try again or set values manually.')), 'error'); + ui.addNotification(null, [ + E('p', _('Invalid speed test results. Please try again or set values manually.')), + E('pre', { 'style': 'white-space: pre-wrap; max-height: 12em; overflow: auto;' }, + output.trim().split('\n').slice(-10).join('\n')) + ], 'error'); return; - } - var gamingRules = output.match(/Gaming device rules added for IP: (.+)/); + } + + // 'rules?' keeps this working with backends that report a single rule + var gamingRules = output.match(/Gaming device rules? added for IP: (.+)/); + var existingRule = output.match(/A rule for IP (.+) already exists/); + var warnings = output.match(/^Warning: .+$/gm) || []; ui.showModal(_(''), [ E('h2', { 'style': 'text-align:center; margin-bottom: 1em;' }, _('Auto Setup Results')), E('h3', { 'style': 'margin-bottom: 0.5em;' }, _('Speed Test Results')), E('p', { 'style': 'color: orange; margin-bottom: 1em;' }, _('Note: Router-based speed tests may underestimate actual speeds. For best results, consider running tests from a LAN device and manually entering the values. These results serve as a starting point.')), + warnings.length ? E('div', { 'class': 'alert-message warning', 'style': 'margin-bottom: 1em;' }, + warnings.map(function(line) { return E('p', {}, line.replace(/^Warning: /, '')); })) : '', E('div', { 'style': 'display: table; width: 100%;' }, [ E('div', { 'style': 'display: table-row;' }, [ E('div', { 'style': 'display: table-cell; padding: 5px; font-weight: bold;' }, _('WAN Interface')), @@ -669,9 +711,10 @@ return view.extend({ E('div', { 'style': 'display: table-cell; padding: 5px;' }, uprate ? uprate[1] + ' kbps' : _('Not set')) ]) ]), - gamingRules ? E('div', { 'style': 'margin-top: 1em;' }, [ + (gamingRules || existingRule) ? E('div', { 'style': 'margin-top: 1em;' }, [ E('div', { 'style': 'font-weight: bold;' }, _('Gaming Rules')), - E('div', {}, _('Added for IP: ') + gamingRules[1]) + E('div', {}, gamingRules ? _('Added for IP: ') + gamingRules[1] + : _('Rule already exists for IP: ') + existingRule[1]) ]) : '', E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [ E('button', { diff --git a/luci-theme-aurora/.dev/docs/DEVELOPMENT.md b/luci-theme-aurora/.dev/docs/DEVELOPMENT.md index 8b6470ece..a8cad7fae 100644 --- a/luci-theme-aurora/.dev/docs/DEVELOPMENT.md +++ b/luci-theme-aurora/.dev/docs/DEVELOPMENT.md @@ -173,6 +173,22 @@ Two rules of thumb that follow from prefix matching: > Unlike the `_`-prefixed partials (which are `@import`-only fragments), patch filenames have no `_` prefix — each is a real build entry that ships to `htdocs/`. +### Mock Pages + +Style a third-party app's page — write or adjust its `patches/*.css`, or check a `main.css`/component change against it — **without having the app (or a device) installed**. Save the page's rendered HTML once, then develop against it with the theme live and hot-reloading. + +- **Where snapshots live:** `.dev/mocks/*.html` (git-ignored — snapshots are large, device/fork-specific and go stale, so they stay local). The directory need not exist in a fresh clone; it's created on first capture. +- **Capture one:** with the dev server proxying a device that has the page, open the page through the proxy and press Alt/Option+Shift+S (or call `__auroraMockCapture()` in the console). The injected `scripts/mock-capture.client.js` helper POSTs the live DOM to `/mocks/__save`, which writes `.dev/mocks/.html` — named after the page's `data-page`, doctype included, dev-only script tags stripped. (The endpoint only accepts requests carrying the helper's custom header, which cross-origin pages can't send without a CORS preflight this server never approves.) Manual capture still works: run `copy(document.documentElement.outerHTML)` in the DevTools console and save the paste as `.dev/mocks/.html` — the filename is free; the page's real identity is the `data-page` attribute already in its ``, which patch selectors match. +- **Capture it from a device running _this_ theme — snapshots are not portable between themes.** A snapshot is a verbatim copy of a rendered page, so it hard-codes the theme that rendered it in three places: the stylesheet links (`/luci-static/aurora/main.css` plus that page's patch), the device's stored UCI token overrides in an inline ` + +
+
+ + + +
+ `; + + const panel = root.querySelector(".panel"); + const toastEl = root.querySelector(".toast"); + const nameBtn = root.querySelector(".name"); + + nameBtn.innerHTML = + escapeHtml(displayName(data.current)) + + `${current + 1}/${mocks.length}`; + + panel.innerHTML = + mocks + .map( + (m) => + `` + + `${escapeHtml(displayName(m.file))}`, + ) + .join("") + + '
全部快照 ↗[ / ] 切换
'; + + const cycle = (step) => { + if (mocks.length < 2) return; + const next = mocks[(current + step + mocks.length) % mocks.length]; + location.href = mockUrl(next.file); + }; + + root.querySelector(".prev").addEventListener("click", () => cycle(-1)); + root.querySelector(".next").addEventListener("click", () => cycle(1)); + nameBtn.addEventListener("click", () => { + panel.hidden = !panel.hidden; + }); + document.addEventListener("click", (event) => { + if (!panel.hidden && !event.composedPath().includes(host)) { + panel.hidden = true; + } + }); + + let toastTimer; + const toast = (text) => { + toastEl.textContent = text; + toastEl.classList.add("show"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2600); + }; + + document.documentElement.appendChild(host); + + // ---- navigation takeover ------------------------------------------------- + + const pageFromHref = (href) => { + try { + const url = new URL(href, location.href); + if (url.origin !== location.origin) return null; + const match = url.pathname.match(/^\/cgi-bin\/luci(?:$|\/(.*))/); + if (!match) return null; + return decodeURIComponent(match[1] || "") + .split("/") + .filter(Boolean) + .join("-"); + } catch { + return null; + } + }; + + document.addEventListener( + "click", + (event) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + const target = event.target instanceof Element ? event.target : null; + const anchor = target && target.closest("a[href]"); + if (!anchor) return; + const page = pageFromHref(anchor.getAttribute("href")); + if (page === null) return; + // Never let a click inside a mock fall through to the proxied router. + event.preventDefault(); + const file = byPage.get(page); + if (file) location.href = mockUrl(file); + else + toast( + `未捕获快照:${page || "/cgi-bin/luci"}(真机页 Alt/Option+Shift+S 可捕获)`, + ); + }, + true, + ); + + const isEditable = (el) => + el instanceof Element && + (el.isContentEditable || /^(input|textarea|select)$/i.test(el.tagName)); + + addEventListener("keydown", (event) => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (isEditable(event.target)) return; + if (event.key === "[") cycle(-1); + else if (event.key === "]") cycle(1); + }); +})(); diff --git a/luci-theme-aurora/.dev/src/media/login.css b/luci-theme-aurora/.dev/src/media/login.css index e293821fd..625b3059c 100644 --- a/luci-theme-aurora/.dev/src/media/login.css +++ b/luci-theme-aurora/.dev/src/media/login.css @@ -6,9 +6,14 @@ @import "tailwindcss"; @import "./_tokens.css"; + @import "./_reduced-motion.css"; @import "./_base.css"; +/* See main.css: keep dev-only tooling out of Tailwind's source detection. */ +@source not "../../vite.config.ts"; +@source not "../../scripts/*.client.js"; + @custom-variant dark (&:where([data-darkmode=true], [data-darkmode=true] *)); @keyframes divider-in { diff --git a/luci-theme-aurora/.dev/src/media/main.css b/luci-theme-aurora/.dev/src/media/main.css index d297d732e..0537a6002 100644 --- a/luci-theme-aurora/.dev/src/media/main.css +++ b/luci-theme-aurora/.dev/src/media/main.css @@ -5,6 +5,13 @@ @import "tailwindcss"; +/* Dev-only tooling holds prose and plain CSS whose bare words (static, + transition, relative, …) Tailwind's automatic source detection would + otherwise read as utility candidates and emit into this shipped stylesheet. + Nothing here renders markup, so none of it belongs in the scan. */ +@source not "../../vite.config.ts"; +@source not "../../scripts/*.client.js"; + @custom-variant dark (&:where([data-darkmode=true], [data-darkmode=true] *)); @plugin "tailwind-scrollbar" { diff --git a/luci-theme-aurora/.dev/vite.config.ts b/luci-theme-aurora/.dev/vite.config.ts index 577017833..d1670cc43 100644 --- a/luci-theme-aurora/.dev/vite.config.ts +++ b/luci-theme-aurora/.dev/vite.config.ts @@ -6,8 +6,9 @@ import tailwindcss from "@tailwindcss/vite"; import browserslist from "browserslist"; import { exec } from "child_process"; -import { existsSync, readdirSync, statSync } from "fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { mkdir, readdir, readFile, writeFile } from "fs/promises"; +import type { IncomingMessage, ServerResponse } from "http"; import { browserslistToTargets } from "lightningcss"; import { basename, dirname, join, relative, resolve, sep } from "path"; import { minify as terserMinify } from "terser"; @@ -182,6 +183,420 @@ function createLocalServePlugin(): Plugin { }; } +// Mock pages: render saved LuCI page snapshots against the live theme, so a +// third-party app's page can be styled without the app (or a device) installed. +// Snapshots live in .dev/mocks/*.html and are served at /mocks/.html +// with the Vite HMR client injected, so theme edits (main.css, components, +// patches/*.css, served JS) trigger the existing full-reload in handleHotUpdate. +// Any third-party asset a snapshot needs (the app's own css/js, e.g. +// qmodem-next.css) goes under .dev/mocks/static/ mirroring its /luci-static/… +// URL and is served as-is (no HMR). Served snapshots additionally get +// scripts/mock-nav.client.js (links between captured pages navigate in place, +// plus a floating switcher), and proxied device pages get +// scripts/mock-capture.client.js (hotkey → POST /mocks/__save captures the +// open page as a snapshot). See "Mock Pages" in .dev/docs/DEVELOPMENT.md. +const MOCK_ROUTE = "/mocks"; +const MOCKS_DIR = resolve(CURRENT_DIR, "mocks"); +const MOCKS_STATIC_DIR = join(MOCKS_DIR, "static"); +const MOCK_NAV_CLIENT = resolve(CURRENT_DIR, "scripts/mock-nav.client.js"); +const MOCK_CAPTURE_CLIENT = resolve( + CURRENT_DIR, + "scripts/mock-capture.client.js", +); +// A captured LuCI page is tens of KB — anything past this is not a page. +const MOCK_SAVE_LIMIT = 20 * 1024 * 1024; + +const MOCK_MIME: Record = { + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".map": "application/json; charset=utf-8", +}; + +function mockContentType(file: string): string { + const ext = file.slice(file.lastIndexOf(".")); + return MOCK_MIME[ext] ?? "application/octet-stream"; +} + +// document.documentElement.outerHTML (the documented manual capture) drops the +// doctype; without one the browser renders the mock in quirks mode and layout +// no longer matches the real page. Every serve and save path funnels through +// this. +function ensureDoctype(html: string): string { + return /^\s*\n${html}`; +} + +const escapeHtml = (s: string): string => + s.replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!, + ); + +// Inject the Vite HMR client so a served snapshot joins the WS channel and +// receives handleHotUpdate's full-reload broadcast on any theme source change. +function injectHmrClient(html: string): string { + if (html.includes("/@vite/client")) return html; + const tag = `\n \n`; + return html.includes("") + ? html.replace("", `${tag} `) + : tag + html; +} + +// A snapshot is static DOM. Left alone, LuCI's runtime (luci.js + the inline +// `new LuCI(...)` bootstrap) boots, polls the backend, gets 403 (no session) +// and pops the "Session expired" modal. Neutralise it for mock rendering: +// strip the scripts that phone home (luci.js/cbi.js/xhr.js and /cgi-bin/ +// endpoints) and pre-define a no-op `L`/`LuCI`/`XHR` stub so the remaining +// inline bootstraps (`new LuCI(...)`, `L.require(...)`, legacy `XHR.poll(...)`) +// run harmlessly. Theme CSS/JS and the theme's own inline scripts (dark mode, +// toolbar) are untouched; framework-dependent theme JS such as menu-aurora +// simply no-ops — the captured DOM is already fully rendered, so a static +// review still looks right. +const MOCK_RUNTIME_GUARD = ``; + +function neutralizeLuciRuntime(html: string): string { + const stripped = html.replace( + /]*\bsrc="[^"]*(?:\/luci-static\/resources\/(?:luci|cbi|xhr)\.js|\/cgi-bin\/)[^"]*"[^>]*>\s*<\/script>/gi, + "", + ); + return stripped.includes("") + ? stripped.replace(/]*>/i, (m) => `${m}\n ${MOCK_RUNTIME_GUARD}`) + : MOCK_RUNTIME_GUARD + stripped; +} + +// Snapshot inventory for the index and for in-mock navigation. A snapshot's +// identity is its (filenames are free), so each file is +// scanned for it once and re-read only when its mtime changes. +interface MockEntry { + file: string; + page: string | null; + mtimeMs: number; +} +const mockMeta = new Map(); + +function listMocks(): MockEntry[] { + if (!existsSync(MOCKS_DIR)) return []; + return readdirSync(MOCKS_DIR, { withFileTypes: true }) + .filter((d) => d.isFile() && d.name.endsWith(".html")) + .map((d) => { + const path = join(MOCKS_DIR, d.name); + const mtimeMs = statSync(path).mtimeMs; + let meta = mockMeta.get(d.name); + if (!meta || meta.mtimeMs !== mtimeMs) { + const page = readFileSync(path, "utf-8").match( + /]*\bdata-page="([^"]*)"/i, + )?.[1]; + meta = { mtimeMs, page: page || null }; + mockMeta.set(d.name, meta); + } + return { file: d.name, page: meta.page, mtimeMs }; + }) + .sort((a, b) => a.file.localeCompare(b.file)); +} + +// Handing the snapshot list to the page lets mock-nav.client.js take over the +// snapshot's own /cgi-bin/luci/ links (jump to the matching mock instead of +// falling through to the proxied router) and render the floating switcher. +function injectMockNav(html: string, current: string): string { + const payload = JSON.stringify({ + current, + mocks: listMocks().map(({ file, page }) => ({ file, page })), + }).replace(/window.__AURORA_MOCKS__ = ${payload};\n` + + ` \n `; + return html.includes("") + ? html.replace("", `${tags}`) + : html + tags; +} + +function readRequestBody(req: IncomingMessage, limit: number): Promise { + return new Promise((resolveBody, reject) => { + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > limit) { + reject(Object.assign(new Error("body too large"), { statusCode: 413 })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf-8"))); + req.on("error", reject); + }); +} + +function relTime(mtimeMs: number): string { + const mins = Math.round((Date.now() - mtimeMs) / 60000); + if (mins < 60) return `${Math.max(mins, 0)}m`; + if (mins < 60 * 24) return `${Math.round(mins / 60)}h`; + return `${Math.round(mins / (60 * 24))}d`; +} + +function renderMockIndex(entries: MockEntry[]): string { + const rows = entries.length + ? entries + .map((e) => { + const meta = [e.page ?? "no data-page", relTime(e.mtimeMs)] + .map(escapeHtml) + .join(" · "); + return ( + `
  • ` + + `${escapeHtml(e.file)}${meta}
  • ` + ); + }) + .join("\n ") + : `
  • No snapshots yet — open a page through the dev proxy and press Alt/Option+Shift+S, or drop a page's HTML into .dev/mocks/
  • `; + return ` + + + + Aurora mock pages + + + +

    Aurora mock pages

    +

    Saved snapshots served against the live theme. Edit theme CSS/JS → the open page hot-reloads; links between captured pages navigate in place.

    +
      + ${rows} +
    + +`; +} + +function createMockPlugin(): Plugin { + const sendJson = (res: ServerResponse, status: number, body: unknown) => { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); + }; + // Each missing mock asset gets one terminal hint, not one per reload. + const mockMissLogged = new Set(); + + return { + name: "mock-pages-plugin", + apply: "serve", + enforce: "pre", + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + if (!req.url) return next(); + const [pathname] = req.url.split("?"); + const inMocks = + pathname === MOCK_ROUTE || pathname.startsWith(`${MOCK_ROUTE}/`); + if (!inMocks && !pathname.startsWith("/luci-static/")) return next(); + + // Every branch answers inside this try: a stray request (malformed + // %-encoding, a directory named *.html, …) must degrade to an error + // response — thrown, it becomes an unhandled rejection and takes the + // whole dev server down with it. + try { + // Dev-helper client scripts (kept as real files for editability). + if ( + pathname === `${MOCK_ROUTE}/__nav.js` || + pathname === `${MOCK_ROUTE}/__capture.js` + ) { + const file = pathname.endsWith("/__nav.js") + ? MOCK_NAV_CLIENT + : MOCK_CAPTURE_CLIENT; + res.statusCode = 200; + res.setHeader("Content-Type", "text/javascript; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(await readFile(file, "utf-8")); + return; + } + + // Capture endpoint, written to by mock-capture.client.js from + // proxied device pages. The custom header doubles as a CORS gate: a + // foreign origin can't send it without a preflight this server never + // approves, so drive-by cross-site POSTs are rejected. + if (pathname === `${MOCK_ROUTE}/__save`) { + if (req.method !== "POST") + return sendJson(res, 405, { error: "POST only" }); + if (req.headers["x-aurora-capture"] !== "1") + return sendJson(res, 403, { error: "missing capture header" }); + const html = (await readRequestBody(req, MOCK_SAVE_LIMIT)) + // The live DOM carries the dev-only tags this server injected; + // a snapshot must stay a clean capture of the real page. + .replace( + /]*\bsrc="(?:\/@vite\/client|\/mocks\/__capture\.js)"[^>]*>\s*<\/script>\s*/gi, + "", + ); + const page = + html.match(/]*\bdata-page="([^"]*)"/i)?.[1] ?? ""; + const safe = page.replace(/[^\w.-]/g, "-").replace(/^\.+/, ""); + const name = `${safe.slice(0, 120) || `snapshot-${Date.now()}`}.html`; + await mkdir(MOCKS_DIR, { recursive: true }); + await writeFile( + join(MOCKS_DIR, name), + ensureDoctype(html), + "utf-8", + ); + console.log( + `${tag("Mocks")} captured ${name}${page ? ` (${page})` : ""}`, + ); + return sendJson(res, 200, { file: name, page: page || null }); + } + + // Index of available snapshots. + if (pathname === MOCK_ROUTE || pathname === `${MOCK_ROUTE}/`) { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(renderMockIndex(listMocks())); + return; + } + + // A single snapshot: LuCI runtime neutralised, HMR client + in-mock + // navigation injected, doctype restored. + if (inMocks && pathname.endsWith(".html")) { + const name = basename(decodeURIComponent(pathname)); + const file = resolve(MOCKS_DIR, name); + if ( + !file.startsWith(MOCKS_DIR + sep) || + !existsSync(file) || + !statSync(file).isFile() + ) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end( + `

    Mock not found: ${escapeHtml(name)} — back to index`, + ); + return; + } + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + const snapshot = await readFile(file, "utf-8"); + res.end( + ensureDoctype( + injectMockNav( + injectHmrClient(neutralizeLuciRuntime(snapshot)), + name, + ), + ), + ); + return; + } + + // Third-party static drop-ins the snapshot references (the app's own + // css/js) — reached only when local-serve didn't already claim the + // path. + if (pathname.startsWith("/luci-static/")) { + const file = resolve( + MOCKS_STATIC_DIR, + decodeURIComponent(pathname.slice(1)), + ); + if ( + file.startsWith(MOCKS_STATIC_DIR + sep) && + existsSync(file) && + statSync(file).isFile() + ) { + res.statusCode = 200; + res.setHeader("Content-Type", mockContentType(file)); + res.setHeader("Cache-Control", "no-store"); + res.end(await readFile(file)); + return; + } + // A miss requested BY a mock page must fail fast, not fall + // through to the device proxy: with no reachable router the + // request just hangs, pinning the tab's load event for minutes + // (device-only assets like a custom logo hit this). Real proxied + // pages keep their normal fallthrough. + const referer = req.headers.referer ?? ""; + if ( + new URL(referer, "http://_").pathname.startsWith(`${MOCK_ROUTE}/`) + ) { + if (!mockMissLogged.has(pathname)) { + mockMissLogged.add(pathname); + console.log( + `${tag("Mocks")} miss ${pathname} → 404 (mirror it at .dev/mocks/static${pathname} to serve it)`, + ); + } + res.statusCode = 404; + res.setHeader("Cache-Control", "no-store"); + res.end(); + return; + } + + // Whatever else reaches here on /luci-static/ is destined for the + // device proxy. Static files answer in milliseconds on a real + // router, so bound the wait: CSS-initiated requests (nav icons) + // carry the stylesheet's URL as referer and dodge the mock 404 + // above — with no reachable device they would otherwise pin the + // tab's load event for minutes. Vite's proxy error handler + // tolerates an already-ended response, and /cgi-bin is untouched + // (dynamic endpoints can be legitimately slow). + const deadline = setTimeout(() => { + if (!res.headersSent && res.writable) { + console.log( + `${tag("Proxy")} ${pathname} → 504 after 5s (router unreachable?)`, + ); + res.statusCode = 504; + res.end(); + } + }, 5000); + res.on("close", () => clearTimeout(deadline)); + } + + next(); + } catch (err: any) { + const status = + err?.statusCode ?? (err instanceof URIError ? 400 : 500); + console.error(`${tag("Mocks")} ${req.url}: ${err?.message ?? err}`); + if (res.headersSent) { + res.destroy(); + return; + } + if (pathname === `${MOCK_ROUTE}/__save`) { + sendJson(res, status, { error: String(err?.message ?? err) }); + } else { + res.statusCode = status; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end(status === 400 ? "Bad request" : "Mock route error"); + } + } + }); + }, + }; +} + const UT_TEMPLATE_DIR = resolve(PROJECT_ROOT, "ucode/template/themes/aurora"); const UT_REMOTE_DIR = "/usr/share/ucode/luci/template/themes/aurora"; @@ -371,6 +786,7 @@ export default defineConfig(({ mode }) => { tailwindcss(), createRedirectPlugin(), createLocalServePlugin(), + createMockPlugin(), createUtSyncPlugin(OPENWRT_SSH_HOST), createLuciJsCompressPlugin(), ], @@ -445,6 +861,16 @@ export default defineConfig(({ mode }) => { ) { html = html.replace("", `${client}\n\t`); } + // Real device pages also get the mock-capture helper: + // Alt/Option+Shift+S → POST /mocks/__save stores the open + // page as a snapshot for the /mocks/ workflow. + const capture = ``; + if ( + html.includes("") && + !html.includes("/__capture.js") + ) { + html = html.replace("", `${capture}\n\t`); + } const { "transfer-encoding": _, ...headers } = proxyRes.headers; res.writeHead(status, { ...headers, diff --git a/luci-theme-aurora/.gitignore b/luci-theme-aurora/.gitignore index 8d90bd748..4366d7b7b 100644 --- a/luci-theme-aurora/.gitignore +++ b/luci-theme-aurora/.gitignore @@ -17,3 +17,6 @@ docs/superpowers/ # On-device perf baselines — record device model and LAN address; keep local /.claude/skills/aurora-performance/baselines/ + +# Local page snapshots for the dev /mocks/ route — see .dev/docs/DEVELOPMENT.md +/.dev/mocks/ diff --git a/luci-theme-aurora/Makefile b/luci-theme-aurora/Makefile index c064b2385..8c578f110 100644 --- a/luci-theme-aurora/Makefile +++ b/luci-theme-aurora/Makefile @@ -9,7 +9,7 @@ LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CS LUCI_DEPENDS:=+luci-base PKG_VERSION:=1.1.2 -PKG_RELEASE:=54 +PKG_RELEASE:=55 PKG_LICENSE:=Apache-2.0 LUCI_MINIFY_CSS:= diff --git a/luci-theme-aurora/htdocs/luci-static/aurora/patches/admin-modem-qmodem.css b/luci-theme-aurora/htdocs/luci-static/aurora/patches/admin-modem-qmodem.css new file mode 100644 index 000000000..304111674 --- /dev/null +++ b/luci-theme-aurora/htdocs/luci-static/aurora/patches/admin-modem-qmodem.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-content:"";--tw-font-weight:initial;--tw-duration:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}[data-page^=admin-modem-qmodem] .copyright-section{border-style:var(--tw-border-style);border-width:1px;border-color:var(--hairline);background-color:var(--surface-sunken);text-align:center!important;color:var(--text-muted)!important}[data-page^=admin-modem-qmodem] fieldset.cbi-section>legend{float:left;margin-bottom:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--hairline);width:100%;padding-inline:0;padding-bottom:calc(var(--spacing) * 3);--tw-leading:var(--leading-tight,1.25);line-height:var(--leading-tight,1.25);color:var(--text)}[data-page^=admin-modem-qmodem] fieldset.cbi-section{display:flow-root}[data-page^=admin-modem-qmodem] fieldset.cbi-section>:not(legend){clear:both}[data-page^=admin-modem-qmodem] .cbi-section-table-titles.named:before{content:var(--tw-content);border-top-left-radius:calc(var(--radius-base) * 2);content:var(--tw-content);content:var(--tw-content);content:var(--tw-content);background-color:var(--surface-sunken);border-bottom-style:var(--tw-border-style)!important;border-bottom-width:1px!important;border-color:var(--hairline)!important}[data-page^=admin-modem-qmodem-sms-conversation] #sms-messages-area{border-style:var(--tw-border-style);border-width:1px;border-color:var(--hairline)!important;background-color:var(--surface-sunken)!important;color:var(--text)!important}[data-page^=admin-modem-qmodem-sms-conversation] .sms-message-bubble{border-style:var(--tw-border-style);border-width:1px;border-color:var(--hairline)!important;background-color:var(--surface-overlay)!important;color:var(--text)!important}[data-page^=admin-modem-qmodem-sms-conversation] .sms-message-content{color:var(--text)!important}[data-page^=admin-modem-qmodem-sms-conversation] .sms-message-meta{color:var(--text-muted)!important}[data-page=admin-modem-qmodem-config_advanced] .cbi-tabmenu li{cursor:pointer;border-radius:calc(var(--radius-base) * 4);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);text-align:center;font-size:var(--text-sm,.875rem);line-height:var(--tw-leading,var(--text-sm--line-height,calc(1.25 / .875)));--tw-font-weight:var(--font-weight-medium,500);font-weight:var(--font-weight-medium,500);white-space:nowrap;color:var(--text-muted);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function,cubic-bezier(.4, 0, .2, 1)));transition-duration:var(--tw-duration,var(--default-transition-duration,.15s));--tw-duration:.15s;transition-duration:.15s}@media (hover:hover){[data-page=admin-modem-qmodem-config_advanced] .cbi-tabmenu li:hover{background-color:var(--hover-faint);color:var(--text)}}[data-page=admin-modem-qmodem-config_advanced] .cbi-tabmenu li.cbi-tab{background-color:var(--text);--tw-font-weight:var(--font-weight-semibold,600);font-weight:var(--font-weight-semibold,600);color:var(--surface);--tw-shadow:var(--app-shadow-sm);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}[data-page=admin-modem-qmodem-sms] .td>div{margin-top:calc(var(--spacing) * 1.5)!important;font-size:var(--text-xs,.75rem)!important;line-height:var(--tw-leading,var(--text-xs--line-height,calc(1 / .75)))!important;--tw-leading:var(--leading-relaxed,1.625)!important;line-height:var(--leading-relaxed,1.625)!important;color:var(--text-muted)!important}[data-page=admin-modem-qmodem-sms] .td>span[style*=margin-left]{color:var(--text);margin-left:calc(var(--spacing) * 2)!important}[data-page=admin-modem-qmodem-sms] .sms-conversations{margin-top:calc(var(--spacing) * 5);margin-bottom:0}[data-page=admin-modem-qmodem-sms] .sms-conversations>.cbi-section{border-style:var(--tw-border-style)!important;--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important;background-color:#0000!important;border-width:0!important;margin:0!important;padding:0!important}[data-page=admin-modem-qmodem-sms] .sms-conversations fieldset.cbi-section{margin-bottom:0}[data-page=admin-modem-qmodem-sms] .sms-conversations .table.cbi-section-table{margin-bottom:0;overflow:hidden}[data-page=admin-modem-qmodem-sms] .cbi-section-table-row.tr{color:var(--text);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function,cubic-bezier(.4, 0, .2, 1)));transition-duration:var(--tw-duration,var(--default-transition-duration,.15s));--tw-duration:.15s;transition-duration:.15s}[data-page=admin-modem-qmodem-sms] .cbi-section-table-row.tr>.td,[data-page=admin-modem-qmodem-sms] .cbi-section-table-row.tr>td{color:var(--text)}[data-page=admin-modem-qmodem-sms] .cbi-section-table-row.tr>.td:nth-child(2),[data-page=admin-modem-qmodem-sms] .cbi-section-table-row.tr>td:nth-child(2){color:var(--text-muted)}[data-page=admin-modem-qmodem-sms] .cbi-section-table-row.tr:hover{background-color:var(--hover-faint)!important;color:var(--text)!important}[data-page=admin-modem-qmodem-sms] .cbi-section-table-row.tr:hover>.td,[data-page=admin-modem-qmodem-sms] .cbi-section-table-row.tr:hover>td{color:var(--text)!important;background-color:#0000!important}[data-page=admin-modem-qmodem-sms] .sms-unread-conversation{--tw-font-weight:var(--font-weight-semibold,600);font-weight:var(--font-weight-semibold,600);background-color:var(--brand-subtle)!important}[data-page=admin-modem-qmodem-sms] .sms-unread-badge{margin-left:calc(var(--spacing) * 2)!important;background-color:var(--danger)!important;padding-inline:calc(var(--spacing) * 2)!important;padding-block:calc(var(--spacing) * .5)!important;font-size:var(--text-xs,.75rem)!important;line-height:var(--tw-leading,var(--text-xs--line-height,calc(1 / .75)))!important;--tw-font-weight:var(--font-weight-semibold,600)!important;font-weight:var(--font-weight-semibold,600)!important;color:var(--on-brand)!important;border-radius:3.40282e38px!important}[data-page=admin-modem-qmodem-sms_sim] .cbi-section-table-row.tr{color:var(--text);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function,cubic-bezier(.4, 0, .2, 1)));transition-duration:var(--tw-duration,var(--default-transition-duration,.15s));--tw-duration:.15s;transition-duration:.15s}[data-page=admin-modem-qmodem-sms_sim] .cbi-section-table-row.tr>.td,[data-page=admin-modem-qmodem-sms_sim] .cbi-section-table-row.tr>td{color:var(--text)}[data-page=admin-modem-qmodem-sms_sim] .cbi-section-table-row.tr:hover{background-color:var(--hover-faint)!important}[data-page=admin-modem-qmodem-sms_sim] .cbi-section-table-row.tr:hover>.td,[data-page=admin-modem-qmodem-sms_sim] .cbi-section-table-row.tr:hover>td{color:var(--text)!important;background-color:#0000!important}[data-page=admin-modem-qmodem-settings] .cbi-section-table-descr.named:before{content:var(--tw-content);background-color:var(--surface-sunken);content:var(--tw-content);--tw-content:""!important;content:var(--tw-content)!important}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} diff --git a/qosmate/Makefile b/qosmate/Makefile index dcca0634e..35c523b81 100644 --- a/qosmate/Makefile +++ b/qosmate/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=qosmate PKG_VERSION:=0.5.48 -PKG_RELEASE:=3 +PKG_RELEASE:=4 PKG_MAINTAINER:=Markus Hütter PKG_LICENSE:=GPL-3.0-or-later diff --git a/qosmate/README.md b/qosmate/README.md index 24cd23af7..70a9ecd48 100644 --- a/qosmate/README.md +++ b/qosmate/README.md @@ -377,6 +377,8 @@ logread | grep qosmate-autorate QoSmate allows you to define custom DSCP (Differentiated Services Code Point) marking rules to prioritize specific types of traffic. These rules are defined in the `/etc/config/qosmate` file under the `rule` sections and via luci-app-qosmate. +> **Rules are only evaluated in the upload direction.** QoSmate stores the DSCP of a connection in conntrack on egress, and `tc-ctinfo` restores it on incoming packets before the firewall sees them. WAN → LAN traffic therefore bypasses rule evaluation completely. One rule that matches the upload direction (for example the `src_ip` of your device) automatically covers the download direction of the same connection. A mirrored "inbound" rule is not needed - it can never match, and its counter stays at 0. + | Config option | Description | Type | Default | | ------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | ------- | | name | A unique name for the rule. Used for identification and logging. | string | | @@ -424,50 +426,33 @@ config rule option class 'cs1' option counter '1' ``` -Explanation: This rule assigns low priority to P2P traffic (like BitTorrent) by marking it as CS1. Using `list` for both `src_port` and `dest_port` covers both incoming and outgoing P2P traffic. +Explanation: This rule assigns low priority to P2P traffic (like BitTorrent) by marking it as CS1. Both port lists are matched on outgoing packets: `src_port` catches traffic sent from your local peer's listening port, `dest_port` catches traffic sent to a remote peer's listening port. 3. Call of Duty Game Traffic: ``` config rule - option name 'cod1' + option name 'cod' option proto 'udp' option src_ip '192.168.1.208' option src_port '3074' option dest_port '30000-65535' option class 'cs5' option counter '1' - -config rule - option name 'cod2' - option proto 'udp' - option dest_ip '192.168.1.208' - option dest_port '3074' - option class 'cs5' - option counter '1' ``` -Explanation: These rules prioritize Call of Duty game traffic. The first rule targets outgoing traffic from the game console (IP 192.168.1.208), while the second rule handles incoming traffic. Both use CS5 class, which is typically used for gaming traffic due to its high priority. The wide range of destination ports in the first rule covers the game's server ports. +Explanation: This rule prioritizes Call of Duty game traffic sent by the console (IP 192.168.1.208) from port 3074 to the game's server ports. It uses the CS5 class, which is typically used for gaming traffic due to its high priority. The incoming traffic of these connections is handled automatically via conntrack, so a second rule for the download direction is not required. 4. Generic Game Console/Gaming PC Traffic: ``` config rule - option name 'Game_Console_Outbound' + option name 'Game_Console' option proto 'udp' option src_ip '192.168.1.208' list dest_port '!=80' list dest_port '!=443' option class 'cs5' option counter '1' - -config rule - option name 'Game_Console_Inbound' - option proto 'udp' - option dest_ip '192.168.1.208' - list src_port '!=80' - list src_port '!=443' - option class 'cs5' - option counter '1' ``` -Explanation: These rules provide a more generic approach to prioritizing game console/gaming pc traffic. The outbound rule prioritizes all UDP traffic from the console (192.168.1.208) except for ports 80 and 443 (common web traffic). The inbound rule does the same for incoming traffic. This approach ensures that game-related traffic gets priority while allowing normal web browsing to use default priorities. The use of '!=' (not equal) in the port lists demonstrates how to exclude specific ports from the rule. +Explanation: This rule provides a more generic approach to prioritizing game console/gaming pc traffic. It prioritizes all UDP traffic from the console (192.168.1.208) except for ports 80 and 443 (common web traffic), which ensures that game-related traffic gets priority while allowing normal web browsing to use default priorities. The use of '!=' (not equal) in the port list demonstrates how to exclude specific ports from the rule. This is more or less equivalent to the `realtime4` and `realtime6` variables from the SimpleHFSCgamer script. However, this rule is even better as it excludes UDP port 80 and 443, which are often used for QUIC. This is likely less of an issue on a gaming console than on a gaming PC, where a YouTube video using QUIC might be running alongside the game. This rule is also applied when the auto-setup is used via CLI or UI and a Gaming Device IP (optional) is entered. @@ -1453,6 +1438,7 @@ QoSmate handles traffic in both directions (upload and download) using the follo - If the conditional bit (128) is set, the original DSCP value is restored: - DSCP is extracted as `mark & 63` - This DSCP value is applied to incoming packets + - Since the DSCP is already restored at this point, WAN → LAN packets skip the nftables marking chain, which is why rule counters only increase for the upload direction 3. **IFB (Intermediate Functional Block):** - Linux can only shape outgoing traffic diff --git a/qosmate/etc/config/qosmate b/qosmate/etc/config/qosmate index 411274ca1..6291c79a7 100644 --- a/qosmate/etc/config/qosmate +++ b/qosmate/etc/config/qosmate @@ -83,6 +83,10 @@ config autorate 'autorate' option flash_history '0' option log_changes '0' +# Rules are only evaluated on egress (LAN -> WAN): QoSmate stores the DSCP in conntrack +# and tc-ctinfo restores it on ingress. One rule matching the upload direction therefore +# covers both directions of a connection - a mirrored inbound rule would never match. + #config rule 'cod1' # option name 'cod1' # option proto 'udp' @@ -92,14 +96,6 @@ config autorate 'autorate' # option class 'cs5' # option counter '1' -#config rule 'cod2' -# option name 'cod3' -# option proto 'udp' -# option dest_ip '192.168.1.208' -# option dest_port '3074' -# option class 'cs5' -# option counter '1' - #config rule # option name 'TeamViewer' # option proto 'tcp' @@ -109,17 +105,11 @@ config autorate 'autorate' # option counter '1' #config rule -# option name 'ServerDownPrio1' +# option name 'ServerDownPrio' # option src_ip '192.168.15.0/24' # option class 'cs1' # option counter '1' -#config rule -# option name 'ServerDownPrio2' -# option dest_ip '192.168.15.0/24' -# option class 'cs1' -# option counter '1' - #config ipset # option name 'gaming_devices' # option mode 'static' diff --git a/qosmate/etc/init.d/qosmate b/qosmate/etc/init.d/qosmate index 9457fee2f..d83cd0220 100644 --- a/qosmate/etc/init.d/qosmate +++ b/qosmate/etc/init.d/qosmate @@ -9,12 +9,13 @@ USE_PROCD=1 VERSION="dev" UPD_CHANNEL="release" -EXTRA_COMMANDS="check_version update auto_setup auto_setup_noninteractive validate_custom_rules health_check" +EXTRA_COMMANDS="check_version update auto_setup auto_setup_noninteractive auto_setup_detached validate_custom_rules health_check" EXTRA_HELP=" check_version Check for updates update Update qosmate auto_setup Automatically configure qosmate auto_setup_noninteractive Automatically configure qosmate with no interaction + auto_setup_detached Start the non-interactive auto-setup in the background validate_custom_rules Validate custom rules health_check Check if QoSmate is properly configured and running" @@ -54,6 +55,7 @@ QOSMATE_DEFAULTS_FILE=${QOSMATE_D}/qosmate-defaults QOSMATE_CUSTOM_RULES_FILE=${QOSMATE_D}/custom_rules.nft QOSMATE_INLINE_RULES_FILE=${QOSMATE_D}/inline_dscptag.nft QOSMATE_RUN_DIR=/tmp/qosmate +QOSMATE_AUTO_SETUP_OUTPUT=/tmp/qosmate_auto_setup_output.txt # Local path of JS files QOSMATE_UPD_DIR=/var/run/qosmate-update @@ -197,7 +199,7 @@ print_msg() { # logs each argument separately and prints to a separate line # optional arguments: '-err', '-warn' to set logged error level log_msg() { - local msgs_prefix='' _arg err_l=info msgs_dest + local msgs_prefix='' _arg err_l=info local IFS="$DEFAULT_IFS" for _arg in "$@" @@ -207,11 +209,12 @@ log_msg() { "-warn") err_l=warn msgs_prefix="Warning: " ;; '') printf '\n' ;; # print out empty lines *) + # Write to the inherited descriptors instead of redirecting to /dev/stdout: that + # re-opens the target, and when stdout is a regular file '>' truncates it. case "$err_l" in - err|warn) msgs_dest="/dev/stderr" ;; - *) msgs_dest="/dev/stdout" + err|warn) printf '%s\n' "${msgs_prefix}${_arg}" >&2 ;; + *) printf '%s\n' "${msgs_prefix}${_arg}" esac - printf '%s\n' "${msgs_prefix}${_arg}" > "$msgs_dest" logger -t qosmate -p user."$err_l" "${msgs_prefix}${_arg}" msgs_prefix='' esac @@ -2218,6 +2221,48 @@ parse_speedtest_output() { : } +# Prints WAN throughput in Mbit/s once per second as " ", until killed. +# 1 - network device +sample_wan_rates() { + local stats="/sys/class/net/${1}/statistics" prev_rx prev_tx rx tx + read -r prev_rx < "$stats/rx_bytes" && read -r prev_tx < "$stats/tx_bytes" || return 1 + while sleep 1; do + read -r rx < "$stats/rx_bytes" && read -r tx < "$stats/tx_bytes" || return 1 + printf '%s %s\n' $(( (rx - prev_rx) * 8 / 1000000 )) $(( (tx - prev_tx) * 8 / 1000000 )) + prev_rx=$rx prev_tx=$tx + done +} + +# Median of the samples reaching at least half of the peak sample, which is the plateau of a test +# phase. Ignores ramp-up, idle time and the ACK traffic of the opposite direction. +# Prints 0 if there is no usable plateau. +# 1 - samples file, 2 - column (1 = rx, 2 = tx) +plateau_rate() { + [ -s "$1" ] || { echo "0"; return 0; } + awk -v col="$2" ' + { v[NR] = $col + 0; if (v[NR] > peak) peak = v[NR] } + END { + if (peak <= 0) { print 0; exit } + for (i = 1; i <= NR; i++) if (v[i] >= peak / 2) s[++n] = v[i] + if (n < 3) { print 0; exit } + for (i = 2; i <= n; i++) { x = s[i]; for (j = i - 1; j > 0 && s[j] > x; j--) s[j + 1] = s[j]; s[j + 1] = x } + print (n % 2) ? s[(n + 1) / 2] : int((s[n / 2] + s[n / 2 + 1]) / 2) + }' "$1" 2>/dev/null +} + +# 1, 2 - rates to compare +lower_of() { + awk -v a="$1" -v b="$2" 'BEGIN{print (a < b) ? a : b}' +} + +# Warns when the speed test result is implausibly higher than what crossed the interface. +# 1 - direction label, 2 - reported rate, 3 - measured rate +warn_if_overreported() { + awk -v r="$2" -v m="$3" 'BEGIN{exit !(m > 0 && r > m * 1.25)}' && + print_msg "Warning: the speed test reported $2 Mbit/s $1, but only $3 Mbit/s crossed the WAN interface. Using the measured value." + : +} + auto_setup() { try_auto_setup "$@" && return 0 error_out "auto-setup failed." @@ -2380,15 +2425,49 @@ try_auto_setup() { fi print_msg "Running speed test... This may take a few minutes." - SPEED_RESULT=$($SPEEDTEST_CMD) - DOWNLOAD_SPEED=$(printf %s "$SPEED_RESULT" | parse_speedtest_output "$SPEEDTEST_CMD" Download) && - UPLOAD_SPEED=$(printf %s "$SPEED_RESULT" | parse_speedtest_output "$SPEEDTEST_CMD" Upload) || - return 1 + local samples_file=/tmp/qosmate-speedtest-samples sampler_pid speedtest_rv retried='' + local reported_down reported_up measured_down measured_up + + while :; do + sample_wan_rates "$FINAL_INTERFACE" > "$samples_file" & + sampler_pid=$! + SPEED_RESULT=$($SPEEDTEST_CMD 2>&1) + speedtest_rv=$? + kill "$sampler_pid" 2>/dev/null + + [ "$speedtest_rv" = 0 ] && + DOWNLOAD_SPEED=$(printf %s "$SPEED_RESULT" | parse_speedtest_output "$SPEEDTEST_CMD" Download) && + UPLOAD_SPEED=$(printf %s "$SPEED_RESULT" | parse_speedtest_output "$SPEEDTEST_CMD" Upload) && + break + + [ -z "$retried" ] || { + # include the tool output, otherwise the failure is only reported generically + error_out "Speed test failed (exit code $speedtest_rv). Speed test output:" "$SPEED_RESULT" + return 1 + } + retried=1 + log_msg -warn "Speed test failed (exit code $speedtest_rv), retrying once." + done + + # A speed test measures at the socket, so it can report several times the rate that actually + # crossed the link (speedtest-go does this on upload). Cross-check with the WAN counters. + reported_down="$DOWNLOAD_SPEED" reported_up="$UPLOAD_SPEED" + measured_down=$(plateau_rate "$samples_file" 1) + measured_up=$(plateau_rate "$samples_file" 2) + rm -f "$samples_file" + + # Use the lower value: the test may over-report, the counters may include foreign traffic + [ "$measured_down" != 0 ] && DOWNLOAD_SPEED=$(lower_of "$reported_down" "$measured_down") + [ "$measured_up" != 0 ] && UPLOAD_SPEED=$(lower_of "$reported_up" "$measured_up") + + warn_if_overreported download "$reported_down" "$measured_down" + warn_if_overreported upload "$reported_up" "$measured_up" print_msg "Speed test results:" \ "Download speed: $DOWNLOAD_SPEED Mbit/s" \ - "Upload speed: $UPLOAD_SPEED Mbit/s" + "Upload speed: $UPLOAD_SPEED Mbit/s" \ + "WAN counters during the test: $measured_down Mbit/s down, $measured_up Mbit/s up (speed test: $reported_down / $reported_up)" fi # Convert speeds to kbps and apply 90% rule @@ -2419,43 +2498,29 @@ try_auto_setup() { fi fi - if [ -n "$gaming_ip" ]; then - # Validate IP address format - if [[ $gaming_ip =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then - # Check if rules for this IP already exist - local ip_configured= - config_foreach is_ip_configured rule "$gaming_ip" - # $ip_configured is set in is_ip_configured() - if [ -n "$ip_configured" ]; then - print_msg "Rules for IP $gaming_ip already exist. Skipping addition of new rules." - gaming_ip= - fi + # Exactly one outcome is reported, so the UI can always tell the user what happened + local ip_configured= + if [ -z "$gaming_ip" ]; then + print_msg "No gaming device IP given - no rule added." + elif [[ ! "$gaming_ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then + print_msg "Invalid IP address format '$gaming_ip' - no gaming device rule added." + else + config_foreach is_ip_configured rule "$gaming_ip" # sets $ip_configured + if [ -n "$ip_configured" ]; then + print_msg "A rule for IP $gaming_ip already exists - keeping it." else - print_msg "Invalid IP address format. No gaming device rules added." - gaming_ip= - fi - fi - - if [ -n "$gaming_ip" ]; then - local direction ip_dir port_dir - for direction in Inbound Outbound; do - case "$direction" in - Inbound) ip_dir=dest port_dir=src ;; - Outbound) ip_dir=src port_dir=dest ;; - esac + # Only an egress rule is needed: the DSCP is stored in conntrack and restored on ingress via tc-ctinfo uci add qosmate rule 1>/dev/null && - uci set "qosmate.@rule[-1].name=Game_Console_${direction}" && + uci set "qosmate.@rule[-1].name=Game_Console" && uci set "qosmate.@rule[-1].proto=udp" && - uci set "qosmate.@rule[-1].${ip_dir}_ip=${gaming_ip}" && - uci add_list "qosmate.@rule[-1].${port_dir}_port=!=80" && - uci add_list "qosmate.@rule[-1].${port_dir}_port=!=443" && + uci set "qosmate.@rule[-1].src_ip=${gaming_ip}" && + uci add_list "qosmate.@rule[-1].dest_port=!=80" && + uci add_list "qosmate.@rule[-1].dest_port=!=443" && uci set "qosmate.@rule[-1].class=cs5" && uci set "qosmate.@rule[-1].counter=1" || return 1 - done - print_msg "Gaming device rules added for IP: $gaming_ip" - else - print_msg "No gaming device IP added." + print_msg "Gaming device rule added for IP: $gaming_ip" + fi fi uci commit qosmate || return 1 @@ -2470,16 +2535,41 @@ try_auto_setup() { } auto_setup_noninteractive() { - local output_file="/tmp/qosmate_auto_setup_output.txt" auto_setup_rv + local auto_setup_rv + # Truncate up front and append afterwards. Truncating while a writer already holds an offset + # leaves a gap that the kernel fills with NUL bytes, and ubus file.read stops at the first NUL, + # so a polling caller would only ever see the beginning of the file. + : > "$QOSMATE_AUTO_SETUP_OUTPUT" { echo "Starting qosmate non-interactive auto-setup..." auto_setup -n "$1" 2>&1 - } > "$output_file" - auto_setup_rv=$? - echo "$output_file" + auto_setup_rv=$? + # lets callers polling this file tell a finished run from a partial or stale one + echo "AUTO_SETUP_DONE rv=$auto_setup_rv" + } >> "$QOSMATE_AUTO_SETUP_OUTPUT" + echo "$QOSMATE_AUTO_SETUP_OUTPUT" return $auto_setup_rv } +# Starts the non-interactive auto-setup detached and prints the output file path right away. +# A speed test regularly takes longer than uhttpd's script_timeout (60s by default), which kills +# the CGI and leaves the caller with a half-written file, so the caller polls the file instead. +# 1 - optional gaming device IP +auto_setup_detached() { + printf '%s\n' "Starting qosmate auto-setup..." > "$QOSMATE_AUTO_SETUP_OUTPUT" || return 1 + # A new session is required, not just a background job: ash waits for children of the same + # session in a command substitution, which is how callers read this function's output. + if check_util setsid; then + # stdin is closed as well, otherwise the run keeps the caller's pipe open - for a request + # from the web UI that blocks the connection the UI then polls on + setsid "$QOSMATE_SERVICE_PATH" auto_setup_noninteractive "$1" >/dev/null 2>&1 <&- & + else + log_msg -warn "setsid is not available, running the auto-setup in the foreground." + auto_setup_noninteractive "$1" >/dev/null 2>&1 + fi + echo "$QOSMATE_AUTO_SETUP_OUTPUT" +} + health_check() { # Check if QoSmate is properly configured and running local status="" errors=0 service_enabled=0 config_status='' diff --git a/qosmate/etc/qosmate.sh b/qosmate/etc/qosmate.sh index fa1ea14dd..a60576695 100644 --- a/qosmate/etc/qosmate.sh +++ b/qosmate/etc/qosmate.sh @@ -25,15 +25,21 @@ error_out() { log_msg -err "${@}"; } # prints each argument to a separate line print_msg() { - local _arg msgs_dest="/dev/stdout" msgs_prefix='' + # Write to the inherited descriptors instead of redirecting to /dev/stdout: that re-opens the + # target, and when stdout is a regular file '>' truncates it. + local _arg to_stderr='' msgs_prefix='' for _arg in "$@" do case "${_arg}" in - -err) msgs_dest="/dev/stderr" msgs_prefix="Error: " ;; - -warn) msgs_dest="/dev/stderr" msgs_prefix="Warning: " ;; + -err) to_stderr=1 msgs_prefix="Error: " ;; + -warn) to_stderr=1 msgs_prefix="Warning: " ;; '') printf '\n' ;; # print out empty lines *) - printf '%s\n' "${msgs_prefix}${_arg}" > "$msgs_dest" + if [ -n "$to_stderr" ]; then + printf '%s\n' "${msgs_prefix}${_arg}" >&2 + else + printf '%s\n' "${msgs_prefix}${_arg}" + fi msgs_prefix='' esac done @@ -43,7 +49,7 @@ print_msg() { # logs each argument separately and prints to a separate line # optional arguments: '-err', '-warn' to set logged error level log_msg() { - local msgs_prefix='' _arg err_l=info msgs_dest + local msgs_prefix='' _arg err_l=info local IFS="$DEFAULT_IFS" for _arg in "$@" @@ -54,10 +60,9 @@ log_msg() { '') printf '\n' ;; # print out empty lines *) case "$err_l" in - err|warn) msgs_dest="/dev/stderr" ;; - *) msgs_dest="/dev/stdout" + err|warn) printf '%s\n' "${msgs_prefix}${_arg}" >&2 ;; + *) printf '%s\n' "${msgs_prefix}${_arg}" esac - printf '%s\n' "${msgs_prefix}${_arg}" > "$msgs_dest" logger -t qosmate -p user."$err_l" "${msgs_prefix}${_arg}" msgs_prefix='' esac