diff --git a/baidudrive/Makefile b/baidudrive/Makefile index 905434bd..795aaf7c 100644 --- a/baidudrive/Makefile +++ b/baidudrive/Makefile @@ -10,8 +10,8 @@ include $(TOPDIR)/rules.mk PKG_ARCH_BAIDUDRIVE:=$(ARCH) PKG_NAME:=baidudrive -PKG_VERSION:=linkeasefull-runtime-v3.0.6 -PKG_RELEASE:=9 +PKG_VERSION:=linkeasefull-runtime-v3.0.8 +PKG_RELEASE:=10 PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/baidudrive-runtime-v$(PKG_VERSION)/ PKG_HASH:=skip diff --git a/linkease-common-bin/Makefile b/linkease-common-bin/Makefile index b8ef44e7..1ad5cc5f 100644 --- a/linkease-common-bin/Makefile +++ b/linkease-common-bin/Makefile @@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH) PKG_NAME:=linkease-common-bin # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable PKG_SOURCE_DATE:=1.7.5 -PKG_RELEASE:=5 +PKG_RELEASE:=6 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) @@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel) ARCH_HEXCODE=1b0c endif -PKG_SOURCE_VERSION:=9f351503d4320bb3767c300bc677a85c5685c9ba +PKG_SOURCE_VERSION:=78c1b63d4be857054a8e6e37bc22eb235e3f151e PKG_SOURCE:=linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/ PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE) diff --git a/linkease/Makefile b/linkease/Makefile index 574545ca..27e5e869 100644 --- a/linkease/Makefile +++ b/linkease/Makefile @@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH) PKG_NAME:=linkease # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable PKG_SOURCE_DATE:=1.7.5 -PKG_RELEASE:=8 +PKG_RELEASE:=9 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) @@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel) ARCH_HEXCODE=1b0c endif -PKG_SOURCE_VERSION:=9f351503d4320bb3767c300bc677a85c5685c9ba +PKG_SOURCE_VERSION:=78c1b63d4be857054a8e6e37bc22eb235e3f151e PKG_SOURCE:=linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/ PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE) diff --git a/linkeasefull/Makefile b/linkeasefull/Makefile index 4bfff1f8..c0ff24b6 100644 --- a/linkeasefull/Makefile +++ b/linkeasefull/Makefile @@ -11,7 +11,7 @@ PKG_ARCH_LINKEASE:=$(ARCH) PKG_NAME:=linkeasefull # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable PKG_SOURCE_DATE:=3.0.5 -PKG_RELEASE:=7 +PKG_RELEASE:=8 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) @@ -24,7 +24,7 @@ LINKEASE_RUNTIME_ARCH:=arm64 PKG_HASH:=skip endif -PKG_SOURCE_VERSION:=9f351503d4320bb3767c300bc677a85c5685c9ba +PKG_SOURCE_VERSION:=78c1b63d4be857054a8e6e37bc22eb235e3f151e PKG_SOURCE:=linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkeasefull-runtime-v$(PKG_SOURCE_DATE)/ PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH) diff --git a/luci-app-bandix/Makefile b/luci-app-bandix/Makefile index 8f404653..4314775c 100644 --- a/luci-app-bandix/Makefile +++ b/luci-app-bandix/Makefile @@ -10,8 +10,8 @@ LUCI_DEPENDS:=+luci-base +luci-lib-jsonc +curl +bandix PKG_MAINTAINER:=timsaya -PKG_VERSION:=0.12.8 -PKG_RELEASE:=6 +PKG_VERSION:=0.12.9 +PKG_RELEASE:=7 include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci-app-bandix/htdocs/luci-static/resources/bandix/donate/alipay.jpg b/luci-app-bandix/htdocs/luci-static/resources/bandix/donate/alipay.jpg new file mode 100644 index 00000000..5069c8e2 Binary files /dev/null and b/luci-app-bandix/htdocs/luci-static/resources/bandix/donate/alipay.jpg differ diff --git a/luci-app-bandix/htdocs/luci-static/resources/bandix/donate/wechat-pay.png b/luci-app-bandix/htdocs/luci-static/resources/bandix/donate/wechat-pay.png new file mode 100644 index 00000000..f6b96a96 Binary files /dev/null and b/luci-app-bandix/htdocs/luci-static/resources/bandix/donate/wechat-pay.png differ diff --git a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js index 251b0855..e8a6f7a5 100644 --- a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js +++ b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js @@ -9,6 +9,11 @@ var BANDIX_COLOR_UPLOAD = '#f97316'; // 橙色 - 上传/上行 var BANDIX_COLOR_DOWNLOAD = '#06b6d4'; // 青色 - 下载/下行 +// 远程广告配置 +var BANDIX_AD_CONFIG_URL = 'https://raw.githubusercontent.com/timsaya/bandix-ads/main/ads.json'; +var BANDIX_AD_DEFAULT_REFRESH_INTERVAL = 3600; +var BANDIX_AD_RETRY_INTERVAL = 900; + // 悬浮框背景色定义 var BANDIX_TOOLTIP_BG_OPENWRT2020_LIGHT = '#ffffff'; // OpenWrt 2020 浅色主题悬浮框背景 var BANDIX_TOOLTIP_BG_OPENWRT2020_DARK = '#2a2a2a'; // OpenWrt 2020 深色主题悬浮框背景 @@ -556,6 +561,117 @@ return view.extend({ background-color: rgba(239, 68, 68, 0.2); transform: translateY(-1px); } + + .bandix-ad-row { + display: grid; + grid-template-columns: repeat(var(--bandix-ad-count, 6), minmax(112px, 1fr)); + gap: 8px; + margin: 12px 0; + overflow-x: auto; + scrollbar-width: thin; + } + + .bandix-ad-slot { + flex: 1 1 0; + min-width: 112px; + padding: 0; + aspect-ratio: 3 / 1; + border: 1px solid rgba(107, 114, 128, 0.35); + border-radius: 6px; + background: linear-gradient(135deg, rgba(225, 37, 27, 0.08), rgba(107, 114, 128, 0.04)); + display: flex; + align-items: center; + color: inherit; + box-sizing: border-box; + overflow: hidden; + position: relative; + text-decoration: none; + transition: border-color 0.2s ease, transform 0.2s ease; + } + + .bandix-ad-slot:not(.bandix-ad-disabled):hover, + .bandix-ad-slot:not(.bandix-ad-disabled):focus-visible { + border-color: rgba(225, 37, 27, 0.75); + box-shadow: 0 4px 12px rgba(225, 37, 27, 0.12); + transform: translateY(-2px); + } + + .bandix-ad-disabled { + cursor: default; + } + + .bandix-ad-label { + align-self: flex-start; + padding: 1px 5px; + border: none; + border-radius: 3px; + background: rgba(225, 37, 27, 0.88); + color: #ffffff; + font-size: 0.5625rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.4; + } + + .bandix-ad-copy { + align-self: stretch; + flex: 1 1 auto; + min-width: 0; + padding: 6px 4px 6px 8px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 3px; + box-sizing: border-box; + } + + .bandix-ad-title { + width: 100%; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.25; + text-align: left; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .bandix-ad-media { + align-self: stretch; + flex: 0 0 33.333333%; + min-width: 0; + aspect-ratio: 1 / 1; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.92); + } + + .bandix-ad-image { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + } + + .bandix-ad-placeholder { + flex: 0 0 33.333333%; + aspect-ratio: 1 / 1; + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + padding: 3px; + background: rgba(255, 255, 255, 0.92); + color: #e1251b; + font-size: 0.625rem; + font-weight: 700; + line-height: 1.2; + text-align: center; + box-sizing: border-box; + } /* 移动端隐藏版本信息和更新徽章 */ @@ -563,6 +679,26 @@ return view.extend({ .bandix-version-wrapper { display: none; } + + .bandix-ad-row { + display: flex; + gap: 6px; + padding-bottom: 4px; + } + + .bandix-ad-slot { + flex: 0 0 180px; + min-width: 180px; + height: 60px; + } + + .bandix-ad-copy { + padding-left: 7px; + } + + .bandix-ad-title { + font-size: 0.625rem; + } } .device-mode-group { @@ -2820,6 +2956,25 @@ return view.extend({ ]) ]), + // 广告数据从公开仓库动态加载 + E('div', { + 'class': 'bandix-ad-row', + 'id': 'bandix-ad-row', + 'aria-label': _('Advertising spaces') + }, [1, 2, 3, 4, 5, 6].map(function (index) { + return E('div', { + 'class': 'bandix-ad-slot bandix-ad-disabled', + 'data-ad-slot': String(index), + 'aria-disabled': 'true' + }, [ + E('div', { 'class': 'bandix-ad-copy' }, [ + E('span', { 'class': 'bandix-ad-label' }, _('AD')), + E('span', { 'class': 'bandix-ad-title' }, _('Advertising space') + ' ' + index) + ]), + E('span', { 'class': 'bandix-ad-placeholder' }, _('Advertising space')) + ]); + })), + // 警告提示(包含在线设备数) E('div', { 'class': 'bandix-alert' + (getThemeType() === 'wide' ? ' wide-theme' : '') @@ -2975,7 +3130,8 @@ return view.extend({ ]) ]), E('div', { 'class': 'usage-ranking-query-presets' }, [ - E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': 'today' }, _('Today')), + E('button', { 'class': 'cbi-button cbi-button-positive', 'data-preset': 'today' }, _('Today')), + E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': 'yesterday' }, _('Yesterday')), E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': 'thisweek' }, _('This Week')), E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': 'lastweek' }, _('Last Week')), E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': 'thismonth' }, _('This Month')), @@ -2983,7 +3139,7 @@ return view.extend({ E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': '7days' }, _('Last 7 Days')), E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': '30days' }, _('Last 30 Days')), E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': '90days' }, _('Last 90 Days')), - E('button', { 'class': 'cbi-button cbi-button-positive', 'data-preset': '1year' }, _('Last Year')) + E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': '1year' }, _('Last Year')) ]), E('div', { 'class': 'usage-ranking-timeline', 'id': 'usage-ranking-timeline' }, [ E('div', { 'class': 'usage-ranking-timeline-range', 'id': 'usage-ranking-timeline-range' }) @@ -3047,7 +3203,8 @@ return view.extend({ ]) ]), E('div', { 'class': 'usage-ranking-query-presets' }, [ - E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': 'today' }, _('Today')), + E('button', { 'class': 'cbi-button cbi-button-positive', 'data-preset': 'today' }, _('Today')), + E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': 'yesterday' }, _('Yesterday')), E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': 'thisweek' }, _('This Week')), E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': 'lastweek' }, _('Last Week')), E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': 'thismonth' }, _('This Month')), @@ -3055,7 +3212,7 @@ return view.extend({ E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': '7days' }, _('Last 7 Days')), E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': '30days' }, _('Last 30 Days')), E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': '90days' }, _('Last 90 Days')), - E('button', { 'class': 'cbi-button cbi-button-positive', 'data-preset': '1year' }, _('Last Year')) + E('button', { 'class': 'cbi-button cbi-button-neutral', 'data-preset': '1year' }, _('Last Year')) ]), E('div', { 'class': 'usage-ranking-timeline', 'id': 'traffic-increments-timeline' }, [ E('div', { 'class': 'usage-ranking-timeline-range', 'id': 'traffic-increments-timeline-range' }) @@ -7504,7 +7661,6 @@ return view.extend({ updateDeviceData(); refreshWhitelistStatus(); fetchAllScheduleRules(); - updateTrafficStatistics(); // 初始化时间范围查询功能 setTimeout(function () { @@ -7631,6 +7787,12 @@ return view.extend({ startDate = new Date(today); endDate = new Date(today); break; + case 'yesterday': + var yesterdayDate = new Date(today); + yesterdayDate.setDate(yesterdayDate.getDate() - 1); + startDate = new Date(yesterdayDate); + endDate = new Date(yesterdayDate); + break; case 'thisweek': // 本周:周一到今天 var dayOfWeek = today.getDay(); // 0=周日, 1=周一, ..., 6=周六 @@ -7724,18 +7886,16 @@ return view.extend({ // 重置按钮 if (resetBtn) { resetBtn.addEventListener('click', function () { - var oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()); - setDateRange(oneYearAgo, today, '1year'); + setDateRange(today, today, 'today'); queryData(); }); } - // 初始化:默认选择最近一年 - var oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()); - setDateRange(oneYearAgo, today, '1year'); + // 初始化:默认选择今天 + setDateRange(today, today, 'today'); // 设置初始时间范围并自动加载数据(结束时间为当天的 23:59:59) - var startMs = oneYearAgo.getTime(); + var startMs = todayMs; var endToday = new Date(today); endToday.setHours(23, 59, 59, 999); var endMs = endToday.getTime(); @@ -7896,6 +8056,12 @@ return view.extend({ startDate = new Date(today); endDate = new Date(today); break; + case 'yesterday': + var yesterdayDate = new Date(today); + yesterdayDate.setDate(yesterdayDate.getDate() - 1); + startDate = new Date(yesterdayDate); + endDate = new Date(yesterdayDate); + break; case 'thisweek': var dayOfWeek = today.getDay(); var mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; @@ -7952,15 +8118,13 @@ return view.extend({ // 重置按钮 if (resetBtn) { resetBtn.addEventListener('click', function () { - var oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()); - setDateRange(oneYearAgo, today, '1year'); + setDateRange(today, today, 'today'); queryData(); }); } - // 初始化:默认选择最近一年 - var oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()); - setDateRange(oneYearAgo, today, '1year'); + // 初始化:默认选择今天 + setDateRange(today, today, 'today'); startDateInput.addEventListener('change', function () { updateTimeline(this.value, endDateInput.value); @@ -7971,7 +8135,7 @@ return view.extend({ }); // 设置初始时间范围并自动加载数据(不显示 loading) - var startMs = oneYearAgo.getTime(); + var startMs = todayMs; var endToday = new Date(today); endToday.setHours(23, 59, 59, 999); var endMs = endToday.getTime(); @@ -7983,6 +8147,205 @@ return view.extend({ updateTrafficIncrements(startMs, endMs, null, null); }, 700); + // 异步加载远程广告(不阻塞主流程) + (function () { + var refreshTimer = null; + + function resolveHttpsUrl(value, baseUrl) { + if (typeof value !== 'string' || !value) return null; + + try { + var resolved = new URL(value, baseUrl); + return resolved.protocol === 'https:' ? resolved.href : null; + } catch (e) { + return null; + } + } + + function scheduleRefresh(seconds) { + var interval = Number(seconds); + if (!Number.isFinite(interval)) interval = BANDIX_AD_DEFAULT_REFRESH_INTERVAL; + interval = Math.max(300, Math.min(86400, interval)); + + if (refreshTimer !== null) window.clearTimeout(refreshTimer); + refreshTimer = window.setTimeout(loadAdvertisements, interval * 1000); + } + + function normalizeAdvertisementLocale(value) { + if (typeof value !== 'string') return ''; + + var locale = value.trim().replace(/_/g, '-').toLowerCase(); + if (!locale || locale === 'auto' || locale === 'und') return ''; + + if (locale === 'zh' || locale === 'zh-cn' || locale === 'zh-sg' || locale === 'zh-hans') { + return 'zh-hans'; + } + if (locale === 'zh-tw' || locale === 'zh-hk' || locale === 'zh-mo' || locale === 'zh-hant') { + return 'zh-hant'; + } + + return locale; + } + + function getAdvertisementLocale() { + var candidates = []; + if (L.env) candidates.push(L.env.lang || L.env.language || ''); + var htmlLanguage = document.documentElement.getAttribute('lang'); + if (htmlLanguage) candidates.push(htmlLanguage); + if (window.navigator) candidates.push(window.navigator.language || ''); + + for (var i = 0; i < candidates.length; i++) { + var locale = normalizeAdvertisementLocale(candidates[i]); + if (locale) return locale; + } + + return ''; + } + + function selectLocalizedAdvertisements(config) { + // 兼容旧版单语言配置,方便两个仓库分开部署 + if (config.version === 1 && Array.isArray(config.ads)) { + return { locale: 'legacy', ads: config.ads }; + } + + if (config.version !== 2 || !config.locales || typeof config.locales !== 'object') { + throw new Error('Invalid advertising configuration'); + } + + var currentLocale = getAdvertisementLocale(); + var defaultLocale = normalizeAdvertisementLocale(config.default_locale); + var localeCandidates = []; + + if (currentLocale) { + localeCandidates.push(currentLocale); + var separatorIndex = currentLocale.indexOf('-'); + if (separatorIndex > 0) localeCandidates.push(currentLocale.substring(0, separatorIndex)); + } + if (defaultLocale) localeCandidates.push(defaultLocale); + + for (var i = 0; i < localeCandidates.length; i++) { + var candidate = localeCandidates[i]; + if (Object.prototype.hasOwnProperty.call(config.locales, candidate) && + Array.isArray(config.locales[candidate])) { + return { locale: candidate, ads: config.locales[candidate] }; + } + } + + throw new Error('No advertising locale is available'); + } + + function renderAdvertisements(config) { + var adRow = document.getElementById('bandix-ad-row'); + if (!adRow || !config) { + throw new Error('Invalid advertising configuration'); + } + + var selection = selectLocalizedAdvertisements(config); + adRow.setAttribute('data-ad-locale', selection.locale); + + var ads = selection.ads.slice(0, 6).map(function (ad, index) { + if (!ad || typeof ad !== 'object') ad = {}; + + var href = resolveHttpsUrl(ad.href, BANDIX_AD_CONFIG_URL); + var image = resolveHttpsUrl(ad.image, BANDIX_AD_CONFIG_URL); + var title = typeof ad.title === 'string' ? ad.title.trim() : _('Advertising space') + ' ' + (index + 1); + if (!title) title = _('Advertising space') + ' ' + (index + 1); + var alt = typeof ad.alt === 'string' ? ad.alt.trim() : title; + + return { + enabled: ad.enabled === true && href !== null, + href: href, + image: image, + title: title, + alt: alt || title + }; + }); + + while (ads.length < 6) { + ads.push({ + enabled: false, + href: null, + image: null, + title: _('Advertising space') + ' ' + (ads.length + 1), + alt: _('Advertising space') + }); + } + + while (adRow.firstChild) adRow.removeChild(adRow.firstChild); + + ads.forEach(function (ad, index) { + var tagName = ad.enabled ? 'a' : 'div'; + var attributes = { + 'class': 'bandix-ad-slot' + (ad.enabled ? '' : ' bandix-ad-disabled'), + 'data-ad-slot': String(index + 1), + 'title': ad.title + }; + + if (ad.enabled) { + attributes.href = ad.href; + attributes.target = '_blank'; + attributes.rel = 'noopener noreferrer sponsored'; + } else { + attributes['aria-disabled'] = 'true'; + } + + var content = [E('div', { 'class': 'bandix-ad-copy' }, [ + E('span', { 'class': 'bandix-ad-label' }, _('AD')), + E('span', { 'class': 'bandix-ad-title' }, ad.title) + ])]; + if (ad.enabled && ad.image) { + content.push(E('div', { 'class': 'bandix-ad-media' }, [ + E('img', { + 'class': 'bandix-ad-image', + 'src': ad.image, + 'alt': ad.alt, + 'loading': 'lazy', + 'referrerpolicy': 'no-referrer' + }) + ])); + } else { + content.push(E('span', { 'class': 'bandix-ad-placeholder' }, _('Advertising space'))); + } + + adRow.appendChild(E(tagName, attributes, content)); + }); + + adRow.style.setProperty('--bandix-ad-count', '6'); + } + + function loadAdvertisements() { + var adRow = document.getElementById('bandix-ad-row'); + if (!adRow) return; + + if (typeof window.fetch !== 'function') { + console.debug('Failed to load advertisements: Fetch API is unavailable'); + scheduleRefresh(BANDIX_AD_RETRY_INTERVAL); + return; + } + + var separator = BANDIX_AD_CONFIG_URL.indexOf('?') === -1 ? '?' : '&'; + var configUrl = BANDIX_AD_CONFIG_URL + separator + '_=' + Date.now(); + + window.fetch(configUrl, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + cache: 'no-store' + }).then(function (response) { + if (!response.ok) throw new Error('HTTP ' + response.status); + return response.json(); + }).then(function (config) { + renderAdvertisements(config); + scheduleRefresh(config.refresh_interval); + }).catch(function (err) { + console.debug('Failed to load advertisements:', err); + scheduleRefresh(BANDIX_AD_RETRY_INTERVAL); + }); + } + + window.setTimeout(loadAdvertisements, 200); + })(); + // 异步加载版本信息(不阻塞主流程) (function () { // 延迟执行,确保页面先完成初始化 diff --git a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js index 70659560..96ab5556 100644 --- a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js +++ b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js @@ -673,6 +673,46 @@ return view.extend({ return false; }; + // 支持作者(基本设置的最后一项) + var alipayImage = L.resource('bandix/donate/alipay.jpg'); + var wechatImage = L.resource('bandix/donate/wechat-pay.png'); + + function donationCard(title, imageUrl, altText, dotClass) { + return E('div', { 'class': 'bandix-donation-card' }, [ + E('h4', { 'class': 'bandix-donation-card-title' }, [ + E('span', { 'class': 'bandix-donation-dot ' + dotClass }), + title + ]), + E('a', { + 'class': 'bandix-donation-image-link', + 'href': imageUrl, + 'target': '_blank', + 'rel': 'noopener noreferrer', + 'title': altText + }, [ + E('img', { + 'class': 'bandix-donation-image', + 'src': imageUrl, + 'alt': altText, + 'loading': 'lazy' + }) + ]) + ]); + } + + o = s.option(form.DummyValue, '_donation_qr_codes', _('Support the Author')); + o.renderWidget = function () { + return E('div', { 'class': 'bandix-donation-content' }, [ + E('p', { 'class': 'bandix-donation-description' }, + _('If Bandix is useful to you, you can support its continued development.')), + E('div', { 'class': 'bandix-donation-grid' }, [ + donationCard(_('Alipay'), alipayImage, _('Scan with Alipay to support'), 'bandix-donation-dot-alipay'), + donationCard(_('WeChat Pay'), wechatImage, _('Scan with WeChat Pay to support'), 'bandix-donation-dot-wechat') + ]), + E('p', { 'class': 'bandix-donation-note' }, _('Donations are voluntary. Thank you for supporting Bandix.')) + ]); + }; + // 2. 流量监控设置部分 (traffic) s = m.section(form.NamedSection, 'traffic', 'traffic', _('Traffic Monitor Settings')); s.description = _('Configure traffic monitoring related parameters'); @@ -826,6 +866,98 @@ return view.extend({ // 将 view 实例关联到 form map,以便在 cfgvalue 中访问 m.view = this; - return m.render(); + return m.render().then(function (mapEl) { + var oldStyle = document.getElementById('bandix-donation-styles'); + if (oldStyle && oldStyle.parentNode) oldStyle.parentNode.removeChild(oldStyle); + + document.head.appendChild(E('style', { 'id': 'bandix-donation-styles' }, ` + .bandix-donation-content { + text-align: center; + } + + .bandix-donation-description { + margin: 0 auto 18px; + max-width: 620px; + opacity: 0.72; + line-height: 1.6; + } + + .bandix-donation-grid { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 280px)); + justify-content: center; + gap: 20px; + } + + .bandix-donation-card { + padding: 12px; + border: 1px solid rgba(107, 114, 128, 0.3); + border-radius: 10px; + background: rgba(107, 114, 128, 0.04); + box-sizing: border-box; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + } + + .bandix-donation-card:hover { + border-color: rgba(59, 130, 246, 0.55); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1); + transform: translateY(-2px); + } + + .bandix-donation-card-title { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + margin: 0 0 10px; + font-size: 1rem; + font-weight: 600; + } + + .bandix-donation-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; + } + + .bandix-donation-dot-alipay { + background: #1677ff; + } + + .bandix-donation-dot-wechat { + background: #07c160; + } + + .bandix-donation-image-link { + display: block; + border-radius: 7px; + overflow: hidden; + background: #ffffff; + line-height: 0; + } + + .bandix-donation-image { + display: block; + width: 100%; + height: auto; + } + + .bandix-donation-note { + margin: 14px 0 0; + font-size: 0.75rem; + opacity: 0.58; + } + + @media (max-width: 600px) { + .bandix-donation-grid { + grid-template-columns: minmax(200px, 280px); + gap: 14px; + } + } + `)); + + return mapEl; + }); } }); diff --git a/luci-app-bandix/po/es/bandix.po b/luci-app-bandix/po/es/bandix.po index 7160b6ee..9a12acca 100644 --- a/luci-app-bandix/po/es/bandix.po +++ b/luci-app-bandix/po/es/bandix.po @@ -1112,6 +1112,30 @@ msgstr "Restablecer" msgid "Today" msgstr "Hoy" +msgid "Yesterday" +msgstr "Ayer" + +msgid "Support the Author" +msgstr "Apoyar al autor" + +msgid "If Bandix is useful to you, you can support its continued development." +msgstr "Si Bandix te resulta útil, puedes apoyar su desarrollo continuo." + +msgid "Alipay" +msgstr "Alipay" + +msgid "Scan with Alipay to support" +msgstr "Escanea con Alipay para apoyar" + +msgid "WeChat Pay" +msgstr "WeChat Pay" + +msgid "Scan with WeChat Pay to support" +msgstr "Escanea con WeChat Pay para apoyar" + +msgid "Donations are voluntary. Thank you for supporting Bandix." +msgstr "Las donaciones son voluntarias. Gracias por apoyar a Bandix." + msgid "This Week" msgstr "Esta Semana" diff --git a/luci-app-bandix/po/fr/bandix.po b/luci-app-bandix/po/fr/bandix.po index 42e378f3..a32ab4cf 100644 --- a/luci-app-bandix/po/fr/bandix.po +++ b/luci-app-bandix/po/fr/bandix.po @@ -1112,6 +1112,30 @@ msgstr "Réinitialiser" msgid "Today" msgstr "Aujourd'hui" +msgid "Yesterday" +msgstr "Hier" + +msgid "Support the Author" +msgstr "Soutenir l’auteur" + +msgid "If Bandix is useful to you, you can support its continued development." +msgstr "Si Bandix vous est utile, vous pouvez soutenir la poursuite de son développement." + +msgid "Alipay" +msgstr "Alipay" + +msgid "Scan with Alipay to support" +msgstr "Scannez avec Alipay pour nous soutenir" + +msgid "WeChat Pay" +msgstr "WeChat Pay" + +msgid "Scan with WeChat Pay to support" +msgstr "Scannez avec WeChat Pay pour nous soutenir" + +msgid "Donations are voluntary. Thank you for supporting Bandix." +msgstr "Les dons sont facultatifs. Merci de soutenir Bandix." + msgid "This Week" msgstr "Cette Semaine" diff --git a/luci-app-bandix/po/ja/bandix.po b/luci-app-bandix/po/ja/bandix.po index bc18fdf3..a27fc1d2 100644 --- a/luci-app-bandix/po/ja/bandix.po +++ b/luci-app-bandix/po/ja/bandix.po @@ -1112,6 +1112,30 @@ msgstr "リセット" msgid "Today" msgstr "今日" +msgid "Yesterday" +msgstr "昨日" + +msgid "Support the Author" +msgstr "作者を支援" + +msgid "If Bandix is useful to you, you can support its continued development." +msgstr "Bandix が役に立った場合は、継続的な開発をご支援いただけます。" + +msgid "Alipay" +msgstr "Alipay" + +msgid "Scan with Alipay to support" +msgstr "Alipay でスキャンして支援" + +msgid "WeChat Pay" +msgstr "WeChat Pay" + +msgid "Scan with WeChat Pay to support" +msgstr "WeChat Pay でスキャンして支援" + +msgid "Donations are voluntary. Thank you for supporting Bandix." +msgstr "寄付は任意です。Bandix をご支援いただきありがとうございます。" + msgid "This Week" msgstr "今週" diff --git a/luci-app-bandix/po/pl/bandix.po b/luci-app-bandix/po/pl/bandix.po index 56e9d2db..163cb6e2 100644 --- a/luci-app-bandix/po/pl/bandix.po +++ b/luci-app-bandix/po/pl/bandix.po @@ -1112,6 +1112,30 @@ msgstr "Resetuj" msgid "Today" msgstr "Dzisiaj" +msgid "Yesterday" +msgstr "Wczoraj" + +msgid "Support the Author" +msgstr "Wesprzyj autora" + +msgid "If Bandix is useful to you, you can support its continued development." +msgstr "Jeśli Bandix jest dla Ciebie przydatny, możesz wesprzeć jego dalszy rozwój." + +msgid "Alipay" +msgstr "Alipay" + +msgid "Scan with Alipay to support" +msgstr "Zeskanuj w Alipay, aby wesprzeć" + +msgid "WeChat Pay" +msgstr "WeChat Pay" + +msgid "Scan with WeChat Pay to support" +msgstr "Zeskanuj w WeChat Pay, aby wesprzeć" + +msgid "Donations are voluntary. Thank you for supporting Bandix." +msgstr "Darowizny są dobrowolne. Dziękujemy za wsparcie Bandix." + msgid "This Week" msgstr "Ten Tydzień" diff --git a/luci-app-bandix/po/ru/bandix.po b/luci-app-bandix/po/ru/bandix.po index 32e835a9..d9085271 100644 --- a/luci-app-bandix/po/ru/bandix.po +++ b/luci-app-bandix/po/ru/bandix.po @@ -1112,6 +1112,30 @@ msgstr "Сбросить" msgid "Today" msgstr "Сегодня" +msgid "Yesterday" +msgstr "Вчера" + +msgid "Support the Author" +msgstr "Поддержать автора" + +msgid "If Bandix is useful to you, you can support its continued development." +msgstr "Если Bandix полезен вам, вы можете поддержать его дальнейшую разработку." + +msgid "Alipay" +msgstr "Alipay" + +msgid "Scan with Alipay to support" +msgstr "Отсканируйте в Alipay, чтобы поддержать" + +msgid "WeChat Pay" +msgstr "WeChat Pay" + +msgid "Scan with WeChat Pay to support" +msgstr "Отсканируйте в WeChat Pay, чтобы поддержать" + +msgid "Donations are voluntary. Thank you for supporting Bandix." +msgstr "Пожертвования добровольны. Спасибо за поддержку Bandix." + msgid "This Week" msgstr "На этой неделе" diff --git a/luci-app-bandix/po/zh_Hans/bandix.po b/luci-app-bandix/po/zh_Hans/bandix.po index 2e74db81..0aa5f8c0 100644 --- a/luci-app-bandix/po/zh_Hans/bandix.po +++ b/luci-app-bandix/po/zh_Hans/bandix.po @@ -1094,6 +1094,15 @@ msgstr "有更新" msgid "Update available, click to go to settings" msgstr "有更新,点击前往设置" +msgid "Advertising spaces" +msgstr "广告位" + +msgid "Advertising space" +msgstr "广告位" + +msgid "AD" +msgstr "广告" + msgid "Traffic Statistics" msgstr "流量统计" @@ -1115,6 +1124,30 @@ msgstr "重置" msgid "Today" msgstr "今天" +msgid "Yesterday" +msgstr "昨天" + +msgid "Support the Author" +msgstr "支持作者" + +msgid "If Bandix is useful to you, you can support its continued development." +msgstr "如果 Bandix 对您有帮助,您可以支持项目的持续开发。" + +msgid "Alipay" +msgstr "支付宝" + +msgid "Scan with Alipay to support" +msgstr "使用支付宝扫码支持" + +msgid "WeChat Pay" +msgstr "微信支付" + +msgid "Scan with WeChat Pay to support" +msgstr "使用微信支付扫码支持" + +msgid "Donations are voluntary. Thank you for supporting Bandix." +msgstr "赞助完全自愿。感谢您对 Bandix 的支持。" + msgid "This Week" msgstr "本周" diff --git a/luci-app-bandix/po/zh_Hant/bandix.po b/luci-app-bandix/po/zh_Hant/bandix.po index b77babc6..671ead3c 100644 --- a/luci-app-bandix/po/zh_Hant/bandix.po +++ b/luci-app-bandix/po/zh_Hant/bandix.po @@ -1112,6 +1112,30 @@ msgstr "重置" msgid "Today" msgstr "今天" +msgid "Yesterday" +msgstr "昨天" + +msgid "Support the Author" +msgstr "支持作者" + +msgid "If Bandix is useful to you, you can support its continued development." +msgstr "如果 Bandix 對您有幫助,您可以支持專案的持續開發。" + +msgid "Alipay" +msgstr "支付寶" + +msgid "Scan with Alipay to support" +msgstr "使用支付寶掃碼支持" + +msgid "WeChat Pay" +msgstr "微信支付" + +msgid "Scan with WeChat Pay to support" +msgstr "使用微信支付掃碼支持" + +msgid "Donations are voluntary. Thank you for supporting Bandix." +msgstr "贊助完全自願。感謝您對 Bandix 的支持。" + msgid "This Week" msgstr "本週" @@ -1255,3 +1279,12 @@ msgstr "TCX 出站錨點程式 ID" msgid "Used when tc_order is before/after. Must be a valid egress program id on the same interface." msgstr "當 tc_order 為 before/after 時使用。必須是同一介面上的有效出站程式 ID。" + +msgid "Advertising spaces" +msgstr "廣告位" + +msgid "Advertising space" +msgstr "廣告位" + +msgid "AD" +msgstr "廣告" diff --git a/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js b/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js index b818d928..95664e5a 100644 --- a/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js +++ b/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js @@ -114,6 +114,14 @@ return L.view.extend({ return m.checkDepends(); }; + o = s.option(form.Flag, "mptcp_dscp_weight_vps_sync", _("Mirror DSCP/weight pins to gateway"), + _("When using a DSCP or weight BPF scheduler, also sync each WAN’s pin to the gateway (VPS) so it also holds for traffic the gateway sends (downloads), not just traffic the router sends (uploads). Disabling this only stops future syncs -- it does not remove pins already pushed to the gateway.")); + o.default = "1"; + o.depends("mptcp_scheduler", "bpf_dscp"); + o.depends("mptcp_scheduler", "bpf_weight"); + o.depends("mptcp_scheduler", "bpf_weight_rr"); + o.depends("mptcp_scheduler", "bpf_burstweight"); + if (parseFloat(boardinfo.kernel.substring(0,4)) < 6) { o = s.option(form.Value, "mptcp_syn_retries", _("Multipath TCP SYN retries")); o.datatype = "uinteger"; diff --git a/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js b/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js new file mode 100644 index 00000000..2c9fbc2f --- /dev/null +++ b/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js @@ -0,0 +1,167 @@ +'use strict'; +'require form'; +'require uci'; + +/* + * Copyright (C) 2026 Ycarus (Yannick Chabanois) for OpenMPTCProuter + * This is free software, licensed under the GNU General Public License v3. + * See /LICENSE for more information + */ + +// Same class/value pairs as mptcp-scheduler-dscp.sh's dscp_to_val. +var DSCP_CLASSES = [ + ['cs0', _('CS0 (0) - Best effort')], + ['cs1', _('CS1 (8) - Low priority')], + ['cs2', _('CS2 (16) - High priority')], + ['cs3', _('CS3 (24) - SIP/signaling')], + ['cs4', _('CS4 (32) - Real-time interactive')], + ['cs5', _('CS5 (40) - Broadcast video')], + ['cs6', _('CS6 (48) - Network control')], + ['cs7', _('CS7 (56) - Latency sensitive')], + ['le', _('LE (1) - Lower effort')], + ['af11', 'AF11 (10)'], + ['af12', 'AF12 (12)'], + ['af13', 'AF13 (14)'], + ['af21', 'AF21 (18)'], + ['af22', 'AF22 (20)'], + ['af23', 'AF23 (22)'], + ['af31', 'AF31 (26)'], + ['af32', 'AF32 (28)'], + ['af33', 'AF33 (30)'], + ['af41', 'AF41 (34)'], + ['af42', 'AF42 (36)'], + ['af43', 'AF43 (38)'], + ['ef', _('EF (46) - Voice')] +]; + +// Same WAN filter as this app's own mptcp.js "Interfaces Settings" +// section: excludes the pseudo-interfaces that are never a real WAN. +function isEligibleIface(name) { + return !!name && !name.match(/^oip/) && !name.match(/^lo/) && + name !== 'omrvpn' && name !== 'OWVPN' && name !== 'omr6in4'; +} + +function fillIfaceOptions(option) { + option.value('', _('-- not pinned --')); + uci.sections('network', 'interface', function(ifc) { + if (isEligibleIface(ifc['.name'])) + option.value(ifc['.name'], ifc.label || ifc['.name']); + }); +} + +// Same normalization as mptcp.js's own normalizeSchedulerValue, so +// "mptcp_bpf_dscp.o" (the raw .o filename, seen on older saves / before a +// kernel>=6.18 write ever normalizes it) and "bpf_dscp" compare equal. +function normalizeSchedulerValue(value) { + if (value == null) + return value; + var normalized = String(value).trim(); + if (normalized.slice(-2) === '.o') + normalized = normalized.slice(0, -2); + if (normalized.slice(0, 6) === 'mptcp_') + normalized = normalized.slice(6); + return normalized; +} + +return L.view.extend({ + load: function() { + return Promise.all([ + uci.load('network'), + // mqvpn may not be installed at all -- resolve to null instead + // of rejecting the whole page's load() in that case. + L.resolveDefault(uci.load('mqvpn'), null) + ]); + }, + + render: function() { + var m, s, o; + + var mptcpScheduler = normalizeSchedulerValue(uci.get('network', 'globals', 'mptcp_scheduler')); + var mptcpHasDscpScheduler = (mptcpScheduler === 'bpf_dscp'); + var mptcpHasWeightScheduler = (mptcpScheduler === 'bpf_weight' || mptcpScheduler === 'bpf_weight_rr' || mptcpScheduler === 'bpf_burstweight'); + + var mqvpnEnabled = (uci.get('mqvpn', 'settings', 'enable') === '1'); + var mqvpnScheduler = uci.get('mqvpn', 'multipath', 'scheduler'); + // mqvpn has no scheduler value dedicated to DSCP the way MPTCP has + // bpf_dscp -- 007-mqvpn-dscp pushes the DSCP mask unconditionally + // whenever mqvpn is enabled, regardless of which scheduler is + // running (unlike weight below, which mqvpn only reads for wrtt/ + // wrr). So "mqvpn wants the DSCP table" just means "mqvpn is + // enabled" -- there's no narrower signal to gate on. + var mqvpnHasDscpScheduler = mqvpnEnabled; + // Matches 006-mqvpn-weight's own gate exactly (it no-ops for any + // other scheduler). + var mqvpnHasWeightScheduler = mqvpnEnabled && (mqvpnScheduler === 'wrtt' || mqvpnScheduler === 'wrr'); + + var showDscp = mptcpHasDscpScheduler || mqvpnHasDscpScheduler; + var showWeight = mptcpHasWeightScheduler || mqvpnHasWeightScheduler; + + m = new form.Map('network', _('DSCP / Weight Routing'), + _('Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by the *weight schedulers on both. This page only edits the pins/weights; pick the scheduler that actually uses them on the MPTCP and MQVPN pages themselves.')); + + if (!showDscp && !showWeight) { + m.description += '

' + _('Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler (bpf_weight/bpf_weight_rr/bpf_burstweight, or mqvpn’s Weighted RTT/Weighted Round Robin) is currently selected on the MPTCP or MQVPN pages.'); + return m.render(); + } + + // Same option as luci-app-mptcp's main MPTCP page -- one uci value, + // editable from either page. The Download column below only has an + // effect while this is enabled (it's what gets pushed to the + // gateway as dscp_remote_id); showing it while sync is off would be + // a dead control, so its visibility is decided from this option's + // *persisted* value at page load, not live -- toggling the box + // below needs Save & Apply, then a reload of this page, before the + // Download column itself appears/disappears to match. + s = m.section(form.TypedSection, 'globals', _('Gateway sync')); + + o = s.option(form.Flag, 'mptcp_dscp_weight_vps_sync', _('Mirror DSCP/weight pins to gateway'), + _('When using a DSCP or weight BPF scheduler, also sync each WAN’s pin to the gateway (VPS) so it also holds for traffic the gateway sends (downloads), not just traffic the router sends (uploads). Disabling this only stops future syncs -- it does not remove pins already pushed to the gateway.') + '
' + + _('While disabled, the DSCP table below only shows Upload -- Download has no effect until this is re-enabled and applied.')); + o.default = '1'; + + var vpsSyncEnabled = (uci.get('network', 'globals', 'mptcp_dscp_weight_vps_sync') !== '0'); + + if (showDscp) { + s = m.section(form.TableSection, 'dscp_pin', _('DSCP class pins')); + s.anonymous = true; + s.addremove = true; + s.sortable = true; + + o = s.option(form.ListValue, 'dscp', _('DSCP class')); + o.rmempty = false; + DSCP_CLASSES.forEach(function(c) { o.value(c[0], c[1]); }); + + o = s.option(form.ListValue, 'upload_interface', _('Upload interface'), + _('Router → internet. Pins this class to a WAN for MPTCP’s bpf_dscp scheduler (local dscp_iface map) and, for mqvpn, announces the class on this path’s DSCP mask via the control API.')); + o.load = function(section_id) { + fillIfaceOptions(this); + return this.super('load', [section_id]); + }; + + if (vpsSyncEnabled) { + o = s.option(form.ListValue, 'download_interface', _('Download interface'), + _('Gateway (VPS) → router. Only takes effect for MPTCP’s bpf_dscp scheduler (pins the VPS’s own send-side choice via dscp_remote_id) and can be a different WAN than Upload. mqvpn has no equivalent: its downlink always mirrors whichever WAN carries the class on upload, so this field has no effect on mqvpn.')); + o.load = function(section_id) { + fillIfaceOptions(this); + return this.super('load', [section_id]); + }; + } + } + + if (showWeight) { + s = m.section(form.TypedSection, 'interface', _('WAN weight'), + _('Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by mqvpn’s "Weighted RTT"/"Weighted Round Robin" schedulers -- same value, applies to both upload and download (mirrored to the gateway automatically). Unlike the DSCP pins above, weight is a single value per WAN, not per traffic class.')); + s.filter = function(section) { + return isEligibleIface(section); + }; + + o = s.option(form.Value, 'multipath_weight', _('Weight'), + _('A weight >100 makes a WAN more attractive, a weight <100 makes it less attractive. Max 256.')); + o.datatype = 'uinteger'; + o.rmempty = false; + o.default = 100; + } + + return m.render(); + } +}); diff --git a/luci-app-mptcp/root/usr/share/luci/menu.d/luci-app-mptcp.json b/luci-app-mptcp/root/usr/share/luci/menu.d/luci-app-mptcp.json index 80e5bfc4..3db1c476 100644 --- a/luci-app-mptcp/root/usr/share/luci/menu.d/luci-app-mptcp.json +++ b/luci-app-mptcp/root/usr/share/luci/menu.d/luci-app-mptcp.json @@ -21,6 +21,17 @@ "acl": [ "luci-app-mptcp" ] } }, + "admin/network/mptcp/mptcp_dscp_routing": { + "title": "DSCP / Weight Routing", + "order": 15, + "action": { + "type": "view", + "path": "mptcp/mptcp_dscp_routing" + }, + "depends": { + "acl": [ "luci-app-mptcp" ] + } + }, "admin/network/mptcp/bandwidth": { "title": "Bandwidth", "order": 20, diff --git a/luci-app-mptcp/root/usr/share/rpcd/acl.d/luci-app-mptcp.json b/luci-app-mptcp/root/usr/share/rpcd/acl.d/luci-app-mptcp.json index 1502e052..12179b08 100644 --- a/luci-app-mptcp/root/usr/share/rpcd/acl.d/luci-app-mptcp.json +++ b/luci-app-mptcp/root/usr/share/rpcd/acl.d/luci-app-mptcp.json @@ -2,7 +2,7 @@ "luci-app-mptcp": { "description": "Grant UCI access for luci-app-mptcp", "read": { - "uci": [ "openmptcprouter", "network" ], + "uci": [ "openmptcprouter", "network", "mqvpn" ], "file": { "/usr/lib/mptcpd": [ "list" ], "/usr/share/bpf/scheduler": [ "list" ], diff --git a/mptcp/Makefile b/mptcp/Makefile index 127e4b61..abc90e68 100644 --- a/mptcp/Makefile +++ b/mptcp/Makefile @@ -10,7 +10,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=mptcp PKG_VERSION:=6.1 -PKG_RELEASE:=8 +PKG_RELEASE:=9 PKG_MAINTAINER:=Ycarus (Yannick Chabanois) PKG_BUILD_DIR := $(BUILD_DIR)/$(PKG_NAME) diff --git a/mptcp/files/etc/init.d/mptcp b/mptcp/files/etc/init.d/mptcp index 9cec61af..80a3cc70 100755 --- a/mptcp/files/etc/init.d/mptcp +++ b/mptcp/files/etc/init.d/mptcp @@ -9,6 +9,28 @@ USE_PROCD=1 . /usr/lib/unbound/iptools.sh . /lib/functions/network.sh +restart_scheduler_dependent_services() { + local proxy vpn + proxy="$(uci -q get openmptcprouter.settings.proxy)" + case "$proxy" in + shadowsocks) /etc/init.d/shadowsocks-libev restart >/dev/null 2>&1 ;; + shadowsocks-rust|shadowsocks-go) /etc/init.d/shadowsocks-rust restart >/dev/null 2>&1 ;; + v2ray*) /etc/init.d/v2ray restart >/dev/null 2>&1 ;; + xray*) /etc/init.d/xray restart >/dev/null 2>&1 ;; + esac + + vpn="$(uci -q get openmptcprouter.settings.vpn)" + case "$vpn" in + openvpn) /etc/init.d/openvpn restart >/dev/null 2>&1 ;; + openvpn_bonding) + /etc/init.d/openvpn restart >/dev/null 2>&1 + /etc/init.d/openvpnbonding restart >/dev/null 2>&1 + ;; + glorytun_tcp) /etc/init.d/glorytun restart >/dev/null 2>&1 ;; + esac + logger -t "MPTCP" "Scheduler changed, restarted proxy=${proxy:-none} vpn=${vpn:-none} (vpn restart skipped unless TCP-based)" +} + global_multipath_settings() { local multipath mptcp_path_manager mptcp_scheduler mptcp_subflows mptcp_debug mptcp_add_addr_accepted congestion mptcp_checksum mptcp_syn_retries mptcp_fullmesh_num_subflows mptcp_fullmesh_create_on_err mptcp_ndiffports_num_subflows mptcp_rr_cwnd_limited mptcp_rr_num_segments local multipath_status=0 @@ -47,7 +69,11 @@ global_multipath_settings() { echo "net.mptcp.mptcp_enabled=${multipath_status}" >> /etc/sysctl.d/zzz_openmptcprouter.conf fi [ -z "$mptcp_path_manager" ] || sysctl -qew net.mptcp.mptcp_path_manager="$mptcp_path_manager" - [ -z "$mptcp_scheduler" ] || sysctl -qew net.mptcp.mptcp_scheduler="$mptcp_scheduler" + if [ -n "$mptcp_scheduler" ]; then + local prev_sched="$(cat /proc/sys/net/mptcp/mptcp_scheduler 2>/dev/null)" + sysctl -qew net.mptcp.mptcp_scheduler="$mptcp_scheduler" + [ -n "$prev_sched" ] && [ "$prev_sched" != "$mptcp_scheduler" ] && restart_scheduler_dependent_services + fi [ -z "$mptcp_checksum" ] || sysctl -qew net.mptcp.mptcp_checksum="$mptcp_checksum" [ -z "$mptcp_debug" ] || sysctl -qew net.mptcp.mptcp_debug="$mptcp_debug" [ -z "$mptcp_syn_retries" ] || sysctl -qew net.mptcp.mptcp_syn_retries="$mptcp_syn_retries" @@ -76,6 +102,7 @@ global_multipath_settings() { [ -z "$mptcp_blackhole_timeout" ] || sysctl -qew net.mptcp.blackhole_timeout="$mptcp_blackhole_timeout" if [ -n "$mptcp_scheduler" ]; then local sched="${mptcp_scheduler#mptcp_}"; sched="${sched%.o}" + local prev_sched="$(cat /proc/sys/net/mptcp/scheduler 2>/dev/null)" if [ -d /usr/share/bpf/scheduler ]; then local pin_dir="/sys/fs/bpf/mptcp" mkdir -p "$pin_dir" @@ -87,6 +114,7 @@ global_multipath_settings() { [ -f "$obj" ] && bpftool struct_ops register "$obj" "$pin_dir/" >/dev/null 2>&1 fi sysctl -qew net.mptcp.scheduler="$sched" >/dev/null 2>&1 + [ -n "$prev_sched" ] && [ "$prev_sched" != "$sched" ] && restart_scheduler_dependent_services fi fi [ -z "$congestion" ] || sysctl -qew net.ipv4.tcp_congestion_control="$congestion" @@ -518,13 +546,13 @@ interface_multipath_settings() { if [ "$(uci -q get openmptcprouter.settings.force_multipath)" != "0" ] && [ -n "$(ip a show dev $iface | grep inet)" ]; then if ([ "$mode" = "master" ] || [ "$mode" = "on" ]) && [ -z "$(multipath $iface | grep default)" ]; then logger -t "MPTCP" "Set $iface to $mode from $(multipath $iface)" - multipath "$iface" "on" + multipath "$iface" "on" "$id" elif ([ "$mode" = "off" ] || [ -z "$mode" ]) && [ -z "$(multipath $iface | grep deactivated)" ]; then logger -t "MPTCP" "Set $iface to $mode from $(multipath $iface)" - multipath "$iface" "$mode" + multipath "$iface" "$mode" "$id" elif [ "$mode" = "backup" ] && [ -z "$(multipath $iface | grep backup)" ]; then logger -t "MPTCP" "Set $iface to $mode from $(multipath $iface)" - multipath "$iface" "$mode" + multipath "$iface" "$mode" "$id" fi fi diff --git a/mptcp/files/usr/bin/multipath b/mptcp/files/usr/bin/multipath index 5c1011a5..09969f3d 100755 --- a/mptcp/files/usr/bin/multipath +++ b/mptcp/files/usr/bin/multipath @@ -24,7 +24,7 @@ case $1 in if [ -f /proc/sys/net/mptcp/mptcp_enabled ]; then echo " multipath device {on | off | backup }" else - echo " multipath device {on | off | signal | backup }" + echo " multipath device {on | off | signal | backup } [id]" fi echo echo "show established conections: -c" @@ -39,6 +39,11 @@ case $1 in echo "will allow a subflow to be established across this interface, but only be used" echo "as backup." echo + echo "Optional trailing [id] (on/signal/backup only): explicit MPTCP endpoint id" + echo "(0-255) to pass to 'ip mptcp endpoint add ... id ' instead of letting the" + echo "kernel auto-assign one. Needed for a stable id per WAN across reconnects, e.g." + echo "so a peer (VPS) can pin traffic to this WAN via the subflow's remote_id." + echo exit 0 ;; "-c") if [ -f /proc/net/mptcp_net/mptcp ]; then @@ -123,6 +128,16 @@ esac DEVICE="$1" TYPE="$2" +# Optional explicit MPTCP endpoint id (stable across delete/recreate, unlike +# the kernel's auto-assigned one) so a peer can key on subflow->remote_id. +# Anything that isn't a plain 0-255 integer is ignored (kernel auto-assigns). +ENDPOINT_ID="$3" +case "$ENDPOINT_ID" in + ''|*[!0-9]*) ENDPOINT_ID="" ;; + *) [ "$ENDPOINT_ID" -ge 0 ] 2>/dev/null && [ "$ENDPOINT_ID" -le 255 ] 2>/dev/null || ENDPOINT_ID="" ;; +esac +IDOPT="" +[ -n "$ENDPOINT_ID" ] && IDOPT="id $ENDPOINT_ID" #FLAG_PATH=`find /sys/devices/ -path "*/net/$DEVICE/flags"` [ -e "/sys/class/net/$DEVICE/" ] || { @@ -215,7 +230,7 @@ else done } for i in $IP; do - ip mptcp endpoint add $i dev $DEVICE subflow fullmesh + ip mptcp endpoint add $i dev $DEVICE subflow fullmesh $IDOPT done exit 0;; "signal") @@ -230,7 +245,7 @@ else } for i in $IP; do #ip mptcp endpoint add $i dev $DEVICE signal subflow fullmesh - ip mptcp endpoint add $i dev $DEVICE signal + ip mptcp endpoint add $i dev $DEVICE signal $IDOPT done exit 0;; "backup") @@ -244,7 +259,7 @@ else done } for i in $IP; do - ip mptcp endpoint add $i dev $DEVICE subflow backup fullmesh + ip mptcp endpoint add $i dev $DEVICE subflow backup fullmesh $IDOPT done exit 0;; "") diff --git a/mptcp/files/usr/lib/mptcp/init_helpers.sh b/mptcp/files/usr/lib/mptcp/init_helpers.sh new file mode 100755 index 00000000..62abf179 --- /dev/null +++ b/mptcp/files/usr/lib/mptcp/init_helpers.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +mptcp_has_master_multipath() { + uci -q show network | grep -q "\.multipath='master'$" +} + +mptcp_ensure_ipv6_oif_rule() { + local table="$1" + local iface="$2" + + [ -n "$table" ] || return 1 + [ -n "$iface" ] || return 1 + + ip -6 rule show 2>/dev/null | grep -Fq "oif $iface lookup $table" && return 0 + ip -6 rule add oif "$iface" table "$table" pref 0 +} diff --git a/sqm-autorate/Makefile b/sqm-autorate/Makefile index 8e094b57..e0461c60 100644 --- a/sqm-autorate/Makefile +++ b/sqm-autorate/Makefile @@ -9,14 +9,14 @@ include $(TOPDIR)/rules.mk PKG_NAME:=sqm-autorate PKG_VERSION:=0.1 -PKG_RELEASE:=4 +PKG_RELEASE:=5 include $(INCLUDE_DIR)/package.mk define Package/$(PKG_NAME) SECTION:=net CATEGORY:=Network -DEPENDS:=+bash +tsping +DEPENDS:=+bash +tsping +rpcd TITLE:=SQM Autorate endef diff --git a/sqm-autorate/files/etc/init.d/sqm-autorate b/sqm-autorate/files/etc/init.d/sqm-autorate index 8cbb27c5..43f2cda7 100755 --- a/sqm-autorate/files/etc/init.d/sqm-autorate +++ b/sqm-autorate/files/etc/init.d/sqm-autorate @@ -42,6 +42,15 @@ _launch_autorate() { procd_set_param limits nofile="51200 51200" procd_set_param respawn 0 10 0 procd_set_param stderr 1 + # launcher.sh's own EXIT/INT/TERM trap (kill_cake_instances) SIGTERMs each + # cake-autorate.sh child one-by-one and waits for all of them to exit + # before it exits itself. procd's default grace period between SIGTERM + # and escalating to SIGKILL is too short for that to reliably finish with + # multiple WAN instances running -- if procd SIGKILLs launcher.sh first, + # the trap never runs (SIGKILL can't be caught) and every cake-autorate.sh + # child is orphaned, running forever until the next reboot. Give the trap + # enough room to actually reap its children on every stop/restart/reload. + procd_set_param term_timeout 15 procd_close_instance } diff --git a/sqm-autorate/files/usr/libexec/rpcd/sqm-autorate b/sqm-autorate/files/usr/libexec/rpcd/sqm-autorate new file mode 100755 index 00000000..f6f04110 --- /dev/null +++ b/sqm-autorate/files/usr/libexec/rpcd/sqm-autorate @@ -0,0 +1,215 @@ +#!/bin/sh +# /usr/libexec/rpcd/sqm-autorate +# rpcd plugin for sqm-autorate config + live metrics +# +# Usage: +# ubus call sqm-autorate get_config '{"section":"wan1"}' +# ubus call sqm-autorate get_config {} +# ubus call sqm-autorate set_config '{"section":"wan1","download":"50000","max_download":"80000","restart":true}' +# ubus call sqm-autorate get_metrics '{"section":"wan1"}' +# ubus call sqm-autorate get_metrics {} + +CONFIG_DIR="/usr/share/sqm-autorate" + +# UCI options readable via get_config / settable via set_config -- mirrors +# what luci-app-sqm-autorate's sqm.js form exposes, plus the autorate-only +# knobs config_template.sh reads directly. Keep in sync with both when either +# side gains/loses a field. +VALID_OPTIONS="enabled autorate interface download min_download max_download \ + upload min_upload max_upload debug_logging verbosity qdisc script \ + qdisc_advanced use_mq squash_dscp squash_ingress ingress_ecn egress_ecn \ + qdisc_really_really_advanced ilimit elimit itarget etarget iqdisc_opts \ + eqdisc_opts linklayer overhead linklayer_advanced tcMTU tcTSIZE tcMPU \ + linklayer_adaptation_mechanism output_processing_stats output_load_stats \ + output_reflector_stats output_cake_changes debug sss_compensation pinger \ + no_pingers reflector_ping_interval_s delay_thr_ms enable_sleep_functions \ + connection_active_thr_kpbs sustained_idle_sleep_thr \ + min_shaper_rates_enforcement startup_wait_s" + +_json_str() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +_section_exists() { + [ -n "$(uci -q get "sqm.$1" 2>/dev/null)" ] +} + +_all_sections() { + uci -q show sqm 2>/dev/null | grep '=queue$' | cut -d. -f2 | cut -d= -f1 +} + +# Emit one queue section as a JSON object of its currently-set options. +_section_json() { + local sec="$1" opt val entries="" + for opt in $VALID_OPTIONS; do + val=$(uci -q get "sqm.${sec}.${opt}" 2>/dev/null) + [ -z "$val" ] && continue + [ -n "$entries" ] && entries="${entries}," + entries="${entries}\"$(_json_str "$opt")\":\"$(_json_str "$val")\"" + done + printf '{"section":"%s","options":{%s}}' "$(_json_str "$sec")" "$entries" +} + +# tc reports bandwidth/backlog/drop counters as plain text; there is no -j +# support to rely on here (see tests/cases/11c which parses the same way). +_iface_metrics_json() { + local dev="$1" out bandwidth sent_bytes sent_pkts dropped overlimits backlog_bytes backlog_pkts qdisc_name + + if [ -z "$dev" ] || ! out=$(tc -s qdisc show dev "$dev" 2>/dev/null) || [ -z "$out" ]; then + printf '{"device":"%s","exists":false}' "$(_json_str "$dev")" + return + fi + + qdisc_name=$(printf '%s\n' "$out" | awk 'NR==1{print $2}') + bandwidth=$(printf '%s\n' "$out" | grep -o 'bandwidth [^ ]*' | head -n1 | cut -d' ' -f2) + sent_bytes=$(printf '%s\n' "$out" | sed -n 's/^ *Sent \([0-9]*\) bytes.*/\1/p' | head -n1) + sent_pkts=$(printf '%s\n' "$out" | sed -n 's/^ *Sent [0-9]* bytes \([0-9]*\) pkt.*/\1/p' | head -n1) + dropped=$(printf '%s\n' "$out" | sed -n 's/.*dropped \([0-9]*\),.*/\1/p' | head -n1) + overlimits=$(printf '%s\n' "$out" | sed -n 's/.*overlimits \([0-9]*\).*/\1/p' | head -n1) + backlog_bytes=$(printf '%s\n' "$out" | sed -n 's/^ *backlog \([0-9]*\)b .*/\1/p' | head -n1) + backlog_pkts=$(printf '%s\n' "$out" | sed -n 's/^ *backlog [0-9]*b \([0-9]*\)p.*/\1/p' | head -n1) + + printf '{"device":"%s","exists":true,"qdisc":"%s","bandwidth":"%s","sent_bytes":%s,"sent_pkts":%s,"dropped":%s,"overlimits":%s,"backlog_bytes":%s,"backlog_pkts":%s}' \ + "$(_json_str "$dev")" "$(_json_str "$qdisc_name")" "$(_json_str "$bandwidth")" \ + "${sent_bytes:-0}" "${sent_pkts:-0}" "${dropped:-0}" "${overlimits:-0}" \ + "${backlog_bytes:-0}" "${backlog_pkts:-0}" +} + +_metrics_json() { + local sec="$1" interface ul_if dl_if pid running config_file base_dl min_dl max_dl base_ul min_ul max_ul + + interface=$(uci -q get "sqm.${sec}.interface" 2>/dev/null) + ul_if="$interface" + dl_if="ifb4${interface}" + + config_file="${CONFIG_DIR}/config.${sec}.sh" + pid="" + running="false" + if [ -f "$config_file" ]; then + pid=$(pgrep -f "cake-autorate.sh ${config_file}" 2>/dev/null | head -n1) + [ -n "$pid" ] && running="true" + fi + + base_dl=$(uci -q get "sqm.${sec}.download" 2>/dev/null) + min_dl=$(uci -q get "sqm.${sec}.min_download" 2>/dev/null) + max_dl=$(uci -q get "sqm.${sec}.max_download" 2>/dev/null) + base_ul=$(uci -q get "sqm.${sec}.upload" 2>/dev/null) + min_ul=$(uci -q get "sqm.${sec}.min_upload" 2>/dev/null) + max_ul=$(uci -q get "sqm.${sec}.max_upload" 2>/dev/null) + + printf '{"section":"%s","interface":"%s","autorate_running":%s,"pid":%s,"download":{"base_kbps":"%s","min_kbps":"%s","max_kbps":"%s","tc":%s},"upload":{"base_kbps":"%s","min_kbps":"%s","max_kbps":"%s","tc":%s}}' \ + "$(_json_str "$sec")" "$(_json_str "$interface")" "$running" "${pid:-null}" \ + "$(_json_str "$base_dl")" "$(_json_str "$min_dl")" "$(_json_str "$max_dl")" "$(_iface_metrics_json "$dl_if")" \ + "$(_json_str "$base_ul")" "$(_json_str "$min_ul")" "$(_json_str "$max_ul")" "$(_iface_metrics_json "$ul_if")" +} + +case "$1" in + list) + echo '{ + "get_config": { "section": "str" }, + "set_config": { "section": "str", "restart": false }, + "get_metrics": { "section": "str" } +}' + # get_config/get_metrics: omit "section" to get every sqm queue section. + # set_config: pass any subset of the known sqm option names alongside + # "section" (e.g. download, max_download, delay_thr_ms, ...); only + # recognised options are applied. restart=true also bounces the + # sqm-autorate service afterwards (regenerates and relaunches every + # instance -- there is no per-interface restart, see launcher.sh). + ;; + + call) + input=$(cat) + case "$2" in + get_config) + section=$(echo "$input" | jsonfilter -q -e '@.section') + if [ -n "$section" ]; then + if ! _section_exists "$section"; then + echo "{\"error\":\"section '$(_json_str "$section")' not found in sqm config\"}" + exit 1 + fi + _section_json "$section" + else + entries="" + for sec in $(_all_sections); do + [ -n "$entries" ] && entries="${entries}," + entries="${entries}$(_section_json "$sec")" + done + printf '{"sections":[%s]}' "$entries" + fi + ;; + + set_config) + section=$(echo "$input" | jsonfilter -q -e '@.section') + restart=$(echo "$input" | jsonfilter -q -e '@.restart') + + if [ -z "$section" ]; then + echo '{"error":"missing section parameter"}' + exit 1 + fi + if ! _section_exists "$section"; then + echo "{\"error\":\"section '$(_json_str "$section")' not found in sqm config\"}" + exit 1 + fi + + applied="" + for opt in $VALID_OPTIONS; do + val=$(echo "$input" | jsonfilter -q -e "@.${opt}" 2>/dev/null) + [ -z "$val" ] && continue + # cake-autorate's config validation classifies delay_thr_ms + # by whether it contains a decimal point (typeof: float vs + # integer) -- a plain integer here fails instance startup + # with "not a valid value of type: 'float'" (confirmed on + # the bench). Force the decimal so set_config can't + # reintroduce that trap. + if [ "$opt" = "delay_thr_ms" ]; then + case "$val" in + *.*) ;; + *) val="${val}.0" ;; + esac + fi + uci -q set "sqm.${section}.${opt}=${val}" + [ -n "$applied" ] && applied="${applied}," + applied="${applied}\"$(_json_str "$opt")\"" + done + + if [ -z "$applied" ]; then + echo '{"error":"no recognised options provided"}' + exit 1 + fi + + uci -q commit sqm + + if [ "$restart" = "true" ]; then + /etc/init.d/sqm-autorate restart >/dev/null 2>&1 & + fi + + printf '{"result":"ok","section":"%s","applied":[%s],"restarted":%s}' \ + "$(_json_str "$section")" "$applied" "$([ "$restart" = "true" ] && echo true || echo false)" + ;; + + get_metrics) + section=$(echo "$input" | jsonfilter -q -e '@.section') + if [ -n "$section" ]; then + if ! _section_exists "$section"; then + echo "{\"error\":\"section '$(_json_str "$section")' not found in sqm config\"}" + exit 1 + fi + _metrics_json "$section" + else + entries="" + for sec in $(_all_sections); do + [ -n "$entries" ] && entries="${entries}," + entries="${entries}$(_metrics_json "$sec")" + done + printf '{"sections":[%s]}' "$entries" + fi + ;; + + *) + echo '{"error":"unknown method"}' + exit 1 + ;; + esac + ;; +esac diff --git a/sqm-autorate/files/usr/share/rpcd/acl.d/sqm-autorate.json b/sqm-autorate/files/usr/share/rpcd/acl.d/sqm-autorate.json new file mode 100644 index 00000000..9f302cde --- /dev/null +++ b/sqm-autorate/files/usr/share/rpcd/acl.d/sqm-autorate.json @@ -0,0 +1,15 @@ +{ + "sqm-autorate": { + "description": "Grant ubus access for sqm-autorate", + "read": { + "ubus": { + "sqm-autorate": [ "get_config", "get_metrics" ] + } + }, + "write": { + "ubus": { + "sqm-autorate": [ "set_config" ] + } + } + } +} diff --git a/sqm-autorate/files/usr/share/sqm-autorate/cake-autorate.sh b/sqm-autorate/files/usr/share/sqm-autorate/cake-autorate.sh index 6a5b4562..d4705971 100755 --- a/sqm-autorate/files/usr/share/sqm-autorate/cake-autorate.sh +++ b/sqm-autorate/files/usr/share/sqm-autorate/cake-autorate.sh @@ -612,7 +612,22 @@ parse_tsping() case "${command[0]}" in REFLECTOR_RESPONSE) - read -r timestamp reflector seq _ _ _ _ _ dl_owd_ms ul_owd_ms checksum <<< "${command[@]:1}" + if (( ${#command[@]} == 6 )) + then + # tsping run in ICMP echo mode (e.g. via '-e'/'--icmp-echo' in ping_extra_args, + # used so that reflectors which do not answer ICMP timestamp requests can still + # be used) only reports a single round trip time and none of the Originate/ + # Received/Transmit/Finished timestamps used below to derive dl/ul owd, so fall + # back to splitting the rtt evenly across dl and ul, exactly as parse_ping() does + # for the plain 'ping' binary, which is likewise rtt-only. + read -r timestamp reflector seq rtt_ms checksum <<< "${command[@]:1}" + dl_owd_ms="${rtt_ms}" + ul_owd_ms="${rtt_ms}" + icmp_echo_mode=1 + else + read -r timestamp reflector seq _ _ _ _ _ dl_owd_ms ul_owd_ms checksum <<< "${command[@]:1}" + icmp_echo_mode=0 + fi ;; START_PINGER) @@ -681,8 +696,15 @@ parse_tsping() [[ "${timestamp:-}" && "${reflector:-}" && "${seq:-}" && "${dl_owd_ms:-}" && "${ul_owd_ms:-}" && "${checksum:-}" ]] || continue [[ "${checksum}" == "${timestamp}" ]] || continue - dl_owd_us="${dl_owd_ms}000" - ul_owd_us="${ul_owd_ms}000" + if ((icmp_echo_mode)) + then + # rtt-only sample (see note above): split evenly between dl and ul + dl_owd_us=$(( (dl_owd_ms * 1000) / 2 )) + ul_owd_us="${dl_owd_us}" + else + dl_owd_us="${dl_owd_ms}000" + ul_owd_us="${ul_owd_ms}000" + fi dl_owd_delta_us=$(( dl_owd_us - dl_owd_baselines_us[${reflector}] )) ul_owd_delta_us=$(( ul_owd_us - ul_owd_baselines_us[${reflector}] ))