diff --git a/linkease-runtime-transition/Makefile b/linkease-runtime-transition/Makefile index 2edd4c6bc..4076b617d 100644 --- a/linkease-runtime-transition/Makefile +++ b/linkease-runtime-transition/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=linkease-runtime-transition PKG_VERSION:=1.7.6 -PKG_RELEASE:=1 +PKG_RELEASE:=2 include $(INCLUDE_DIR)/package.mk @@ -12,7 +12,7 @@ define Package/$(PKG_NAME) SUBMENU:=Web Servers/Proxies TITLE:=LinkEase split-runtime transition contract DEPENDS:=@(arm||x86_64||aarch64) +linkease +luci-lib-linkeasefile - EXTRA_DEPENDS:=linkease (>= 1.7.6~), luci-lib-linkeasefile (>= 2.1.70-r4) + EXTRA_DEPENDS:=linkease (>=1.7.6~0), luci-lib-linkeasefile (>=2.1.70-r4) PKGARCH:=all URL:=https://www.linkease.com/ endef diff --git a/linkease/Makefile b/linkease/Makefile index 6ed74d914..524c66ca6 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.6 -PKG_RELEASE:=46 +PKG_RELEASE:=47 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) @@ -44,7 +44,7 @@ define Package/$(PKG_NAME) SUBMENU:=Web Servers/Proxies TITLE:=LinkEase - the file cloud DEPENDS:=@(arm||x86_64||aarch64) +linkease-common-bin - EXTRA_DEPENDS:=linkease-common-bin (>= 1.7.6~) + EXTRA_DEPENDS:=linkease-common-bin (>=1.7.6~0) PKGARCH:=all URL:=https://www.linkease.com/ endef diff --git a/linkeasefull/Makefile b/linkeasefull/Makefile index 2a38a16aa..8fb19357a 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.22 -PKG_RELEASE:=45 +PKG_RELEASE:=46 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) @@ -40,7 +40,7 @@ define Package/$(PKG_NAME) SUBMENU:=Web Servers/Proxies TITLE:=LinkEase Full - the local web desktop DEPENDS:=@(x86_64||aarch64) +linkease-app-entry +linkease-common-bin +ca-bundle +cifsmount +kmod-fs-cifs - EXTRA_DEPENDS:=linkease-common-bin (>= 1.7.6~) + EXTRA_DEPENDS:=linkease-common-bin (>=1.7.6~0) PKGARCH:=all URL:=https://www.linkease.com/ endef diff --git a/luci-app-adguardhome/.official_json_version b/luci-app-adguardhome/.official_json_version index 8db3474b4..790d04676 100644 --- a/luci-app-adguardhome/.official_json_version +++ b/luci-app-adguardhome/.official_json_version @@ -1 +1 @@ -26.272.65915~80de46c +26.273.63965~32775cf diff --git a/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js b/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js index 4b44b2f07..908a27ed8 100644 --- a/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +++ b/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js @@ -2,29 +2,88 @@ 'require view'; 'require fs'; 'require ui'; +'require uci'; +'require rpc'; + +const callCommit = rpc.declare({ + object: 'uci', + method: 'commit', + params: [ 'config' ], + reject: true +}); return view.extend({ load() { - return L.resolveDefault(fs.read('/etc/firewall.user'), ''); + return Promise.all([ + L.resolveDefault(fs.read('/etc/firewall.user'), ''), + uci.load('firewall') + ]).then(data => data[0]); + }, + + saveInclude() { + const include = uci.sections('firewall', 'include').find(s => + s.path == '/etc/firewall.user' && (!s.type || s.type == 'script')); + const sid = include ? include['.name'] : uci.add('firewall', 'include'); + + uci.set('firewall', sid, 'path', '/etc/firewall.user'); + uci.set('firewall', sid, 'type', 'script'); + uci.set('firewall', sid, 'enabled', '1'); + if (L.hasSystemFeature('firewall4')) + uci.set('firewall', sid, 'fw4_compatible', '1'); + + return uci.save().then(() => callCommit('firewall')) + .then(L.bind(ui.changes.init, ui.changes)); }, handleSave(ev) { - const value = (document.querySelector('textarea').value || '').trim().replace(/\r\n/g, '\n') + '\n'; + const textarea = document.querySelector('textarea'); + const value = (textarea.value || '').trim().replace(/\r\n/g, '\n') + '\n'; - return fs.write('/etc/firewall.user', value).then(function(rc) { - document.querySelector('textarea').value = value; - ui.addNotification(null, E('p', _('Contents have been saved.')), 'info'); - fs.exec('/etc/init.d/firewall', ['restart']); - }).catch(function(e) { - ui.addNotification(null, E('p', _('Unable to save contents: %s').format(e.message))); + return fs.write('/etc/firewall.user', value).then(() => { + return fs.exec('/bin/sh', [ '-n', '/etc/firewall.user' ]); + }).then(res => { + if (res.code != 0) { + return fs.write('/etc/firewall.user', this.contents).then(() => { + throw new Error(res.stderr || res.stdout || _('Invalid shell script.')); + }); + } + + this.contents = textarea.value = value; + return this.saveInclude(); + }).then(() => { + return fs.exec('/etc/init.d/firewall', [ 'restart' ]); + }).then(res => { + if (res.code != 0 || /Include '\/etc\/firewall\.user' failed with exit code/.test(res.stderr || '')) + throw new Error(res.stderr || res.stdout || _('Unable to restart the firewall.')); + + ui.addNotification(null, E('p', _('Custom rules have been saved and applied.')), 'info'); + }).catch(e => { + ui.addNotification(null, E('p', _('Unable to save or apply custom rules: %s').format(e.message))); }); }, render(fwuser) { + this.contents = fwuser || ''; + let value = this.contents; + + if (!value.trim() && L.hasSystemFeature('firewall4')) { + value = [ + '# This file is interpreted as a shell script.', + '# Custom commands run after the default rules on every firewall restart.', + '# Use nft commands here. The inet fw4 table is recreated on reload.', + '#', + '# Example (remove the leading # to enable):', + '# nft add rule inet fw4 input counter comment "custom-input"', + '' + ].join('\n'); + } + return E([ E('h2', _('Firewall - Custom Rules')), - E('p', {}, _('Custom rules allow you to define custom firewall rules which are not otherwise covered by the firewall framework. On firewall4 systems, these rules must use nftables syntax.')) - E('p', {}, E('textarea', { 'style': 'width:100%', 'rows': 25 }, [ fwuser != null ? fwuser : '' ])) + E('p', {}, L.hasSystemFeature('firewall4') + ? _('Custom rules are shell commands executed after each firewall restart, after the default ruleset has been loaded. Use nft commands for the nftables firewall. Saving applies the rules immediately.') + : _('Custom rules allow you to execute arbitrary iptables commands which are not otherwise covered by the firewall framework. The commands are executed after each firewall restart, right after the default ruleset has been loaded.')), + E('p', {}, E('textarea', { 'style': 'width:100%', 'rows': 25, 'disabled': !L.hasViewPermission() || null }, [ value ])) ]); }, diff --git a/luci-app-firewall/po/templates/firewall.pot b/luci-app-firewall/po/templates/firewall.pot index 73ddc58aa..6450085ee 100644 --- a/luci-app-firewall/po/templates/firewall.pot +++ b/luci-app-firewall/po/templates/firewall.pot @@ -1435,3 +1435,23 @@ msgstr "" #: applications/luci-app-firewall/htdocs/luci-static/resources/tools/firewall.js:338 msgid "valid firewall mark" msgstr "" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Custom rules are shell commands executed after each firewall restart, after the default ruleset has been loaded. Use nft commands for the nftables firewall. Saving applies the rules immediately." +msgstr "" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Custom rules have been saved and applied." +msgstr "" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Unable to save or apply custom rules: %s" +msgstr "" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Invalid shell script." +msgstr "" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Unable to restart the firewall." +msgstr "" diff --git a/luci-app-firewall/po/zh_Hans/firewall.po b/luci-app-firewall/po/zh_Hans/firewall.po index 713e7d391..4efb87740 100644 --- a/luci-app-firewall/po/zh_Hans/firewall.po +++ b/luci-app-firewall/po/zh_Hans/firewall.po @@ -345,7 +345,7 @@ msgid "TCP congestion control algorithm." msgstr "TCP 拥塞控制算法。" msgid "Running status: %s, TCP congestion control algorithm is %s" -msgstr "当前运行状态:%s,TCP 拥塞控制算法为 %s" +msgstr "当前NAT1状态:%s,TCP 拥塞控制算法为 %s" msgid "Broadcom Fullcone NAT1" msgstr "Broadcom Fullcone NAT1" @@ -1567,3 +1567,23 @@ msgstr "最多 65536 个条目。" #: applications/luci-app-firewall/htdocs/luci-static/resources/tools/firewall.js:338 msgid "valid firewall mark" msgstr "有效的防火墙标记" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Custom rules are shell commands executed after each firewall restart, after the default ruleset has been loaded. Use nft commands for the nftables firewall. Saving applies the rules immediately." +msgstr "自定义规则是 Shell 命令,每次防火墙重启并加载默认规则后执行。nftables 防火墙请使用 nft 命令。保存后立即应用规则。" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Custom rules have been saved and applied." +msgstr "自定义规则已保存并应用。" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Unable to save or apply custom rules: %s" +msgstr "无法保存或应用自定义规则:%s" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Invalid shell script." +msgstr "Shell 脚本语法错误。" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Unable to restart the firewall." +msgstr "无法重启防火墙。" diff --git a/luci-app-firewall/po/zh_Hant/firewall.po b/luci-app-firewall/po/zh_Hant/firewall.po index 7ecb0c523..b028558d4 100644 --- a/luci-app-firewall/po/zh_Hant/firewall.po +++ b/luci-app-firewall/po/zh_Hant/firewall.po @@ -1553,3 +1553,23 @@ msgstr "最多 65536 個條目。" #: applications/luci-app-firewall/htdocs/luci-static/resources/tools/firewall.js:338 msgid "valid firewall mark" msgstr "有效防火牆標記" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Custom rules are shell commands executed after each firewall restart, after the default ruleset has been loaded. Use nft commands for the nftables firewall. Saving applies the rules immediately." +msgstr "自訂規則是 Shell 指令,每次防火牆重新啟動並載入預設規則後執行。nftables 防火牆請使用 nft 指令。儲存後立即套用規則。" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Custom rules have been saved and applied." +msgstr "自訂規則已儲存並套用。" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Unable to save or apply custom rules: %s" +msgstr "無法儲存或套用自訂規則:%s" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Invalid shell script." +msgstr "Shell 指令碼語法錯誤。" + +#: applications/luci-app-firewall/htdocs/luci-static/resources/view/firewall/custom.js +msgid "Unable to restart the firewall." +msgstr "無法重新啟動防火牆。" diff --git a/luci-app-firewall/root/usr/share/luci/menu.d/luci-app-firewall.json b/luci-app-firewall/root/usr/share/luci/menu.d/luci-app-firewall.json index af8f9bf88..3cc4fc400 100644 --- a/luci-app-firewall/root/usr/share/luci/menu.d/luci-app-firewall.json +++ b/luci-app-firewall/root/usr/share/luci/menu.d/luci-app-firewall.json @@ -63,6 +63,6 @@ "action": { "type": "view", "path": "firewall/custom" - }, + } } } diff --git a/luci-app-firewall/root/usr/share/rpcd/acl.d/luci-app-firewall.json b/luci-app-firewall/root/usr/share/rpcd/acl.d/luci-app-firewall.json index 433d1ccff..e13f1f200 100644 --- a/luci-app-firewall/root/usr/share/rpcd/acl.d/luci-app-firewall.json +++ b/luci-app-firewall/root/usr/share/rpcd/acl.d/luci-app-firewall.json @@ -15,10 +15,13 @@ }, "write": { "file": { - "/etc/firewall.user": [ "write" ] + "/etc/firewall.user": [ "write" ], + "/bin/sh -n /etc/firewall.user": [ "exec" ], + "/etc/init.d/firewall restart": [ "exec" ] }, "ubus": { - "file": [ "write" ] + "file": [ "write", "exec" ], + "uci": [ "commit" ] }, "uci": [ "firewall" ] } diff --git a/luci-app-fwlive/Makefile b/luci-app-fwlive/Makefile index 032d6cdf3..63c2ce8a4 100644 --- a/luci-app-fwlive/Makefile +++ b/luci-app-fwlive/Makefile @@ -8,11 +8,11 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=Firewall Live View LUCI_DESCRIPTION:=Live firewall log table (firewall4/nft; iptables-tagged lines still classify) with filters -LUCI_DEPENDS:=+luci-base +logd +jsonfilter +coreutils-timeout +LUCI_DEPENDS:=+luci-base +logd +jsonfilter LUCI_PKGARCH:=all -PKG_VERSION:=0.1.47 -PKG_RELEASE:=53 +PKG_VERSION:=0.1.48 +PKG_RELEASE:=54 # Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI). ifdef SOURCE_DATE_EPOCH diff --git a/luci-app-fwlive/README.md b/luci-app-fwlive/README.md index 8972f29d7..c8059f7fc 100644 --- a/luci-app-fwlive/README.md +++ b/luci-app-fwlive/README.md @@ -38,12 +38,22 @@ No `luasrc/` — modern JS-only app. ## Dependencies -- `luci-base`, `logd`, `jsonfilter`, `coreutils-timeout` (declared in `LUCI_DEPENDS`; `rpcd` via `luci-base`) +- `luci-base`, `logd`, `jsonfilter` (declared in `LUCI_DEPENDS`; `rpcd` via `luci-base`) - Optional reverse DNS uses BusyBox `nslookup` (stock image; not a package depend) - No hard `firewall4` dependency - Menu depends on ACL only (no `fs` AND of `nft`+`iptables` — that hid the entry on stock fw3 and fw4) - Runtime backend detection selects **fw4/nft** on supported **23.05+** images; log lines tagged **iptables** still classify. OpenWrt **21.02** / **22.03** are unsupported +The rpcd plugin invokes its read helpers directly and does not require GNU +`timeout`. The `log.read` ubus invocation uses ubus's native five-second reply +timeout after object lookup; this does not cancel a remote method already +running. Existing line, byte, and count limits still apply, and reverse DNS +stops starting lookups after its elapsed-time budget. Other helpers have no +fwlive deadline. Stock rpcd kills the plugin process after its configured +execution timeout (30 seconds), but does not kill its descendants; helpers can +outlive the request. This is an accepted maintenance tradeoff, to be +reconsidered if users report reliability problems. + ## Maintenance The development home is [lucas-albers-lz4/fwlive](https://github.com/lucas-albers-lz4/fwlive). diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/constants.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/constants.js index da45e2321..20526dd9a 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/constants.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/constants.js @@ -9,7 +9,7 @@ */ return baseclass.extend({ /* Keep in sync with openwrt-feed/luci-app-fwlive/Makefile PKG_VERSION. */ - APP_VERSION: '0.1.47', + APP_VERSION: '0.1.48', ROW_LIMIT_OPTIONS: [25, 50, 100, 250, 500, 1000, 2000], DEFAULT_ROW_LIMIT: 100, FETCH_MODE_OPTIONS: ['auto', 'manual'], diff --git a/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js b/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js index d8ce57d06..f1979a124 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js @@ -24,7 +24,7 @@ 'require fwlive.render-scheduler as renderScheduler'; /* Poll error codes that only a reinstall fixes; retrying cannot help. */ -const INSTALL_POLL_ERRORS = ['timeout_missing', 'jsonfilter_missing', 'classifier_missing']; +const INSTALL_POLL_ERRORS = ['jsonfilter_missing', 'classifier_missing']; const callFwlivePoll = rpc.declare({ object: 'fwlive', @@ -758,8 +758,6 @@ return view.extend({ return _('Could not process firewall rule names.'); case 'no_backend': return _('Could not read the active firewall rules.'); - case 'timeout_missing': - return _('The required timeout utility is unavailable.'); case 'rules_unavailable': if (!this.rulesMapLoaded) return _('Could not load rule names.'); if (this.rulesMap && Object.keys(this.rulesMap).length) @@ -792,12 +790,8 @@ return view.extend({ return lines; }, - /* timeout_missing is an install problem shown elsewhere, not a rules-map gap. */ rulesMapDegraded() { - return !!( - this.rulesMapTruncated || - (this.lastRulesError && this.lastRulesError !== 'timeout_missing') - ); + return !!(this.rulesMapTruncated || this.lastRulesError); }, updateBackendUi() { @@ -1114,7 +1108,6 @@ return view.extend({ this.fillingBuffer = false; this.notePollRtt(rtt, true); this.updateAdaptiveBanner(); - if (this.lastPollErrorCode === 'timeout_missing') this.updateBackendUi(); }, applyPollReply(poll, context) { @@ -1208,24 +1201,13 @@ return view.extend({ /* Visibility changes and disposal invalidate all application of this reply. */ if (epoch !== this.currentPollEpoch()) return; - const recoveringTimeoutProvider = this.lastPollErrorCode === 'timeout_missing'; this.applyPollReply(poll, { beforeLength: beforeLength, fetchLines: fetchLines, pausedAtStart: pausedAtStart, resumeMerge: resumeMerge }); - if (recoveringTimeoutProvider && !this.lastPollError) { - try { - await Promise.all([this.loadRulesMap(epoch), this.loadLoggingStatus(epoch)]); - /* Recovery RPCs can outlive the poll epoch; don't repaint stale views. */ - if (epoch !== this.currentPollEpoch() || this.viewDisposed) return; - /* loadRulesMap refreshes buffered labels when its map read succeeds. */ - } catch (_e) { - /* A throw here is local UI work after a successful poll. Leave - * lastPollError / lastPollErrorCode as applyPollReply set them. */ - } - } else if ( + if ( this.lastRulesError === 'rules_unavailable' && this.rulesRetryAttempt < constants.RULES_RETRY_MAX_ATTEMPTS && !this.tablePaused && diff --git a/luci-app-fwlive/po/de/luci-app-fwlive.po b/luci-app-fwlive/po/de/luci-app-fwlive.po index a49f516a0..40360d5cb 100644 --- a/luci-app-fwlive/po/de/luci-app-fwlive.po +++ b/luci-app-fwlive/po/de/luci-app-fwlive.po @@ -672,10 +672,6 @@ msgstr "Die Firewall wurde nicht neu geladen; gespeicherte und aktive Protokolli msgid "The rate shown for WAN logging is the firewall zone log_limit. OpenWrt defaults to 10/minute when no explicit limit is configured; fwlive does not impose this cap." msgstr "Die angezeigte Rate für WAN-Protokollierung ist das Zonen-log_limit. OpenWrt-Standard ist 10/Minute ohne explizites Limit; fwlive setzt diese Grenze nicht." -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:762 -msgid "The required timeout utility is unavailable." -msgstr "Das erforderliche Programm zur Zeitbegrenzung ist nicht verfügbar." - #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1722 msgid "The router could not read the firewall log — retrying…" msgstr "Der Router konnte das Firewall-Protokoll nicht lesen — neuer Versuch…" diff --git a/luci-app-fwlive/po/ru/luci-app-fwlive.po b/luci-app-fwlive/po/ru/luci-app-fwlive.po index 174b11d5a..c1cd1f417 100644 --- a/luci-app-fwlive/po/ru/luci-app-fwlive.po +++ b/luci-app-fwlive/po/ru/luci-app-fwlive.po @@ -672,10 +672,6 @@ msgstr "Брандмауэр не перезагрузился; сохранён msgid "The rate shown for WAN logging is the firewall zone log_limit. OpenWrt defaults to 10/minute when no explicit limit is configured; fwlive does not impose this cap." msgstr "Показанная частота WAN-журнала — log_limit зоны. По умолчанию OpenWrt: 10/мин без явного лимита; fwlive сам лимит не задаёт." -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:762 -msgid "The required timeout utility is unavailable." -msgstr "Необходимая утилита ограничения времени недоступна." - #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1722 msgid "The router could not read the firewall log — retrying…" msgstr "Маршрутизатор не смог прочитать журнал межсетевого экрана — повторная попытка…" diff --git a/luci-app-fwlive/po/templates/luci-app-fwlive.pot b/luci-app-fwlive/po/templates/luci-app-fwlive.pot index 9bb3134cc..0dae1cc12 100644 --- a/luci-app-fwlive/po/templates/luci-app-fwlive.pot +++ b/luci-app-fwlive/po/templates/luci-app-fwlive.pot @@ -1,31 +1,31 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1710 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1692 msgid "%d matching · %d/%d stored" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1675 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1657 msgid "%dd" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1674 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1656 msgid "%dh" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1673 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1655 msgid "%dm" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1672 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1654 msgid "%ds" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1437 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1419 msgid "%s · requested up to %d raw lines · %d firewall messages returned" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1427 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1409 msgid "" "%s · requested up to %d raw lines · server limited fetch to %d · %d firewall " "messages returned" @@ -35,7 +35,7 @@ msgstr "" msgid "%s: %s" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1712 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1694 msgid "0 matching · %d/%d stored" msgstr "" @@ -48,11 +48,11 @@ msgstr "" msgid "Action" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:951 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:945 msgid "Administrator access is required to disable logging." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:939 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:933 msgid "Administrator access is required to enable logging." msgstr "" @@ -60,7 +60,7 @@ msgstr "" msgid "Allow/deny rules, LAN logging, or anything else." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1912 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1894 msgid "Also seen" msgstr "" @@ -74,16 +74,16 @@ msgid "" "state." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2742 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2724 msgid "Any action" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1905 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1887 msgid "Any protocol" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1418 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1490 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1400 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1472 msgid "Auto" msgstr "" @@ -103,7 +103,7 @@ msgstr "" msgid "Classic (green/red)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2454 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2436 msgid "Classic uses green/red; Accessible uses teal/orange" msgstr "" @@ -111,13 +111,13 @@ msgstr "" msgid "Clear all" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2814 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2796 msgid "" "Click a cell to filter · ≠ on a chip to exclude · Ctrl+click a rule for " "firewall settings · in Simple view, click a row for the full message" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2529 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2511 msgid "" "Click a row (Time or other non-link cells) to see the full log line (Simple " "view)." @@ -127,17 +127,17 @@ msgstr "" msgid "Click a row for the full message" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2534 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2516 msgid "" "Click an IP, action, or protocol to filter; use the Protocol menu (or ≠ on a " "chip) to exclude." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1906 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1888 msgid "Common" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1723 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1705 msgid "Connection lost — retrying…" msgstr "" @@ -153,19 +153,19 @@ msgstr "" msgid "Could not create a temporary file for rule names." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:949 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:943 msgid "Could not disable logging." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:931 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:925 msgid "Could not enable logging." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:865 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:859 msgid "Could not load logging status." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:764 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:762 msgid "Could not load rule names." msgstr "" @@ -177,15 +177,15 @@ msgstr "" msgid "Could not read the active firewall rules." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:864 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:858 msgid "Could not refresh logging status; showing the last known state." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:769 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:767 msgid "Could not refresh rule names." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:767 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:765 msgid "Could not refresh rule names; previously loaded names are still shown." msgstr "" @@ -205,7 +205,7 @@ msgstr "" msgid "Could not write the WAN zone log option." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2769 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2751 msgid "Custom protocol (prefix ! to exclude). Overrides the menu when set." msgstr "" @@ -218,7 +218,7 @@ msgstr "" msgid "Destination" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2799 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2781 msgid "Destination IP contains (! to exclude)" msgstr "" @@ -226,15 +226,15 @@ msgstr "" msgid "Destination port" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2804 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2786 msgid "Destination port (! to exclude)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2657 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2639 msgid "Detail" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2610 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2592 msgid "Details" msgstr "" @@ -246,17 +246,17 @@ msgstr "" msgid "Disabling…" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1467 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1449 msgid "" "Display limited to %d rows on this device; the larger Limit still buffers " "rows." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2395 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2377 msgid "Display options" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2488 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2470 msgid "" "Display options set Limit, Fetch budget, Maximum raw lines, row tint, " "palette, and hostnames." @@ -278,7 +278,7 @@ msgstr "" msgid "Enable logging" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2483 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2465 msgid "" "Enable logging turns on WAN zone drop/reject logging only (same as Network → " "Firewall). It does not add rules or log normal LAN browsing." @@ -288,7 +288,7 @@ msgstr "" msgid "Enabling…" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1919 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1901 msgid "Exclude" msgstr "" @@ -296,11 +296,11 @@ msgstr "" msgid "Exclude instead" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2409 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2391 msgid "Fetch budget" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2498 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2480 msgid "" "Fetch budget controls raw log lines per poll. Auto derives from Limit; " "Manual selects a bounded maximum." @@ -316,12 +316,12 @@ msgstr "" msgid "Filter logs by rule (hint: %s). Ctrl+click to open firewall settings." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2572 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2554 #: openwrt-feed/luci-app-fwlive/root/usr/share/luci/menu.d/luci-app-fwlive.json:3 msgid "Firewall Live View" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1690 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1672 msgid "Firewall log has no current entries; showing rows last received %s ago." msgstr "" @@ -333,7 +333,7 @@ msgstr "" msgid "Flow" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2508 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2490 msgid "" "For a responsive table on a weak device, keep Limit at 250 rows or below. " "The weak-device cap affects rendered rows; the buffer can still retain more." @@ -347,19 +347,19 @@ msgstr "" msgid "Grant access to firewall live log view" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2474 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2456 msgid "Help" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1565 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1547 msgid "Hide rows" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1463 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1445 msgid "Hostname lookup paused while the router is under load." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1910 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1892 msgid "ICMPv6" msgstr "" @@ -367,7 +367,7 @@ msgstr "" msgid "IN" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2545 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2527 msgid "" "If Row tint looks missing, the active LuCI theme may omit success/error or " "info/warn CSS variables; fwlive falls back to local colors (air-gapped, no " @@ -394,7 +394,7 @@ msgid "" "release, then reload the firewall." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1721 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1703 msgid "Installation is incomplete. Reinstall luci-app-fwlive." msgstr "" @@ -403,7 +403,7 @@ msgstr "" msgid "Interface" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2784 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2766 msgid "Interface (prefix ! to exclude)" msgstr "" @@ -420,11 +420,11 @@ msgstr "" msgid "Len" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2398 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2380 msgid "Limit" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:819 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:813 msgid "Live view may be incomplete — a legacy iptables table is registered" msgstr "" @@ -432,16 +432,16 @@ msgstr "" msgid "Logging is off on this router" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1418 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1491 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1400 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1473 msgid "Manual" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2427 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2409 msgid "Manual still uses server protection and poll cadence" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2503 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2485 msgid "" "Manual still uses server protection and poll cadence; it changes the fetch " "budget, not the polling interval." @@ -451,18 +451,18 @@ msgstr "" msgid "Manual test (System → Terminal):" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2421 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2403 msgid "Maximum raw lines" msgstr "" #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:52 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:340 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2669 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2676 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2651 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2658 msgid "Message" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2779 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2761 msgid "More filters" msgstr "" @@ -495,7 +495,7 @@ msgstr "" msgid "OUT" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2699 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2681 msgid "One line" msgstr "" @@ -522,20 +522,20 @@ msgstr "" msgid "Outbound" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2448 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2430 msgid "Palette" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1784 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2623 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1766 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2605 msgid "Pause" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1783 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1765 msgid "Paused" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1450 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1432 msgid "" "Polling slowed: the router is responding slowly, so polls run less often." msgstr "" @@ -545,11 +545,11 @@ msgstr "" msgid "Proto" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2760 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2742 msgid "Protocol — common values" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2739 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2721 msgid "Quick search" msgstr "" @@ -557,22 +557,22 @@ msgstr "" msgid "Remove filter" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1784 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1766 msgid "Resume" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2438 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2420 msgid "Row tint" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2539 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2521 msgid "" "Row tint shows pass/deny row backgrounds when checked. Choose Classic (green/" "red, default) or Accessible (teal/orange). Action text stays colored either " "way." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2595 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2577 msgid "" "Row tint used a local color fallback because the active LuCI theme did not " "apply pass/deny backgrounds." @@ -582,19 +582,19 @@ msgstr "" msgid "Rule" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2610 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2592 msgid "Rule name details" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:786 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:784 msgid "Rule-name lookup does not change your firewall rules." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:771 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:769 msgid "Rule-name lookup failed." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:778 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:776 msgid "Rule-name lookup reached a safety limit." msgstr "" @@ -606,53 +606,53 @@ msgstr "" msgid "Search" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1447 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1429 msgid "Server adaptive protection is disabled." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1455 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1437 msgid "" "Server limited fetch: the router is under load; at most %d log lines per " "poll." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1460 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1442 msgid "" "Server limited fetch: this poll returned fewer lines to protect the router." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1445 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1427 msgid "Server protection state is unknown." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2465 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2447 msgid "Show hostnames" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2436 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2418 msgid "Show pass/deny row background colors" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1565 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2722 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1547 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2704 msgid "Show rows" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1525 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1507 msgid "Showing a compact summary of the top of the shown sample." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2646 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2628 msgid "Simple" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:789 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:787 msgid "" "Some entries may show names derived from their log prefixes. Searching by a " "friendly rule name may miss those entries." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:813 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:807 msgid "Some rule names may be missing" msgstr "" @@ -661,7 +661,7 @@ msgstr "" msgid "Source" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2789 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2771 msgid "Source IP contains (! to exclude)" msgstr "" @@ -669,36 +669,36 @@ msgstr "" msgid "Source port" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2794 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2776 msgid "Source port (! to exclude)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1527 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1509 msgid "Summary data is unavailable; show rows to inspect the sample." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2713 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2695 msgid "Summary mode" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2493 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2475 msgid "" "Summary mode: when the router responds slowly, a compact summary replaces " "the table. Use Show rows to see the table; it returns automatically after " "three fast polls." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1529 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1511 msgid "Summary was bounded before all categories could be included." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2513 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2495 msgid "" "Switching tabs pauses polling; returning performs one catch-up poll. " "Hostnames are off by default because lookups add work." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1619 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1601 msgid "" "Table refresh throttled: the event rate is high, so the table repaints less " "often to protect the browser. The buffer still updates; refresh resumes " @@ -709,34 +709,30 @@ msgstr "" msgid "The firewall did not reload; saved and live logging may differ." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2523 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2505 msgid "" "The rate shown for WAN logging is the firewall zone log_limit. OpenWrt " "defaults to 10/minute when no explicit limit is configured; fwlive does not " "impose this cap." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:762 -msgid "The required timeout utility is unavailable." -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1722 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1704 msgid "The router could not read the firewall log — retrying…" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2518 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2500 msgid "" "The router log ring may evict older events before fwlive reads them. fwlive " "cannot recover evicted entries or change forwarding behavior." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2478 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2460 msgid "" "The table updates automatically when your firewall logs traffic. Use Pause " "if it moves too fast." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2598 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2580 msgid "Theme tint fallback" msgstr "" @@ -753,15 +749,15 @@ msgstr "" msgid "Time" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1534 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1516 msgid "Top drops" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1537 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1519 msgid "Top rules" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1531 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1513 msgid "Top talkers" msgstr "" @@ -784,16 +780,16 @@ msgstr "" msgid "Undo:" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2542 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2524 msgid "Use Detail for all columns (flags, length, raw message)." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2628 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2634 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2610 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2616 msgid "View" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:950 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:944 msgid "WAN drop/reject logging is off." msgstr "" @@ -803,13 +799,13 @@ msgid "" "here. Normal LAN browsing will not." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:935 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:929 msgid "" "WAN drop/reject logging is on. Blocked inbound traffic should appear here as " "it happens — not normal LAN browsing." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:937 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:931 msgid "WAN logging is already enabled." msgstr "" @@ -838,25 +834,25 @@ msgstr "" msgid "Waiting for firewall events" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1783 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2586 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1765 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2568 msgid "Watching" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2688 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2670 msgid "Wrap" msgstr "" #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:72 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2744 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2726 msgid "block" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1269 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1251 msgid "buffer filling" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1273 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1255 msgid "buffer full" msgstr "" @@ -868,7 +864,7 @@ msgstr "" msgid "default 10/minute" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1278 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1260 msgid "display limited to %d rows" msgstr "" @@ -877,11 +873,11 @@ msgid "does not contain" msgstr "" #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:73 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2745 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2727 msgid "drop" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1282 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1264 msgid "hostname lookup paused" msgstr "" @@ -889,16 +885,16 @@ msgstr "" msgid "is" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1268 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1250 msgid "loading buffer" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1708 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1690 msgid "loading…" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1507 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1516 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1489 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1498 msgid "none" msgstr "" @@ -906,94 +902,94 @@ msgstr "" msgid "not" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1927 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1909 msgid "not AH" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1926 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1908 msgid "not ESP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1925 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1907 msgid "not GRE" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1922 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1904 msgid "not ICMP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1923 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1905 msgid "not ICMPv6" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1924 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1906 msgid "not IGMP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1928 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1910 msgid "not SCTP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1920 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1902 msgid "not TCP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1921 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1903 msgid "not UDP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2750 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2732 msgid "not block" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2749 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2731 msgid "not drop" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2748 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2730 msgid "not pass" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2751 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2733 msgid "not reject" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2752 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2734 msgid "not unknown" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2767 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2749 msgid "or type…" msgstr "" #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:71 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2743 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2725 msgid "pass" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1280 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1262 msgid "polling slowed" msgstr "" #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:74 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2746 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2728 msgid "reject" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1284 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1266 msgid "scroll frozen — scroll to top to follow live" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1281 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1263 msgid "server limited fetch" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1275 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1257 msgid "table refresh throttled" msgstr "" #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:63 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2747 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2729 msgid "unknown" msgstr "" diff --git a/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po b/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po index df4a7e5b9..29aa62a8f 100644 --- a/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po +++ b/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po @@ -672,10 +672,6 @@ msgstr "防火墙未能重新加载;已保存的日志设置与当前生效状 msgid "The rate shown for WAN logging is the firewall zone log_limit. OpenWrt defaults to 10/minute when no explicit limit is configured; fwlive does not impose this cap." msgstr "WAN 日志显示的速率是防火墙区域的 log_limit。未显式配置时 OpenWrt 默认为 10 条/分钟;此上限并非 fwlive 施加。" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:762 -msgid "The required timeout utility is unavailable." -msgstr "所需的超时工具不可用。" - #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1722 msgid "The router could not read the firewall log — retrying…" msgstr "路由器无法读取防火墙日志 — 正在重试…" diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh index bc3bdc7d5..94e6ae8c8 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh +++ b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh @@ -11,14 +11,6 @@ NF_LOG_IPV4="${FWLIVE_NF_LOG_IPV4_PATH:-/proc/sys/net/netfilter/nf_log/2}" NF_LOG_IPV6="${FWLIVE_NF_LOG_IPV6_PATH:-/proc/sys/net/netfilter/nf_log/10}" -# BusyBox ash may resolve its enabled timeout applet ahead of PATH entries. -# Use the coreutils-timeout package path explicitly so GNU `-k` is available. -# FWLIVE_TIMEOUT_BIN is an environment seam for host tests; rpcd's environment -# is root-owned and no ubus input can set it. -FWLIVE_TIMEOUT_BIN="${FWLIVE_TIMEOUT_BIN:-/usr/bin/timeout}" -fwlive_timeout_available() { - [ -x "$FWLIVE_TIMEOUT_BIN" ] -} # /proc/sys/net/netfilter/nf_log/10 is a backend selector, not an IPv6 # availability probe. A missing or empty /proc/net/if_inet6 means the IPv6 # stack is absent (compiled out or ipv6.disable=1), so an IPv6 backend is @@ -766,10 +758,6 @@ legacy_iptables_active() { collect_logging_warnings() { LOGGING_WARNINGS='' - # rpcd/fwlive run_with_timeout fail-closes to 127 without GNU timeout. - # Warnings are diagnostics only — do not gate the enable-logging CTA. - fwlive_timeout_available || logging_warnings_append 'timeout_missing' - # Diagnostic only: supported releases use nftables, but a registered legacy # iptables table can still exist in the namespace visible to rpcd. LuCI # surfaces this warning in the backend label; it must not affect readiness diff --git a/luci-app-fwlive/root/usr/libexec/rpcd/fwlive b/luci-app-fwlive/root/usr/libexec/rpcd/fwlive index 7f6a58826..2ee44eddd 100755 --- a/luci-app-fwlive/root/usr/libexec/rpcd/fwlive +++ b/luci-app-fwlive/root/usr/libexec/rpcd/fwlive @@ -23,35 +23,12 @@ ADAPTIVE_SH="$LIBEXEC_DIR/fwlive-adaptive-cap.sh" RESOLVE_MAX=32 # DNS names are at most 253 octets; a longer PTR answer is treated as no name. RESOLVE_NAME_MAX=253 -# Per-lookup nslookup timeout (integer seconds — BusyBox `timeout` truncates -# fractional values to 0 on OpenWrt 23.05/1.36, so 0.5 would silently -# degrade to no timeout; luna fold 2026-08-10). The wall-clock budget -# below stops starting work after the budget; this per-lookup cap plus the -# shared TERM/KILL grace bounds the last in-progress lookup. LAN/local-resolver -# lookups are single-digit ms, so 1s is ample. Passed as argv to `timeout`, -# never interpolated into a shell string. -RESOLVE_TIMEOUT=1 -# Wall-clock budget (seconds) for a single ubus fwlive.resolve call. The -# lookup loop checks elapsed time and aborts cleanly once this budget is -# spent, so a flood of unresolvable IPs cannot hold an rpcd worker for the -# full RESOLVE_MAX * RESOLVE_TIMEOUT worst case. Worst case total time is -# budget + one in-progress lookup (RESOLVE_TIMEOUT + TIMEOUT_KILL_GRACE), -# plus scheduling/IPC overhead: approximately 7s before scheduler/IPC delay. -# Elapsed time comes from /proc/uptime centiseconds so an NTP step cannot -# stretch the budget; RESOLVE_MAX still bounds the work if uptime is unreadable. +# Elapsed budget stops starting additional lookups; it cannot interrupt an +# in-progress helper. fwlive adds no helper deadline; rpcd limits the plugin process, not descendants. +# /proc/uptime avoids NTP steps; RESOLVE_MAX bounds work if uptime is unreadable. RESOLVE_BUDGET=5 # Cap raw logd lines per poll (~2000 ≈ typical logd ring / ~200 KB JSON before filter). POLL_LINES_MAX=2000 -# Bound nft ruleset dumps (corrupted/hung nft must not block LuCI rules load). -NFT_TIMEOUT=5 -# Bound poll log.read (wedged logd must not hold an rpcd worker past the UI cadence). -# Integer seconds — BusyBox timeout truncates fractions to 0 (see RESOLVE_TIMEOUT). -POLL_TIMEOUT=5 -# Outer GNU timeout hard-kills the command group after the inner command's -# duration plus this TERM grace. The package dependency ensures GNU's -# --foreground and signal options are available, including when BusyBox also -# has a timeout applet. -TIMEOUT_KILL_GRACE=1 # Host tests override this to a pinned libubox helper; devices keep the package path. : "${FWLIVE_JSHN_SH:=/usr/share/libubox/jshn.sh}" @@ -210,68 +187,6 @@ map_prefix_with_label() { fi } -run_with_timeout() { - secs="$1" - shift - if fwlive_timeout_available; then - run_timeout_busybox="$(command -v busybox 2>/dev/null)" || return 127 - run_timeout_cat="$(command -v cat 2>/dev/null)" || return 127 - [ -n "$run_timeout_busybox" ] && [ -n "$run_timeout_cat" ] || return 127 - # The inner foreground timeout sends TERM to the direct command at its - # deadline. Keep a pipe reader alive until every inherited stdout fd - # closes; the outer timeout then KILLs the whole process group at the - # hard deadline. After the pipeline ends, keep this wrapper alive while - # any other live process remains in its dedicated process group. setsid keeps - # unrelated rpcd processes out of that scan and lets the outer timeout - # kill children even when they close stdout. BusyBox ash pipefail - # preserves the inner timeout status; invoking it explicitly keeps this - # portable from host dash too. - "$run_timeout_busybox" setsid "$FWLIVE_TIMEOUT_BIN" -s KILL "$((secs + TIMEOUT_KILL_GRACE))" \ - "$run_timeout_busybox" sh -c ' - set -o pipefail || exit 125 - timeout_bin="$1" - duration="$2" - cat_bin="$3" - busybox_bin="$4" - shift 4 - "$timeout_bin" --foreground "$duration" "$@" | "$cat_bin" - status="$?" - IFS= read -r proc_fields < "/proc/$$/stat" || exit 125 - proc_fields="${proc_fields##*) }" - proc_fields="${proc_fields#* }" - proc_fields="${proc_fields#* }" - group="${proc_fields%% *}" - while :; do - peer=0 - for proc_stat in /proc/[0-9]*/stat; do - [ -r "$proc_stat" ] || continue - IFS= read -r proc_fields < "$proc_stat" || continue - proc_fields="${proc_fields##*) }" - proc_state="${proc_fields%% *}" - proc_fields="${proc_fields#* }" - proc_fields="${proc_fields#* }" - proc_group="${proc_fields%% *}" - proc_pid="${proc_stat#/proc/}" - proc_pid="${proc_pid%/stat}" - if [ "$proc_group" = "$group" ] && \ - [ "$proc_pid" != "$group" ] && [ "$proc_pid" != "$$" ] && \ - [ "$proc_state" != Z ]; then - peer=1 - break - fi - done - [ "$peer" = 1 ] || break - "$busybox_bin" sleep 0.1 || "$busybox_bin" sleep 1 || exit 125 - done - exit "$status" - ' fwlive-timeout "$FWLIVE_TIMEOUT_BIN" "$secs" \ - "$run_timeout_cat" "$run_timeout_busybox" "$@" 2>/dev/null - else - # Fail closed: never run unbounded without the GNU provider. - return 127 - fi -} - map_uci_rule_names() { # Line-wise so each name stays one token. Names outside the key charset # are skipped; one shared line cap also bounds duplicate UCI names that @@ -462,7 +377,7 @@ map_from_nft_stream() { } nft_list_ruleset() { - run_with_timeout "$NFT_TIMEOUT" nft list ruleset + nft list ruleset } _fwlive_tmp_dir_ok() { @@ -512,7 +427,7 @@ _fwlive_tmp_cleanup() { fi } # Same five traps as fwlive-log-filter.sh. The nft dump waits in the -# background so a trapped SIGTERM is not deferred behind setsid. +# background so a trapped SIGTERM is not deferred behind a foreground helper. trap '_fwlive_tmp_cleanup' 0 trap '_fwlive_tmp_cleanup; exit 129' 1 trap '_fwlive_tmp_cleanup; exit 130' 2 @@ -527,7 +442,7 @@ enrich_rules_dump() { # One dump serves detect + parse. Empty/failed dump is not nft # (unknown/no_backend, same as a failed detect probe). # Background + wait: dash defers trapped SIGTERM until a - # foreground setsid/timeout child exits; wait is interruptible. + # foreground child exits; wait is interruptible. # Clear inherited traps so the child's EXIT cannot rm the dump. ( trap - 0 1 2 3 15; nft_list_ruleset >"$_rules_dump" 2>/dev/null ) & _nft_wait=$! @@ -578,11 +493,7 @@ build_rules_map() { # Temp file first, then a single nft_list_ruleset; backend from success. if command -v nft >/dev/null 2>&1; then - if fwlive_timeout_available; then - enrich_rules_dump fwlive-nft - else - _enrich_error='timeout_missing' - fi + enrich_rules_dump fwlive-nft fi if [ "$backend" = unknown ] && [ -z "$_enrich_error" ]; then @@ -696,8 +607,9 @@ fetch_firewall_logs() { shed_flag=${2:-0} # Adaptive plan prints: lines shed bucket start_cs=$(fwlive_adaptive_clock_cs) - # Drop dead oneshot flag — only consumed on logd's stream branch. - if ! raw=$(run_with_timeout "$POLL_TIMEOUT" ubus call log read "{\"lines\":$lines,\"stream\":false}"); then + # Bound the log.read invocation with ubus's native client timeout. This + # begins after object lookup and does not cancel the remote logd method. + if ! raw=$(ubus -t 5 call log read "{\"lines\":$lines,\"stream\":false}"); then # Do not record ~0 ms as cold: failure is not healthy fast processing # and must not clear an existing hot/shed cap. out='{"log":[],"error":"log_read_failed"}' @@ -722,7 +634,7 @@ fetch_firewall_logs() { # Empty success is not valid poll JSON — the shipped filter always # prints {"log":[...]}, but a stub/regression must not emit a blank body. _filter_ok=1 - if ! out=$(printf '%s' "$raw" | run_with_timeout "$POLL_TIMEOUT" "$FILTER_SH" /tmp); then + if ! out=$(printf '%s' "$raw" | "$FILTER_SH" /tmp); then _filter_ok=0 case "$out" in '{"log":[],"error":"filter_failed"}'|\ @@ -766,10 +678,6 @@ fetch_firewall_logs() { } poll_logs() { - if ! fwlive_timeout_available; then - printf '{"log":[],"error":"timeout_missing"}' - return 0 - fi # Optional JSON on $1 (rpcd may also pass argv $3 at the call site); else stdin. input=$(read_rpc_input "$1") fetch_firewall_logs "$input" @@ -817,8 +725,8 @@ resolve_hostname() { [ -n "$ip" ] || return 1 is_resolvable_address "$ip" || return 1 command -v nslookup >/dev/null 2>&1 || return 1 - # timeout/nslookup miss is "no PTR", not a plugin abort. - out=$(run_with_timeout "$RESOLVE_TIMEOUT" nslookup "$ip") || return 1 + # An unsuccessful nslookup is "no PTR", not a plugin abort. + out=$(nslookup "$ip") || return 1 [ -n "$out" ] || return 1 name=$(parse_nslookup_name "$out") || return 1 [ -n "$name" ] || return 1 @@ -918,11 +826,6 @@ resolve_addresses() { printf '{"names":{},"error":"no_resolver"}' return 0 fi - # Do not turn a missing timeout provider into a negative PTR cache entry. - if ! fwlive_timeout_available; then - printf '{"names":{},"error":"timeout_missing"}' - return 0 - fi # Load shed: previous poll was hot. if fwlive_adaptive_is_hot; then @@ -995,7 +898,7 @@ resolve_addresses() { break fi lookups=$((lookups + 1)) - # NXDOMAIN / timeout: resolve_hostname returns 1. set -e + # Unsuccessful lookup: resolve_hostname returns 1. set -e # cannot apply to this assignment. name=$(resolve_hostname "$ip") || name= [ "${#name}" -le "$RESOLVE_NAME_MAX" ] || name= @@ -1428,12 +1331,6 @@ case "${1:-}" in run_selftest exit $? ;; - __limits) - printf '%s %s %s %s %s\n' \ - "$POLL_TIMEOUT" "$TIMEOUT_KILL_GRACE" "$NFT_TIMEOUT" \ - "$RESOLVE_TIMEOUT" "$RESOLVE_BUDGET" - exit 0 - ;; __poll_clamp) poll_clamp_lines "${2:-}" exit $? diff --git a/luci-app-homeproxy-pro/Makefile b/luci-app-homeproxy-pro/Makefile index b76eedb5c..0dba15c52 100644 --- a/luci-app-homeproxy-pro/Makefile +++ b/luci-app-homeproxy-pro/Makefile @@ -23,7 +23,7 @@ LUCI_DEPENDS:= \ PKG_NAME:=luci-app-homeproxy-pro PKG_VERSION:=28.9.1.14 -PKG_RELEASE:=13 +PKG_RELEASE:=14 # Only files the package actually ships belong here. The previous list named # four paths that exist nowhere in the tree - certs/ and ruleset/ are created at diff --git a/luci-app-homeproxy-pro/README.md b/luci-app-homeproxy-pro/README.md index 7c64b2ee3..7cd12bbc7 100644 --- a/luci-app-homeproxy-pro/README.md +++ b/luci-app-homeproxy-pro/README.md @@ -15,7 +15,9 @@ ## 运行要求 -- ImmortalWrt / OpenWrt ≥ 24.10(`apk` 或 `opkg` 均可安装);sing-box ≥ 1.14.0 是硬要求;低于 1.14 时服务拒绝启动并记录明确日志 +- **ImmortalWrt ≥ 25.12**(`apk` 或 `opkg` 均可安装)。这条以前写的是 "≥ 24.10",是错的:从 r11 起本包在模块顶层 `import { … mkdtemp … } from 'fs'`,而 `fs.mkdtemp` 是 ucode 在 2025-11-07 才加上的(`ucode-lang/ucode` 的 `lib/fs.c`),24.10 分支线的 ucode 早于此,**整个 24.10 系列都不满足**。ucode 在模块加载期解析 import,所以不满足时安装期迁移、配置生成器、LuCI RPC 三处同时失败——不是一个"装不上"的问题,而是这个插件整体不可用。旧版本 24.10 用户请停留在 r10 及更早的 pro。 +- sing-box ≥ 1.14.0 是硬要求;低于 1.14 时服务拒绝启动并记录明确日志。 +- 启动时会检查 ucode 是否提供 `fs.mkdtemp`,不满足则拒绝启动并记录明确日志(`hp_require_ucode()`,与 `hp_require_singbox()` 并列),而不是让用户看到一段模块加载堆栈。 ## 按功能性对比 diff --git a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js index 993a0e820..1a6fba340 100644 --- a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js +++ b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js @@ -140,7 +140,16 @@ function renderDnsCache(ctx) { o.rmempty = false; o.depends({'routing_mode': 'custom', '!reverse': true}); o.validate = function(section_id, value) { - if (section_id) { + /* 'wan' is the value this field stored while the dropdown still + * offered "WAN DNS (read from interface)" - the resolver the WAN + * interface handed us. It is no longer offered, because the ISP + * resolver is precisely what this field must not be, but the + * generator still maps the literal to wan_dns + * (generator/context.uc), so a configuration that chose it back + * then keeps working. Rejecting it here would block saving that + * untouched, working configuration - hence accepted, but never + * offered again. */ + if (section_id && !['wan'].includes(value)) { if (!value) return _('Expecting: %s').format(_('non-empty value')); @@ -168,7 +177,11 @@ function renderDnsCache(ctx) { _('Resolves the domains on the China path, so it has to be a domestic service to get the ' + 'correct local CDN answers. Keep the default; the upstream may block UDP/53, in which case ' + 'a DoH address (https://dns.alidns.com/dns-query, https://doh.pub/dns-query) also works. ' + - 'Support UDP, TCP, DoH, DoQ, DoT.')); + 'Support UDP, TCP, DoH, DoQ, DoT. This address is also the resolver the proxy DNS server ' + + 'above uses to look up its own hostname when that one is a domain - and a URL here is a ' + + 'domain, which cannot do that lookup itself, so with a URL here the proxy DNS server falls ' + + 'back to the WAN resolver. Enter the bare IP form of the same service to keep that lookup ' + + 'on this router.')); o.value('wan', _('WAN DNS (read from interface)')); o.value('223.5.5.5', _('Aliyun Public DNS (223.5.5.5)')); o.value('210.2.4.8', _('CNNIC Public DNS (210.2.4.8)')); diff --git a/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot b/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot index 1364526cd..416b7985b 100644 --- a/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot +++ b/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot @@ -152,7 +152,7 @@ msgstr "" msgid "Alibaba Cloud DNS" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:173 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:186 msgid "Aliyun Public DNS (223.5.5.5)" msgstr "" @@ -337,11 +337,11 @@ msgstr "" msgid "CA provider" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:204 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:217 msgid "CN-IP DNS fallback (sing-box 1.14)" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:174 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:187 msgid "CNNIC Public DNS (210.2.4.8)" msgstr "" @@ -366,7 +366,7 @@ msgstr "" msgid "Check update" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:167 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:176 msgid "China DNS server" msgstr "" @@ -879,12 +879,12 @@ msgstr "" #: htdocs/luci-static/resources/homeproxy-pro.js:1156 #: htdocs/luci-static/resources/view/homeproxy-pro/client/access.js:158 #: htdocs/luci-static/resources/view/homeproxy-pro/client/access.js:203 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:145 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:157 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:161 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:183 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:194 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:198 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:154 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:166 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:170 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:196 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:207 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:211 #: htdocs/luci-static/resources/view/homeproxy-pro/client/nodes.js:141 #: htdocs/luci-static/resources/view/homeproxy-pro/client/nodes.js:155 #: htdocs/luci-static/resources/view/homeproxy-pro/client/nodes.js:158 @@ -2208,12 +2208,17 @@ msgid "" "Support UDP, TCP, DoH, DoQ, DoT. TCP protocol will be used if not specified." msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:168 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:177 msgid "" "Resolves the domains on the China path, so it has to be a domestic service " "to get the correct local CDN answers. Keep the default; the upstream may " "block UDP/53, in which case a DoH address (https://dns.alidns.com/dns-query, " -"https://doh.pub/dns-query) also works. Support UDP, TCP, DoH, DoQ, DoT." +"https://doh.pub/dns-query) also works. Support UDP, TCP, DoH, DoQ, DoT. This " +"address is also the resolver the proxy DNS server above uses to look up its " +"own hostname when that one is a domain - and a URL here is a domain, which " +"cannot do that lookup itself, so with a URL here the proxy DNS server falls " +"back to the WAN resolver. Enter the bare IP form of the same service to keep " +"that lookup on this router." msgstr "" #: htdocs/luci-static/resources/view/homeproxy-pro/status.js:258 @@ -2668,7 +2673,7 @@ msgid "" "Tell the client to use the BBR flow control algorithm instead of Hysteria CC." msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:175 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:188 msgid "Tencent Public DNS (119.29.29.29)" msgstr "" @@ -2874,7 +2879,7 @@ msgid "" "QUIC stream based UDP relay mode that TUIC does not provide." msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:176 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:189 msgid "ThreatBook Public DNS (117.50.10.10)" msgstr "" @@ -3116,7 +3121,7 @@ msgstr "" msgid "VMess" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:172 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:185 msgid "WAN DNS (read from interface)" msgstr "" @@ -3132,7 +3137,7 @@ msgstr "" msgid "WebSocket" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:205 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:218 msgid "" "When the main DNS returns a mainland China IP, re-resolve via China DNS " "using evaluate/match_response." @@ -3217,8 +3222,8 @@ msgstr "" #: htdocs/luci-static/resources/homeproxy-pro.js:220 #: htdocs/luci-static/resources/homeproxy-pro.js:1134 #: htdocs/luci-static/resources/homeproxy-pro.js:1154 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:145 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:183 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:154 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:196 #: htdocs/luci-static/resources/view/homeproxy-pro/client/nodes.js:141 #: htdocs/luci-static/resources/view/homeproxy-pro/client/subscription.js:72 #: htdocs/luci-static/resources/view/homeproxy-pro/node.js:484 @@ -3331,10 +3336,10 @@ msgstr "" msgid "v6" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:157 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:161 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:194 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:198 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:166 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:170 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:207 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:211 msgid "valid DNS server address" msgstr "" diff --git a/luci-app-homeproxy-pro/po/zh_Hans/homeproxy-pro.po b/luci-app-homeproxy-pro/po/zh_Hans/homeproxy-pro.po index 67a52dc6e..ae14c0fd0 100644 --- a/luci-app-homeproxy-pro/po/zh_Hans/homeproxy-pro.po +++ b/luci-app-homeproxy-pro/po/zh_Hans/homeproxy-pro.po @@ -162,7 +162,7 @@ msgstr "激进" msgid "Alibaba Cloud DNS" msgstr "阿里云 DNS" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:173 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:186 msgid "Aliyun Public DNS (223.5.5.5)" msgstr "阿里云公共 DNS(223.5.5.5)" @@ -350,11 +350,11 @@ msgstr "默认使用防火墙规则绕过中国大陆流量。" msgid "CA provider" msgstr "CA 颁发机构" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:204 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:217 msgid "CN-IP DNS fallback (sing-box 1.14)" msgstr "大陆 IP DNS 兜底(sing-box 1.14)" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:174 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:187 msgid "CNNIC Public DNS (210.2.4.8)" msgstr "CNNIC 公共 DNS(210.2.4.8)" @@ -379,7 +379,7 @@ msgstr "检查" msgid "Check update" msgstr "检查更新" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:167 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:176 msgid "China DNS server" msgstr "国内 DNS 服务器" @@ -907,12 +907,12 @@ msgstr "Evaluate 标签" #: htdocs/luci-static/resources/homeproxy-pro.js:1156 #: htdocs/luci-static/resources/view/homeproxy-pro/client/access.js:158 #: htdocs/luci-static/resources/view/homeproxy-pro/client/access.js:203 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:145 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:157 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:161 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:183 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:194 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:198 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:154 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:166 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:170 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:196 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:207 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:211 #: htdocs/luci-static/resources/view/homeproxy-pro/client/nodes.js:141 #: htdocs/luci-static/resources/view/homeproxy-pro/client/nodes.js:155 #: htdocs/luci-static/resources/view/homeproxy-pro/client/nodes.js:158 @@ -2256,16 +2256,24 @@ msgstr "" "回被污染或 NXDOMAIN 的结果,这正是代理本身正常、境外网站却解析失败的原因,因" "此这里只提供境外服务。支持 UDP、TCP、DoH、DoQ、DoT,未指定协议时使用 TCP。" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:168 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:177 msgid "" "Resolves the domains on the China path, so it has to be a domestic service " "to get the correct local CDN answers. Keep the default; the upstream may " "block UDP/53, in which case a DoH address (https://dns.alidns.com/dns-query, " -"https://doh.pub/dns-query) also works. Support UDP, TCP, DoH, DoQ, DoT." +"https://doh.pub/dns-query) also works. Support UDP, TCP, DoH, DoQ, DoT. This " +"address is also the resolver the proxy DNS server above uses to look up its " +"own hostname when that one is a domain - and a URL here is a domain, which " +"cannot do that lookup itself, so with a URL here the proxy DNS server falls " +"back to the WAN resolver. Enter the bare IP form of the same service to keep " +"that lookup on this router." msgstr "" -"解析中国路径上的域名,必须是国内服务才能获得正确的本地 CDN 结果。建议保持默认" -"值;若上游封禁 UDP/53,也可使用 DoH 地址(https://dns.alidns.com/dns-query、" -"https://doh.pub/dns-query)。支持 UDP、TCP、DoH、DoQ、DoT。" +"解析走中国大陆路径的域名,所以必须是国内服务,才能拿到正确的本地 CDN 结果。保持" +"默认即可;上游可能封锁 UDP/53,此时改填 DoH 地址(https://dns.alidns.com/" +"dns-query、https://doh.pub/dns-query)也可用。支持 UDP、TCP、DoH、DoQ、DoT。" +"这个地址同时是上方代理 DNS 服务器解析自身域名所用的解析器(当上方填的是域名" +"时);而 URL 本身就是域名,无法完成这次解析——所以此处填 URL 时,上方代理 DNS " +"服务器会退回由 WAN 解析器解析。想避免就把这里填成同一服务的裸 IP。" #: htdocs/luci-static/resources/view/homeproxy-pro/status.js:258 msgid "Resources management" @@ -2737,7 +2745,7 @@ msgid "" "Tell the client to use the BBR flow control algorithm instead of Hysteria CC." msgstr "让客户端使用 BBR 流控算法。" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:175 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:188 msgid "Tencent Public DNS (119.29.29.29)" msgstr "腾讯公共 DNS(119.29.29.29)" @@ -2966,7 +2974,7 @@ msgstr "" "这是 UDP over TCP 协议的 TUIC 版本,旨在提供 TUIC 本身不具备的基于 QUIC 流的 " "UDP 中继模式。" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:176 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:189 msgid "ThreatBook Public DNS (117.50.10.10)" msgstr "微步在线公共 DNS(117.50.10.10)" @@ -3212,7 +3220,7 @@ msgstr "VLESS" msgid "VMess" msgstr "VMess" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:172 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:185 msgid "WAN DNS (read from interface)" msgstr "WAN DNS(从接口获取)" @@ -3228,7 +3236,7 @@ msgstr "警告" msgid "WebSocket" msgstr "WebSocket" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:205 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:218 msgid "" "When the main DNS returns a mainland China IP, re-resolve via China DNS " "using evaluate/match_response." @@ -3314,8 +3322,8 @@ msgstr "" #: htdocs/luci-static/resources/homeproxy-pro.js:220 #: htdocs/luci-static/resources/homeproxy-pro.js:1134 #: htdocs/luci-static/resources/homeproxy-pro.js:1154 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:145 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:183 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:154 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:196 #: htdocs/luci-static/resources/view/homeproxy-pro/client/nodes.js:141 #: htdocs/luci-static/resources/view/homeproxy-pro/client/subscription.js:72 #: htdocs/luci-static/resources/view/homeproxy-pro/node.js:484 @@ -3436,10 +3444,10 @@ msgstr "" msgid "v6" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:157 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:161 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:194 -#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:198 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:166 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:170 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:207 +#: htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js:211 msgid "valid DNS server address" msgstr "有效 DNS 服务器地址" diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/config/model.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/config/model.uc index 2fcb06742..d9bc8ab5d 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/config/model.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/config/model.uc @@ -20,7 +20,7 @@ 'use strict'; -import { isEmpty } from '../homeproxy-pro.uc'; +import { isEmpty, isValidCIDR } from '../homeproxy-pro.uc'; /* Canonical credential multiplexing for a Node. The current generator picks * these apart with ternaries per field (`username` vs `user` vs `password` vs @@ -268,11 +268,47 @@ export const Node = { problems = [...problems, `invalid port '${node.port}'`]; } - /* TLS without server_name is a sing-box-side rejection, but the - * rule itself is protocol-agnostic - the reality branch covers the - * only legitimate escape - so it stays here. */ - if (node.tls.enabled === '1' && !node.tls.server_name && node.tls.reality.enabled !== '1') - problems = [...problems, 'TLS enabled without server_name']; + /* TLS without an explicit server_name. + * + * sing-box defaults an outbound's tls.server_name to the + * outbound's server address, so a node whose address is a + * *hostname* works fine without one: the handshake sends that + * hostname as SNI and verifies the certificate against it. That + * is the documented default (outbound tls.server_name defaults to + * "server address"), not a leniency we invented. + * + * This check used to fire for both address shapes, and firing + * meant die() - OutboundFactory.create() treats a non-empty + * problems list as fatal. A trojan:// / anytls:// / hysteria2:// + * / tuic:// share link with no `sni=` parameter produces exactly + * such a node (each parser writes `tls_sni: params.sni` and + * nothing else), and so does the node form, whose "TLS SNI" + * field is an optional form.Value gated only on tls=1 + * (homeproxy-pro.js:672). As a main node it then took the whole + * client configuration down: + * + * node '
': TLS enabled without server_name + * + * Reproduced on a real router (2026-10-01, sing-box 1.14.2): + * with the SNI removed from the main node, buildable() is false + * and generate() dies - so a reload rolled back to the previous + * configuration and a boot start refused to come up, over a node + * sing-box would have dialed without complaint. + * + * The case that IS worth a problem is an IP address: the implicit + * SNI is then the literal IP, which no real server presents a + * matching certificate for, so the connection cannot succeed. + * That stays fatal for a node a rule or the main node points at + * (the existing contract for an unbuildable node) and is pruned + * with a warning inside a urltest group + * (generator/outbound.uc keep_candidate). reality is the escape + * hatch: it runs its own handshake and does not use server_name. + */ + if (node.tls.enabled === '1' && !node.tls.server_name + && node.tls.reality.enabled !== '1' + && (isValidCIDR(node.address, 4) || isValidCIDR(node.address, 6))) + problems = [...problems, + `TLS enabled on IP address ${node.address} without server_name`]; return problems; }, diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/dns.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/dns.uc index f3dfa1787..fc9695339 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/dns.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/dns.uc @@ -25,7 +25,7 @@ 'use strict'; -import { isEmpty, strToBool, strToInt, strToTime, parseURL, validation, parse_port } from '../homeproxy-pro.uc'; +import { isEmpty, strToBool, strToInt, strToTime, parseURL, validation, parse_port, isValidCIDR } from '../homeproxy-pro.uc'; import { get_outbound, get_resolver, get_ruleset } from './common.uc'; @@ -72,6 +72,32 @@ function parse_dnsquery(strquery) { return querys; } +/* parse_dnsserver() returns null for an address it cannot make sense of, and + * both call sites used to spread that null straight into the server object. + * Spreading null is not a no-op in ucode - it throws + * + * Type error: Value (null) is not iterable + * + * so one unusable DNS address took the generator down with an exception that + * names no field, and the user only saw a rollback. Reproduced on a router + * (2026-10-01) by putting a single-label value in the China DNS field: + * parseURL() only sets `hostname` when /sbin/validate_data accepts the value + * as a hostname, and a dotless name ('cafe', 'beef', 'decade') is not one. + * + * A DNS server with no address is not something the configuration can do + * without, so this fails the same way get_resolver()/get_ruleset() do - with + * the field name and a usable example - instead of substituting a working + * resolver the user did not ask for. */ +function require_dnsserver(addr, default_protocol, field) { + const server = parse_dnsserver(addr, default_protocol); + + if (!server) + die(sprintf("the %s '%s' is not a usable address; use a bare IP, or a DoH/DoT URL such as https://dns.google/dns-query.\n", + field, addr)); + + return server; +}; + /* --- proxy routing-mode path (bypass_mainland_china / proxy / / gfwlist) -- */ /* Domains whose NAPTR (qtype 35) queries bypass china-dns. Aliyun @@ -117,8 +143,34 @@ function bootstrap_addr(ctx) { if (isEmpty(addr)) return null; - return (match(addr, /^[0-9]+(\.[0-9]+){3}$/) != null - || match(addr, /^[0-9a-fA-F:]+$/) != null) ? addr : null; + /* isValidCIDR(), not a hand-rolled shape test. + * + * The pair of regexes this replaces accepted anything made only of hex + * digits and colons - `cafe`, `face`, `abc`, `add`, `dead:beef` are + * all perfectly good hostnames and all passed - and the IPv4 branch did + * not range-check the octets, so 999.999.999.999 passed too. Measured + * on the device (2026-10-01): all six returned an address. + * + * The cost of being wrong here is not one misbehaving resolver. + * append_bootstrap_dns() emits the bootstrap server deliberately + * *without* a domain_resolver of its own - existing is the whole point, + * it is what every other server bootstraps its own hostname through - + * so a hostname there can never be satisfied, and sing-box refuses to + * start at all. Verified against 1.14.2 with `server: "cafe"`: + * + * FATAL create service: initialize DNS server[0]: missing domain + * resolver for domain server address + * + * isValidCIDR() is the validator the firewall path already runs every + * resource value through for the same reason (a value that is not a + * real IP or CIDR reaches a root context), and it range-checks both + * families. Reusing it keeps one definition of "is this an address". + * + * Behaviour for every value this generator is actually pointed at is + * unchanged: 223.5.5.5, 1.12.12.21, fdfe::1 and the other literals the + * option documents all still pass, and 'wan' / empty / a real hostname + * still return null so main-dns keeps borrowing the WAN resolver. */ + return (isValidCIDR(addr, 4) || isValidCIDR(addr, 6)) ? addr : null; } /* Append the bootstrap resolver, when there is a usable address for it. @@ -164,7 +216,7 @@ function append_proxy_dns(config, dm, ctx) { strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : null }, detour: 'main-out', - ...parse_dnsserver(ctx.dns_server, 'tcp') + ...require_dnsserver(ctx.dns_server, 'tcp', 'DNS server') }); /* The DNS half of the mode's default policy. It has to name the same * side the route chain falls through to (ctx.proxy_fallback), or a @@ -213,7 +265,7 @@ function append_proxy_dns(config, dm, ctx) { strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : 'prefer_ipv6' }, detour: ctx.self_mark ? 'direct-out' : null, - ...parse_dnsserver(ctx.china_dns_server) + ...require_dnsserver(ctx.china_dns_server, null, 'China DNS server') }); /* Route NAPTR (qtype 35) queries for SIP/ENUM domains to the ISP diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh index 1bbc69156..9ef74a560 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh @@ -48,6 +48,45 @@ hp_require_singbox() { return 0 } +# hp_require_ucode +# Refuse to start when the installed ucode cannot load this package's own +# modules at all. Returns 1 (and logs) when the interpreter is unusable. +# +# Why a check and not a documented requirement: ucode resolves `import` when a +# module is LOADED, not when a function is called, and homeproxy-pro.uc imports +# +# import { access, lstat, mkdtemp, open, rmdir, unlink } from 'fs'; +# +# so on a ucode whose fs module has no `mkdtemp` export, *every* entry point +# into this package dies with the same one-line reference error at load time - +# the install-time migration (etc/uci-defaults/luci-homeproxy-pro-migration), +# both config generators, and the LuCI RPC module that imports homeproxy-pro.uc +# too. The first of those to run is the migration, which is why the only +# thing an affected user ever saw was a stack trace during `apk add` / +# `opkg install` (issue #3) and never a sentence naming the requirement. +# +# `mkdtemp` entered ucode on 2025-11-07 (lib/fs.c, "fs: add mkdtemp() method +# for creating temporary directories"). It replaced `mkstemp`, which every +# older ucode does export - so this is a hard floor, not a preference, and no +# ImmortalWrt 24.10 release can satisfy it. +# +# The probe is `ucode -e`, the same form tests/ucode/run.sh uses for its +# module syntax checks. Its own output is not discarded: if this ucode is so +# old that even the probe fails, that is exactly the information the operator +# needs, so the error goes into the log next to the requirement. +hp_require_ucode() { + local probe_out + + probe_out="$(ucode -e "import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)" 2>&1)" && return 0 + + log "Error: this build needs a ucode whose fs module exports mkdtemp (added in ucode 2025-11-07, shipped in ImmortalWrt 25.12); the installed ucode does not provide it." + log "Error: every script in this package imports it, so the service, the config generators and the LuCI RPC all fail to load on this firmware." + [ -n "$probe_out" ] && log "Error: the capability check itself reported: ${probe_out}" + log "Hint: this firmware's ucode is too old for this package; ImmortalWrt 25.12 or newer is required." + + return 1 +} + # hp_crontab_drop # Remove the auto-update entry from . # diff --git a/luci-app-homeproxy-pro/root/etc/init.d/homeproxy-pro b/luci-app-homeproxy-pro/root/etc/init.d/homeproxy-pro index 401367aed..0769b4ee0 100755 --- a/luci-app-homeproxy-pro/root/etc/init.d/homeproxy-pro +++ b/luci-app-homeproxy-pro/root/etc/init.d/homeproxy-pro @@ -191,6 +191,16 @@ start_service() { # a router whose node was removed (or whose sing-box just failed its # version gate) keeps redirecting the LAN into a listener that no instance # is serving any more. + # + # The ucode gate comes first because it is the more fundamental one: when + # the interpreter cannot load this package's modules, the generator below + # would fail too, and the sing-box version line is the less useful of the + # two messages. + if ! hp_require_ucode; then + hp_release_stale_intercept + return 1 + fi + if ! hp_require_singbox; then hp_release_stale_intercept return 1 diff --git a/luci-app-homeproxy-pro/tests/README.md b/luci-app-homeproxy-pro/tests/README.md index eb45c0ddc..1e7e420b1 100644 --- a/luci-app-homeproxy-pro/tests/README.md +++ b/luci-app-homeproxy-pro/tests/README.md @@ -293,7 +293,7 @@ Two further host differences are bridged so the remaining checks still run: | `tests/ucode/test_protocol_inventory.sh` | Cross-checks the protocol surface: every type named by `parse_uri.uc`, `CREDENTIALS`, `PROTOCOL_OPTIONS`, `REQUIRED_CREDENTIALS`, `OPTION_FIELDS` and the golden snapshot must agree. This is the check that catches "added a protocol to one table and forgot another". | | `tests/frontend-protocol-inventory.js` | The frontend's single ordered protocol table (`homeproxy-pro.js`'s `protocols`) against the backend tables that decide what the generators can build: every offered type must be modelled (`PROTOCOL_TO_UCI` on the client side, `INBOUND_CREDENTIALS` on the server side), every outbound-capable protocol (`OPTION_FIELDS`, `REQUIRED_CREDENTIALS`) must be selectable in the node form, both rendered orders are pinned, and protocols the backend models but no form offers must be listed as deliberate decisions. This is the check that would have caught `snell`: it had a form block, a credentials row and a golden outbound, but was missing from the node form's value list so it could not be selected. Node-only, no ucode needed. | | `tests/frontend-rpc-inventory.js` | The frontend RPC boundary: exactly one `rpc.declare` site (inside `homeproxy-pro.js`'s `rpcCall`), no view wrapping a call in `L.resolveDefault`, `rpcCall` itself catching and falling back and reporting, and no view left with an unused `require rpc`. Source-level on purpose - the behaviour it protects needs a browser, but "nobody reintroduces a second declaration site" does not. Comments are stripped before matching, because a naive grep matches the doc comment that quotes the old idiom. | -| `tests/frontend-validators.js` | Drives the shared form validators with a fake form context. The snapshots cannot cover these: a `validate` callback is a function, and the dump skips function-valued properties. Sharing the password validator between the node and server forms added the 2022-blake3 key-length check to the node form, which the server form had and the node form did not - a client could save a key that makes the generated configuration fail to decode. Reverse-proved: deleting that branch fails six checks. | +| `tests/frontend-validators.js` | Drives the shared form validators with a fake form context. The snapshots cannot cover these: a `validate` callback is a function, and the dump skips function-valued properties. Sharing the password validator between the node and server forms added the 2022-blake3 key-length check to the node form, which the server form had and the node form did not - a client could save a key that makes the generated configuration fail to decode. Reverse-proved: deleting that branch fails six checks. It also renders the client DNS tab to reach the inline `dns_server` validator, which pins the legacy `'wan'` literal: the dropdown stopped offering "WAN DNS (read from interface)" while the generator still maps `wan` to `wan_dns`, so a configuration that chose it while it was offered must stay saveable - every validator runs on save, and `wan` is neither a hostname nor an address. Reverse-proved: dropping the `'wan'` exception fails one check. | | `tests/lib/luci-module.js` | The off-target LuCI module loader (`'require x as y';` rewritten into a dependency lookup), shared by the snapshot renderer and the frontend invariant tests. Its default `_()` returns a LuCI String object with `.format`, because the form code calls both halves. | | `tests/runtime/test_config_transaction.sh` | The `runtime/` helpers `init.d/homeproxy-pro` leans on: the known-good copy, the fallback when generation produced nothing, the rollback, and the health probe. Pure shell, runs anywhere. | | `tests/runtime/test_dns_snippets.sh` | The dnsmasq snippet writer's incremental behaviour (review L6). Counts `init.d/dnsmasq restart` calls through a stub and asserts that an identical second write does *not* restart (a restart flushes every client's DNS cache), while a changed resource list, routing mode, DNS port or `ipv6_support` setting does. Also pins that a mode change drops the snippet the previous mode produced, and that removing a resource list removes its snippet so the next call is stable again. Pure shell, runs anywhere. | diff --git a/luci-app-homeproxy-pro/tests/arch-guard.sh b/luci-app-homeproxy-pro/tests/arch-guard.sh index cfcac8669..6f1d32cd6 100755 --- a/luci-app-homeproxy-pro/tests/arch-guard.sh +++ b/luci-app-homeproxy-pro/tests/arch-guard.sh @@ -2077,6 +2077,22 @@ echo "== guard 45: the bootstrap resolver is derived, not configured ==" # - a second DNS address next to the China one made no sense to read; # (b) a bare IP is the only usable value, because the option also accepts # 'wan' and a DoH/DoT URL, and a hostname cannot resolve a hostname. +# +# (b) is asserted on the *validator*, not on a hand-rolled regex. The guard +# used to require the two shape tests this function used to carry +# (/^[0-9]+(\.[0-9]+){3}$/ and /^[0-9a-fA-F:]+$/), and that is exactly what let +# a real bug through: the second one matches any hostname made only of hex +# digits and colons - cafe, face, abc, add, dead:beef - and the first one never +# range-checked the octets, so 999.999.999.999 passed too (measured on the +# device, 2026-10-01). The bootstrap server is emitted with no +# domain_resolver by design, so any of those is unsatisfiable and sing-box +# refuses to start at all: +# FATAL create service: initialize DNS server[0]: missing domain resolver +# for domain server address +# isValidCIDR() - the same validator the firewall path runs every address +# through - is the shared definition of "is this an address", and the ucode +# test tests/ucode/test_build_guards.uc pins the behaviour (bare v4/v6 in, +# 'wan' / a DoH URL / a hostname / an out-of-range address out). if grep -q "const addr = ctx.china_dns_server;" "$SCRIPTS/generator/dns.uc"; then pass "the bootstrap address is taken from the China DNS server" else @@ -2084,12 +2100,19 @@ else fail "separate option again, main-dns may fall back to the WAN resolver" fi -if grep -qE "match\(addr, /\^\[0-9\]\+" "$SCRIPTS/generator/dns.uc" && - grep -qE "match\(addr, /\^\[0-9a-fA-F:\]\+" "$SCRIPTS/generator/dns.uc"; then - pass "only a bare IP is accepted as the bootstrap address" +if grep -q "isValidCIDR(addr, 4) || isValidCIDR(addr, 6)" "$SCRIPTS/generator/dns.uc"; then + pass "only a bare IP is accepted as the bootstrap address (via isValidCIDR)" else fail "bootstrap_addr() no longer rejects a non-IP value - a DoH URL or the" fail "literal 'wan' is a hostname, and using one to resolve a hostname is a cycle" + fail "(it must go through isValidCIDR; a hand-rolled shape test accepted" + fail "'cafe' and 999.999.999.999, which made sing-box refuse to start)" +fi + +if grep -qE "match\(addr, /\^\[0-9" "$SCRIPTS/generator/dns.uc"; then + fail "a hand-rolled IP shape test is back in bootstrap_addr() - use isValidCIDR" +else + pass "no hand-rolled IP shape test in bootstrap_addr()" fi # The option is gone from the form and from the shipped config; leaving either diff --git a/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt b/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt index 29d7705aa..bec12979c 100644 --- a/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt +++ b/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt @@ -2,6 +2,7 @@ ===== scenario: A-tun-bypass-client-only (start) ===== uci -q show dhcp.@dnsmasq[0] config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n uname -r ip route show default @@ -152,6 +153,7 @@ file etc/homeproxy-pro/cache.db: ===== scenario: B-tproxy-gfwlist-ipv6-with-server (start) ===== uci -q show dhcp.@dnsmasq[0] config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n uname -r ip route show default @@ -344,6 +346,7 @@ file var/run/homeproxy-pro/known-good/uci-snapshot.txt: homeproxy-pro.config.mai ===== scenario: C-nothing-configured (start) ===== uci -q show dhcp.@dnsmasq[0] config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n log Neither client nor server is configured. Service not started. config_load homeproxy-pro @@ -423,6 +426,7 @@ file var/run/homeproxy-pro/fw4_post.nft: ===== scenario: D-health-gate-rollback (reload) ===== uci -q show dhcp.@dnsmasq[0] config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n uname -r ip route show default @@ -555,6 +559,7 @@ init.d/miniupnpd restart log Service stopped. config_load homeproxy-pro config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n uname -r ip route show default @@ -856,6 +861,7 @@ init.d/miniupnpd restart log Service stopped. config_load homeproxy-pro config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n uname -r ip route show default @@ -1012,6 +1018,7 @@ file dnsmasq/dnsmasq-homeproxy-pro.d/redirect-dns.conf: no-poll|no-resolv|server ===== scenario: E-custom-routing-runtime-files (start) ===== uci -q show dhcp.@dnsmasq[0] config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n uname -r ip route show default @@ -1163,6 +1170,7 @@ file etc/homeproxy-pro/cache.db: ===== scenario: F-custom-routing-nothing-configured (start) ===== uci -q show dhcp.@dnsmasq[0] config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n log Neither client nor server is configured. Service not started. config_load homeproxy-pro @@ -1242,6 +1250,7 @@ file var/run/homeproxy-pro/fw4_post.nft: ===== scenario: G-start-health-gate-failure (start) ===== uci -q show dhcp.@dnsmasq[0] config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n uname -r ip route show default @@ -1516,6 +1525,7 @@ file etc/homeproxy-pro/cache.db: ===== scenario: I-dns-snippet-write-failure (start) ===== uci -q show dhcp.@dnsmasq[0] config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n uname -r ip route show default @@ -1663,6 +1673,7 @@ file etc/homeproxy-pro/cache.db: ===== scenario: H-start-disables-client (start-twice) ===== uci -q show dhcp.@dnsmasq[0] config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n uname -r ip route show default @@ -1769,6 +1780,7 @@ sing-box version -n log sing-box 1.14.0 started. prime start rc=0 config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) sing-box version -n log Reverting the intercept layer; traffic will go out unproxied until the client is up. ip rule del fwmark 101 table 100 diff --git a/luci-app-homeproxy-pro/tests/frontend-validators.js b/luci-app-homeproxy-pro/tests/frontend-validators.js index 693a19c30..e008305ce 100644 --- a/luci-app-homeproxy-pro/tests/frontend-validators.js +++ b/luci-app-homeproxy-pro/tests/frontend-validators.js @@ -133,5 +133,100 @@ check('cert path ignores an empty value', check('cert path ignores a missing section', hp.validateCertificatePath(null, '/etc/passwd') === true); +/* --- dns_server: the legacy 'wan' value has to stay acceptable ---------- * + * The preset-mode "Overseas DNS server" field used to offer a 'wan' entry + * ("WAN DNS (read from interface)"). The entry is gone from the dropdown - + * the ISP resolver is the one thing that must not be picked there - but the + * literal is what a configuration saved back then still carries, and the + * generator keeps mapping it to wan_dns + * (root/etc/homeproxy-pro/scripts/generator/context.uc). Dropping the entry + * without keeping the validator in step made those configurations unsaveable: + * every field's validator runs on save, and 'wan' is neither a hostname nor + * an address, so the page refused to save a configuration that was working. + * + * The callback is inline in renderDnsCache(), so the form is rendered against + * a minimal Section mock and the stored validate is called the way a widget + * calls it - with the config section id, not null. + */ +function dnsServerValidate() { + const classes = [ 'Value', 'ListValue', 'Flag', 'DynamicList', 'MultiValue', 'TextValue', + 'Button', 'TypedSection', 'NamedSection', 'GridSection', 'SectionValue' ]; + const formMock = {}; + for (const name of classes) { + formMock[name] = class {}; + formMock[name].__name__ = name; + } + + let captured = null; + const sectionMock = { + tab() {}, + taboption(_tab, kind, name, title, description) { return this.option(kind, name, title, description); }, + option(kind, name, title, description) { + const option = { kind, name, title, description, value() {}, depends() {} }; + if (kind && kind.__name__ === 'SectionValue') + option.subsection = sectionMock; + if (name === 'dns_server') + captured = option; + return option; + } + }; + + const dns = loadLuciModule( + path.join(root, 'htdocs/luci-static/resources/view/homeproxy-pro/client/dns.js'), + { + baseclass: { extend: (o) => o }, + form: formMock, + uci: { load: () => {}, get: () => undefined }, + homeproxy-pro: {}, + 'view.homeproxy-pro.client.common': {} + }); + + /* Only the datatypes this validator asks for are implemented. A stub + * that accepted everything would also pass the 'wan' check below, which + * is why the empty-value and not-an-address checks are here too. */ + const stubValidator = { + value: null, + apply(type, value) { + if (value != null) + this.value = value; + switch (type) { + case 'ip4addr': + return /^\d{1,3}(\.\d{1,3}){3}$/.test(this.value) && + this.value.split('.').every((octet) => parseInt(octet, 10) <= 255); + case 'ip6addr': + return typeof this.value === 'string' && this.value.includes(':'); + case 'ipaddr': + return this.apply('ip4addr', this.value) || this.apply('ip6addr', this.value); + case 'hostname': + return /^[A-Za-z0-9.-]+$/.test(this.value) && + !/^\d{1,3}(\.\d{1,3}){3}$/.test(this.value); + default: + return true; + } + }, + assert: (condition) => !!condition + }; + + dns.renderDnsCache({ s: sectionMock, stubValidator }); + + const validator = captured && captured.validate; + if (typeof validator !== 'function') + throw new Error('dns.js no longer registers a dns_server validator'); + + const ctx = { section: { formvalue: () => '0' } }; + return (value) => validator.call(ctx, 'config', value); +} + +const validateDnsServer = dnsServerValidate(); + +check("dns_server accepts the legacy 'wan' literal the generator still maps", + validateDnsServer('wan') === true); +check('dns_server still accepts a bare IP', validateDnsServer('1.1.1.1') === true); +check('dns_server still accepts a DoH endpoint', + validateDnsServer('https://cloudflare-dns.com/dns-query') === true); +check('dns_server still rejects an empty value', isError(validateDnsServer(''))); +check('dns_server still rejects a value that is neither a hostname nor an address', + isError(validateDnsServer('not a dns server'))); + console.log(`frontend validators: ${checks} checks, ${failures} failures`); process.exit(failures ? 1 : 0); diff --git a/luci-app-homeproxy-pro/tests/snapshots/client.json b/luci-app-homeproxy-pro/tests/snapshots/client.json index 96177f007..9c702ca35 100644 --- a/luci-app-homeproxy-pro/tests/snapshots/client.json +++ b/luci-app-homeproxy-pro/tests/snapshots/client.json @@ -3307,14 +3307,14 @@ "props": { "default": "8.8.8.8", "rmempty": false, - "validate": "fn(730:48f7f360fbd2)" + "validate": "fn(758:77dbe8107e5c)" } }, { "kind": "Value", "name": "china_dns_server", "title": "China DNS server", - "description": "Resolves the domains on the China path, so it has to be a domestic service to get the correct local CDN answers. Keep the default; the upstream may block UDP/53, in which case a DoH address (https://dns.alidns.com/dns-query, https://doh.pub/dns-query) also works. Support UDP, TCP, DoH, DoQ, DoT.", + "description": "Resolves the domains on the China path, so it has to be a domestic service to get the correct local CDN answers. Keep the default; the upstream may block UDP/53, in which case a DoH address (https://dns.alidns.com/dns-query, https://doh.pub/dns-query) also works. Support UDP, TCP, DoH, DoQ, DoT. This address is also the resolver the proxy DNS server above uses to look up its own hostname when that one is a domain - and a URL here is a domain, which cannot do that lookup itself, so with a URL here the proxy DNS server falls back to the WAN resolver. Enter the bare IP form of the same service to keep that lookup on this router.", "depends": [ [ "routing_mode", diff --git a/luci-app-homeproxy-pro/tests/ucode/run.sh b/luci-app-homeproxy-pro/tests/ucode/run.sh index c7c29e9f0..e26c6052d 100755 --- a/luci-app-homeproxy-pro/tests/ucode/run.sh +++ b/luci-app-homeproxy-pro/tests/ucode/run.sh @@ -514,4 +514,12 @@ sh "$ROOT/tests/ucode/test_protocol_inventory.sh" "$ROOT" "$WORK/inventory" || F echo "== domain model skeleton ==" sh "$ROOT/tests/ucode/test_domain_model_skeleton.sh" "$ROOT" "$WORK/domain_model" || FAILED=1 +echo "== build guards (TLS server_name, bootstrap address) ==" +# The two rules that decide whether a configuration can be built at all. Both +# used to be fatal rejections of configurations sing-box accepts, so a node +# without an SNI (every share link with no `sni=`) took the main outbound - +# and with it the whole client configuration - down. Pure functions, so this +# one needs no fixture and no confdir. +sh "$ROOT/tests/ucode/test_build_guards.sh" "$ROOT" "$WORK/build_guards" || FAILED=1 + exit $FAILED diff --git a/luci-app-homeproxy-pro/tests/ucode/test_build_guards.sh b/luci-app-homeproxy-pro/tests/ucode/test_build_guards.sh new file mode 100644 index 000000000..c4a7d60fe --- /dev/null +++ b/luci-app-homeproxy-pro/tests/ucode/test_build_guards.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +# +# The two "can this configuration be built at all" guards: Node.validate's +# TLS/server_name rule and bootstrap_addr's address check. See +# tests/ucode/test_build_guards.uc for why both used to be fatal rejections +# of configurations sing-box accepts. +# +# Neither rule reads UCI, so this stages the modules the two of them import +# and nothing else - no confdir, no fixture. That is what makes the test +# trustworthy on a target: `cursor()` returns null there, so a UCI-based +# test would read an empty configuration and pass vacuously. +# +# Staging layout (mirrors production relative imports): +# $WORK/scripts/homeproxy-pro.uc - the real module, validate_data path rewritten +# $WORK/scripts/config/model.uc - Node / CREDENTIALS tables +# $WORK/scripts/config/adapter.uc - OutboundFactory +# $WORK/scripts/generator/dns.uc - build_dns +# $WORK/scripts/generator/common.uc - get_outbound / get_resolver +# $WORK/scripts/parser/*.uc - model.uc -> '../homeproxy-pro.uc', loader's mapping +# $WORK/scripts/test_build_guards.uc - the test +# +# Usage: sh tests/ucode/test_build_guards.sh [work-dir] + +ROOT="${1:-.}" +WORK="${2:-/tmp/hp-build-guards-test}" + +ROOT="$(cd "$ROOT" && pwd)" +FAILED=0 + +rm -rf "$WORK" +mkdir -p "$WORK/scripts/config" "$WORK/scripts/generator" "$WORK/scripts/parser" + +# HP_VALIDATE_DATA lets a development host replace /sbin/validate_data (see +# tests/README.md); on a target the production path is kept. The test itself +# never calls it - build_dns() parses the DNS address through parseURL() on +# its way to the block - but homeproxy-pro.uc has to load. +VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}" +sed -e "s#/sbin/validate_data#${VALIDATE_DATA}#" \ + "$ROOT/root/etc/homeproxy-pro/scripts/homeproxy-pro.uc" > "$WORK/scripts/homeproxy-pro.uc" + +cp "$ROOT/root/etc/homeproxy-pro/scripts/config/model.uc" "$WORK/scripts/config/" +cp "$ROOT/root/etc/homeproxy-pro/scripts/config/adapter.uc" "$WORK/scripts/config/" +cp "$ROOT/root/etc/homeproxy-pro/scripts/generator/dns.uc" "$WORK/scripts/generator/" +cp "$ROOT/root/etc/homeproxy-pro/scripts/generator/common.uc" "$WORK/scripts/generator/" +cp "$ROOT/root/etc/homeproxy-pro/scripts/parser/"*.uc "$WORK/scripts/parser/" +cp "$ROOT/tests/ucode/test_build_guards.uc" "$WORK/scripts/" + +if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" test_build_guards.uc ); then + echo "PASS: build guards (TLS server_name, bootstrap address)" +else + echo "FAIL: build guards (TLS server_name, bootstrap address)" + FAILED=1 +fi + +exit $FAILED diff --git a/luci-app-homeproxy-pro/tests/ucode/test_build_guards.uc b/luci-app-homeproxy-pro/tests/ucode/test_build_guards.uc new file mode 100644 index 000000000..855a35ad4 --- /dev/null +++ b/luci-app-homeproxy-pro/tests/ucode/test_build_guards.uc @@ -0,0 +1,269 @@ +#!/usr/bin/ucode +/* + * SPDX-License-Identifier: GPL-2.0-only + * + * Guards that decide whether a configuration can be BUILT at all, as opposed + * to guards that decide what it contains. Both of the rules pinned here used + * to reject configurations sing-box accepts, and both rejections were fatal + * rather than noisy, so each one could take the proxy down over a value the + * user never thought was load-bearing. + * + * 1. config/model.uc Node.validate - "TLS enabled without server_name" + * fired for every TLS node that had no explicit SNI, including the + * ordinary case of a share link with no `sni=` parameter and a node + * form that lets you save one. sing-box defaults an outbound's + * tls.server_name to the outbound's server address, so a hostname + * address needs no SNI at all. As a main node the node failed + * OutboundFactory.create() -> die(), i.e. `node '': TLS enabled + * without server_name`, the reload rolled back to the previous + * configuration and a boot start refused to come up. + * Reproduced on a router (2026-10-01, sing-box 1.14.2). + * The rule is now scoped to IP addresses, where the implicit SNI is + * the literal IP and the connection genuinely cannot succeed. + * + * 2. generator/dns.uc bootstrap_addr - the two regexes it used accepted + * any hostname made only of hex digits and colons (cafe, face, abc, + * add, dead:beef) and an out-of-range IPv4 (999.999.999.999). The + * bootstrap server is emitted with no domain_resolver on purpose, so a + * hostname there is unsatisfiable and sing-box refuses to start: + * FATAL create service: initialize DNS server[0]: missing domain + * resolver for domain server address + * It now goes through isValidCIDR(), like every other address that + * reaches a root context. + * + * Everything here is a pure function of its arguments - no UCI cursor, no + * confdir, no files. That is deliberate: `cursor()` returns null on the + * target (measured 2026-10-01), and a test that loads UCI through a sandbox + * confdir therefore reads an empty configuration there and passes + * vacuously. Asserting the guards directly needs neither. + * + * Run from tests/ucode/test_build_guards.sh. + */ + +'use strict'; + +import { Node } from './config/model.uc'; +import { OutboundFactory } from './config/adapter.uc'; +import { build_dns } from './generator/dns.uc'; + +let failures = 0; +let checks = 0; + +function ok(name, condition, detail) { + checks++; + if (!condition) { + printf('FAIL %s%s\n', name, detail === null ? '' : sprintf(': %J', detail)); + failures++; + } +} + +function expect_problems(name, node, expected) { + checks++; + const got = OutboundFactory.problems(node); + if (sprintf('%J', got) !== sprintf('%J', expected)) { + printf('FAIL %s: expected problems %J, got %J\n', name, expected, got); + failures++; + } +} + +/* A node as the Loader builds it: the nested sub-objects, not a flat dict. */ +function node(address, tls_enabled, server_name, reality) { + return Node.create({ + id: 'n1', + name: 'test', + type: 'trojan', + address: address, + port: '443', + credentials: { password: 'secret' }, + protocol_options: {}, + common: {}, + multiplex: {}, + transport: { type: null }, + tls: { + enabled: tls_enabled, + server_name: server_name, + insecure: '0', + reality: { enabled: reality || '0' } + } + }); +} + +/* --- 1. the TLS / server_name rule -------------------------------------- */ + +/* The regression: a share link with no `sni=` parameter, on a hostname. This + * is the shape the trojan/anytls/hysteria2/tuic parsers produce. */ +expect_problems('hostname address, TLS, no server_name', + node('bwg.example.com', '1', null, null), []); +ok('... is buildable', + OutboundFactory.buildable(node('bwg.example.com', '1', null, null)) === true); + +/* ... and the whole point: the outbound is actually produced, with a TLS + * block that lets sing-box fall back to the address as the SNI. */ +{ + const built = OutboundFactory.tryCreate(node('bwg.example.com', '1', null, null), '100'); + checks++; + if (length(built.problems) || built.outbound === null) { + printf('FAIL outbound build for a sni-less hostname node: %J\n', built.problems); + failures++; + } else if (built.outbound.tls?.enabled !== true || 'server_name' in built.outbound.tls) { + printf('FAIL outbound tls shape: expected { enabled: true } with no server_name, got %J\n', + built.outbound.tls); + failures++; + } +} + +/* The rule still fires where it is right to: an IP address with no SNI gets + * SNI=IP, which no real certificate answers to. */ +expect_problems('IPv4 address, TLS, no server_name', + node('1.2.3.4', '1', null, null), + ['TLS enabled on IP address 1.2.3.4 without server_name']); +expect_problems('IPv6 address, TLS, no server_name', + node('2001:67c:4e8::1', '1', null, null), + ['TLS enabled on IP address 2001:67c:4e8::1 without server_name']); + +/* reality runs its own handshake, so it never needed server_name. */ +expect_problems('IP address, TLS, reality, no server_name', + node('1.2.3.4', '1', null, '1'), []); + +/* An explicit SNI is and always was fine, on either address shape. */ +expect_problems('hostname address, TLS, explicit server_name', + node('bwg.example.com', '1', 'cdn.example.net', null), []); +expect_problems('IP address, TLS, explicit server_name', + node('1.2.3.4', '1', 'cdn.example.net', null), []); + +/* No TLS at all: the rule never applied, and still does not. */ +expect_problems('hostname address, no TLS, no server_name', + node('bwg.example.com', '0', null, null), []); + +/* The rule must not have shadowed the cross-protocol checks. */ +expect_problems('still reports a missing credential', + Node.create({ + id: 'n2', name: 't', type: 'trojan', address: 'bwg.example.com', port: '443', + credentials: {}, protocol_options: {}, common: {}, multiplex: {}, + transport: { type: null }, + tls: { enabled: '1', server_name: null, insecure: '0', reality: { enabled: '0' } } + }), + ['trojan requires password']); +expect_problems('still reports an out-of-range port', + Node.create({ + id: 'n3', name: 't', type: 'trojan', address: 'bwg.example.com', port: '70000', + credentials: { password: 'x' }, protocol_options: {}, common: {}, multiplex: {}, + transport: { type: null }, + tls: { enabled: '1', server_name: null, insecure: '0', reality: { enabled: '0' } } + }), + ["invalid port '70000'"]); + +/* --- 2. the bootstrap resolver address ---------------------------------- */ + +/* append_bootstrap_dns() and append_proxy_dns() are private, so the guards are + * asserted through the public entry point: what ends up in the dns block, and + * what build_dns() does with a China DNS value it cannot use. + * + * `mode` is 'built' (the block was produced) or 'died' (build_dns refused, + * naming the field). The "died" case is the point of the third rule below: a + * value the option cannot express used to reach the generator as `...null` + * and throw "Value (null) is not iterable", which names no field at all. */ +function dns_for(china_dns_server) { + const config = { }; + const ctx = { + routing_mode: 'bypass_mainland_china', + proxy_mode: 'redirect_tproxy', + main_node: 'n1', + main_udp_node: 'nil', + ipv6_support: '0', + log_level: 'warn', + self_mark: '100', + wan_dns: '223.5.5.5', + dns_server: 'https://dns.google/dns-query', + china_dns_server: china_dns_server, + cn_ip_fallback: '0', + sniffer_advanced_mode: '0', + direct_domain_list: [], + proxy_domain_list: [] + }; + + try { + build_dns(config, { dns: { servers: [], rules: [] } }, ctx); + } catch (e) { + return { died: sprintf('%s', e.message) }; + } + + return { died: null, config: config }; +} + +function bootstrap_server(addr) { + const r = dns_for(addr); + if (r.died !== null) + return null; + + for (let s in (r.config.dns?.servers || [])) + if (s.tag === 'bootstrap-dns') + return s; + + return null; +} + +/* The values the option documents, plus the other family. A bare IP is the + * one thing a bootstrap server can be: it has no domain_resolver of its own, + * so it must be able to answer without one. */ +ok('bare IPv4 is used as the bootstrap address', + bootstrap_server('223.5.5.5')?.server === '223.5.5.5', + bootstrap_server('223.5.5.5')); +ok('bare IPv6 is used as the bootstrap address', + bootstrap_server('fdfe::1')?.server === 'fdfe::1', + bootstrap_server('fdfe::1')); + +/* A real hostname was never a candidate: main-dns keeps borrowing the WAN + * resolver, which is the documented behaviour for 'wan' and for a host name. */ +ok('a dotted hostname yields no bootstrap server', + bootstrap_server('dns.alidns.com') === null, + bootstrap_server('dns.alidns.com')); +ok("the 'wan' sentinel yields no bootstrap server", + bootstrap_server('wan') === null, + bootstrap_server('wan')); +ok('an empty value yields no bootstrap server', + bootstrap_server('') === null, + bootstrap_server('')); + +/* Every one of these used to be accepted as a bare address by the regex pair + * bootstrap_addr() carried - a hostname made only of hex digits and colons, + * or an IPv4 whose octets were never range-checked. Measured on the target, + * all seven were emitted as a `bootstrap-dns` server address, and that + * server has no domain_resolver by design, so sing-box refused to start: + * + * FATAL create service: initialize DNS server[0]: missing domain resolver + * for domain server address + * + * The invariant pinned here is that no China DNS value can produce a + * bootstrap server unless it is a real address. */ +for (let bad in ['cafe', 'face', 'abc', 'add', 'dead:beef', '999.999.999.999', '1.2.3']) { + checks++; + if (bootstrap_server(bad) !== null) { + printf('FAIL china_dns_server %s: emitted a bootstrap-dns server (%J)\n', + bad, bootstrap_server(bad)); + failures++; + } +} + +/* And the failure mode for a value the option cannot express at all is a + * refusal that names the field. Spreading the null parse_dnsserver() returns + * used to throw "Value (null) is not iterable" instead - an exception naming + * no field, exit 254, rollback. + * + * Which values land here is a property of /sbin/validate_data, not of this + * generator: a dotless name is a valid *hostname* to it, so 'cafe' is carried + * through to china-dns unchanged, while 'dead:beef' (a colon with no port) + * is not parseable at all. Both are accepted here; only the second may die, + * and it has to die by name. */ +for (let bad in ['cafe', 'face', 'abc', 'add', 'dead:beef', '999.999.999.999', '1.2.3']) { + checks++; + const died = dns_for(bad).died; + if (died !== null && (!match(died, /China DNS server/) || match(died, /not iterable/))) { + printf('FAIL china_dns_server %s: died with %J (want null, or a message naming the field)\n', + bad, died); + failures++; + } +} + +printf('%d checks, %d failures\n', checks, failures); +exit(failures === 0 ? 0 : 1); diff --git a/luci-app-linkease/Makefile b/luci-app-linkease/Makefile index a83ae18f4..9a390e89e 100644 --- a/luci-app-linkease/Makefile +++ b/luci-app-linkease/Makefile @@ -7,11 +7,11 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for linkease LUCI_DEPENDS:=+linkease +luci-lib-linkeasefile -LUCI_EXTRA_DEPENDS:=luci-lib-linkeasefile (>= 2.1.70-r4) +LUCI_EXTRA_DEPENDS:=luci-lib-linkeasefile (>=2.1.70-r4) LUCI_PKGARCH:=all PKG_VERSION:=2.1.70-r4 # PKG_RELEASE MUST be empty for luci.mk -PKG_RELEASE:=5 +PKG_RELEASE:=6 LUCI_MINIFY_CSS:=0 LUCI_MINIFY_JS:=0 diff --git a/luci-app-meow/htdocs/luci-static/resources/tools/meow.js b/luci-app-meow/htdocs/luci-static/resources/tools/meow.js index f84b1ea23..822f5c166 100644 --- a/luci-app-meow/htdocs/luci-static/resources/tools/meow.js +++ b/luci-app-meow/htdocs/luci-static/resources/tools/meow.js @@ -4,6 +4,10 @@ 'require fs'; 'require uci'; +// Shared helpers for the meow LuCI views. Runtime data comes straight from +// the meow REST API through authenticated LuCI RPC. The standalone panel +// uses the HTTP endpoint configured by the UCI panel_port and secret options. + var callServiceList = rpc.declare({ object: 'service', method: 'list', @@ -14,17 +18,25 @@ var callServiceList = rpc.declare({ return baseclass.extend({ apiBase: function() { var port = uci.get('meow', 'main', 'panel_port') || '9090'; - return 'http://' + window.location.hostname + ':' + port; + return 'http://' + window.location.hostname + + ':' + port; }, + secret: function() { return uci.get('meow', 'main', 'secret') || ''; }, + + // URL of the built-in web panel; the secret travels in the fragment so it + // is never sent to the server (the panel stores it in its localStorage). panelURL: function() { var url = this.apiBase() + '/ui'; var secret = this.secret(); return secret ? url + '#token=' + encodeURIComponent(secret) : url; }, + api: function(method, path, body) { + // LuCI RPC stays on the page's authenticated origin, including HTTPS. + // The helper connects to loopback and reads the API secret from UCI. var args = [method, path]; if (body != null) args.push(JSON.stringify(body)); return fs.exec('/usr/libexec/meow-api', args).then(function(res) { @@ -33,6 +45,7 @@ return baseclass.extend({ return (res.stdout || '').trim() ? JSON.parse(res.stdout) : null; }); }, + serviceRunning: function() { return L.resolveDefault(callServiceList('meow'), {}).then(function(res) { try { @@ -42,6 +55,7 @@ return baseclass.extend({ } }); }, + formatBytes: function(b) { return '%1024.2mB'.format(b || 0); } diff --git a/luci-app-meow/htdocs/luci-static/resources/tools/meow_settings.js b/luci-app-meow/htdocs/luci-static/resources/tools/meow_settings.js index 7343cce1a..9d99015b0 100644 --- a/luci-app-meow/htdocs/luci-static/resources/tools/meow_settings.js +++ b/luci-app-meow/htdocs/luci-static/resources/tools/meow_settings.js @@ -3,6 +3,109 @@ 'require fs'; 'require request'; 'require uci'; +'require tools.meow_yaml as yaml'; + +// This name is also used by the package's default YAML. Other named listeners +// belong to the user; never replace the entire listeners array. +var LISTENER = 'tproxy-lan'; + +function port(value, label) { + if (!/^\d+$/.test(String(value)) || Number(value) < 1 || Number(value) > 65535) + throw new Error(_('Invalid port: ') + label); + return Number(value); +} + +function transform(content, settings) { + var doc = yaml.parseDocument(content); + if (doc.errors.length) + throw new Error(doc.errors[0].message); + if (!yaml.isMap(doc.contents)) + throw new Error(_('The YAML configuration must be a mapping.')); + var config = doc.toJS(); + var panelPort = port(settings.panel_port, _('Panel port')); + var enabled = settings.enabled === '1'; + var ipv6 = settings.ipv6 === '1'; + var udp = settings.mode === 'tproxy'; + if (enabled && settings.mode !== 'redirect' && !udp) + throw new Error(_('Unknown transparent proxy mode.')); + if (enabled && ipv6 && udp) + throw new Error(_('IPv6 capture requires REDIRECT mode; UDP TPROXY supports IPv4 only.')); + + doc.set('external-controller', (settings.secret ? '0.0.0.0:' : '127.0.0.1:') + panelPort); + doc.set('secret', settings.secret || ''); + + var listeners = doc.get('listeners', true), managed; + if (listeners && !yaml.isSeq(listeners)) + throw new Error(_('listeners must be a YAML sequence.')); + if (listeners) { + // Resolve aliases when identifying managed entries, but retain the AST + // of unrelated entries (including their comments and anchors). + listeners.items = listeners.items.filter(function(item, i) { + if (config.listeners[i] && config.listeners[i].name === LISTENER) { + managed = yaml.isMap(item) ? item : doc.createNode(config.listeners[i]); + return false; + } + return true; + }); + } + if (enabled) { + var proxyPort = port(settings.tproxy_port, _('Tproxy port')); + var dnsPort = settings.dns_hijack === '1' ? port(settings.dns_port, _('DNS port')) : null; + if (proxyPort === panelPort || proxyPort === dnsPort || panelPort === dnsPort) + throw new Error(_('Panel, transparent proxy and DNS ports must be different.')); + (config.listeners || []).forEach(function(listener) { + if (listener && listener.name !== LISTENER && Number(listener.port) === proxyPort) + throw new Error(_('Transparent proxy port is already used by listener: ') + listener.name); + }); + ['port', 'socks-port', 'mixed-port'].forEach(function(key) { + if (Number(config[key]) === proxyPort) + throw new Error(_('Transparent proxy port is already used by ') + key); + }); + if (!listeners) { + doc.set('listeners', doc.createNode([])); + listeners = doc.get('listeners', true); + } + managed = managed || doc.createNode({ name: LISTENER, 'max-connections': 4096 }); + Object.entries({ type: 'tproxy', listen: ipv6 ? '::' : '0.0.0.0', + port: proxyPort, udp: udp, firewall: false }).forEach(function(entry) { + managed.set(entry[0], entry[1]); + }); + listeners.add(managed); + // The shorthand installs router-local firewall rules. Replace it with + // the explicitly externally-managed gateway listener. + doc.delete('tproxy-port'); + doc.set('routing-mark', 9527); + if (ipv6) + doc.set('ipv6', true); + if (dnsPort !== null) { + if (config.dns != null && (typeof config.dns !== 'object' || Array.isArray(config.dns))) + throw new Error(_('dns must be a YAML mapping.')); + // Materialize an aliased DNS mapping before editing it, avoiding + // changes to any other consumer of that anchor. + if (!yaml.isMap(doc.get('dns', true))) + doc.set('dns', doc.createNode(config.dns || {})); + // Subscriptions may resolve proxy hostnames through this DNS server. + // Moving dns.listen must move those self-references too; keeping the + // old port leaves proxy connections unable to resolve their servers. + var oldListen = config.dns && config.dns.listen; + var oldPort = typeof oldListen === 'string' && oldListen.match(/:(\d+)$/); + var proxyDns = config.dns && config.dns['proxy-server-nameserver']; + if (oldPort && Array.isArray(proxyDns)) { + proxyDns.forEach(function(server, i) { + var local = typeof server === 'string' && server.match(/^(udp:\/\/)?(127\.0\.0\.1|localhost|\[::1\])(?::(\d+))?([#?].*)?$/); + if (local && Number(local[3] || 53) === Number(oldPort[1])) + doc.setIn(['dns', 'proxy-server-nameserver', i], + (local[1] || '') + '127.0.0.1:' + dnsPort + (local[4] || '')); + }); + } + doc.setIn(['dns', 'enable'], true); + doc.setIn(['dns', 'listen'], '0.0.0.0:' + dnsPort); + if (!config.dns || !config.dns.nameserver || !config.dns.nameserver.length) + doc.setIn(['dns', 'nameserver'], ['223.5.5.5', '1.1.1.1']); + } + } + return doc.toString({ lineWidth: 0 }); +} function write(path, content) { var data = new FormData(); @@ -18,41 +121,63 @@ function write(path, content) { }); } -function validate(content) { - var token = Array.from(crypto.getRandomValues(new Uint32Array(4)), function(n) { - return n.toString(16).padStart(8, '0'); - }).join(''); - var scratch = '/tmp/meow-luci-settings-' + token + '.yaml'; - return write(scratch, content).then(function() { - return fs.exec('/usr/libexec/meow-validate', [token]); - }).then(function(res) { - if (res.code !== 0) - throw new Error(((res.stdout || '') + (res.stderr || '')).replace(/\x1b\[[0-9;]*m/g, '').trim() || - _('Configuration test failed')); - }).finally(function() { - return fs.remove(scratch).catch(function() {}); - }); +function values() { + function get(section, key, fallback) { + var value = uci.get('meow', section, key); + return value == null || value === '' ? fallback : value; + } + return { + panel_port: get('main', 'panel_port', '9090'), secret: get('main', 'secret', ''), + enabled: get('tproxy', 'enabled', '0'), mode: get('tproxy', 'mode', 'tproxy'), + tproxy_port: get('tproxy', 'tproxy_port', '7893'), + dns_hijack: get('tproxy', 'dns_hijack', '1'), dns_port: get('tproxy', 'dns_port', '1053'), + ipv6: get('tproxy', 'ipv6', '0') + }; } return baseclass.extend({ - transform: function(content) { return content; }, - prepare: function(content) { return content; }, + transform: transform, + prepare: function(content) { return transform(content, values()); }, + + // Called after form.parse(), before UCI is saved/applied. The returned + // rollback is used if the subsequent UCI save fails. save: function() { var path = uci.get('meow', 'main', 'config_file') || '/etc/meow/config.yaml'; - if (path !== '/etc/meow/config.yaml') - return Promise.reject(new Error(_('LuCI edits require /etc/meow/config.yaml.'))); - var self = this; - var original; + if (path !== '/etc/meow/config.yaml' || (uci.get('meow', 'main', 'work_dir') || '/etc/meow') !== '/etc/meow') + return Promise.reject(new Error(_('LuCI edits require /etc/meow/config.yaml and working directory /etc/meow.'))); + // getRandomValues also works on plain HTTP LuCI pages. + var token = Array.from(crypto.getRandomValues(new Uint32Array(4)), function(n) { + return n.toString(16).padStart(8, '0'); + }).join(''); + var scratch = '/tmp/meow-luci-settings-' + token + '.yaml'; + var original, candidate; return fs.read_direct(path).then(function(content) { original = content; - return validate(content); + candidate = transform(content, values()); + return write(scratch, candidate); + }).then(function() { + return fs.exec('/usr/libexec/meow-validate', [token]); + }).then(function(res) { + if (res.code !== 0) { + var output = ((res.stdout || '') + (res.stderr || '')).replace(/\x1b\[[0-9;]*m/g, ''); + var errors = output.split('\n').filter(function(line) { return /ERROR|Error/.test(line); }); + throw new Error(errors.join('\n') || _('Configuration test failed; settings were not saved.')); + } + return fs.read_direct(path); + }).then(function(current) { + if (current !== original) + throw new Error(_('Configuration changed during validation. Reload and try again.')); + return candidate === original ? null : write(path, candidate); }).then(function() { return function() { return fs.read_direct(path).then(function(current) { - if (current !== original) - throw new Error(_('Cannot restore YAML: configuration changed by another session.')); + if (current !== candidate) + throw new Error(_('Cannot restore YAML: configuration was changed by another session.')); + return write(path, original); }); }; + }).finally(function() { + return fs.remove(scratch).catch(function() {}); }); } }); diff --git a/luci-app-meow/htdocs/luci-static/resources/tools/meow_yaml.LICENSE b/luci-app-meow/htdocs/luci-static/resources/tools/meow_yaml.LICENSE index f5aea6554..e060aaa1f 100644 --- a/luci-app-meow/htdocs/luci-static/resources/tools/meow_yaml.LICENSE +++ b/luci-app-meow/htdocs/luci-static/resources/tools/meow_yaml.LICENSE @@ -8,5 +8,6 @@ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS -OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/luci-app-meow/htdocs/luci-static/resources/tools/meow_yaml.js b/luci-app-meow/htdocs/luci-static/resources/tools/meow_yaml.js index a3cc55039..7b9df27d2 100644 --- a/luci-app-meow/htdocs/luci-static/resources/tools/meow_yaml.js +++ b/luci-app-meow/htdocs/luci-static/resources/tools/meow_yaml.js @@ -1,10 +1,133 @@ 'use strict'; 'require baseclass'; +// Generated by npm ci && npm run build in openwrt/luci-app-meow; yaml 2.9.1 (ISC). +var MeowYaml=(()=>{var vt=Object.defineProperty;var zs=Object.getOwnPropertyDescriptor;var Zs=Object.getOwnPropertyNames;var en=Object.prototype.hasOwnProperty;var tn=(s,e)=>{for(var t in e)vt(s,t,{get:e[t],enumerable:!0})},sn=(s,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of Zs(e))!en.call(s,i)&&i!==t&&vt(s,i,{get:()=>e[i],enumerable:!(n=zs(e,i))||n.enumerable});return s};var nn=s=>sn(vt({},"__esModule",{value:!0}),s);var Kn={};tn(Kn,{isMap:()=>R,isSeq:()=>F,parseDocument:()=>as});var We=Symbol.for("yaml.alias"),He=Symbol.for("yaml.document"),U=Symbol.for("yaml.map"),$t=Symbol.for("yaml.pair"),q=Symbol.for("yaml.scalar"),ne=Symbol.for("yaml.seq"),K=Symbol.for("yaml.node.type"),V=s=>!!s&&typeof s=="object"&&s[K]===We,H=s=>!!s&&typeof s=="object"&&s[K]===He,R=s=>!!s&&typeof s=="object"&&s[K]===U,T=s=>!!s&&typeof s=="object"&&s[K]===$t,E=s=>!!s&&typeof s=="object"&&s[K]===q,F=s=>!!s&&typeof s=="object"&&s[K]===ne;function L(s){if(s&&typeof s=="object")switch(s[K]){case U:case ne:return!0}return!1}function I(s){if(s&&typeof s=="object")switch(s[K]){case We:case U:case q:case ne:return!0}return!1}var Xe=s=>(E(s)||L(s))&&!!s.anchor;var j=Symbol("break visit"),cs=Symbol("skip children"),X=Symbol("remove node");function z(s,e){let t=fs(e);H(s)?we(null,s.contents,t,Object.freeze([s]))===X&&(s.contents=null):we(null,s,t,Object.freeze([]))}z.BREAK=j;z.SKIP=cs;z.REMOVE=X;function we(s,e,t,n){let i=us(s,e,t,n);if(I(i)||T(i))return hs(s,n,i),we(s,i,t,n);if(typeof i!="symbol"){if(L(e)){n=Object.freeze(n.concat(e));for(let r=0;rs.replace(/[!,[\]{}]/g,e=>rn[e]),Z=class s{constructor(e,t){this.docStart=null,this.docEnd=!1,this.yaml=Object.assign({},s.defaultYaml,e),this.tags=Object.assign({},s.defaultTags,t)}clone(){let e=new s(this.yaml,this.tags);return e.docStart=this.docStart,e}atDocument(){let e=new s(this.yaml,this.tags);switch(this.yaml.version){case"1.1":this.atNextDocument=!0;break;case"1.2":this.atNextDocument=!1,this.yaml={explicit:s.defaultYaml.explicit,version:"1.2"},this.tags=Object.assign({},s.defaultTags);break}return e}add(e,t){this.atNextDocument&&(this.yaml={explicit:s.defaultYaml.explicit,version:"1.1"},this.tags=Object.assign({},s.defaultTags),this.atNextDocument=!1);let n=e.trim().split(/[ \t]+/),i=n.shift();switch(i){case"%TAG":{if(n.length!==2&&(t(0,"%TAG directive should contain exactly two parts"),n.length<2))return!1;let[r,o]=n;return this.tags[r]=o,!0}case"%YAML":{if(this.yaml.explicit=!0,n.length!==1)return t(0,"%YAML directive should contain exactly one part"),!1;let[r]=n;if(r==="1.1"||r==="1.2")return this.yaml.version=r,!0;{let o=/^\d+\.\d+$/.test(r);return t(6,`Unsupported YAML version ${r}`,o),!1}}default:return t(0,`Unknown directive ${i}`,!0),!1}}tagName(e,t){if(e==="!")return"!";if(e[0]!=="!")return t(`Not a valid tag: ${e}`),null;if(e[1]==="<"){let o=e.slice(2,-1);return o==="!"||o==="!!"?(t(`Verbatim tags aren't resolved, so ${e} is invalid.`),null):(e[e.length-1]!==">"&&t("Verbatim tags must end with a >"),o)}let[,n,i]=e.match(/^(.*!)([^!]*)$/s);i||t(`The ${e} tag has no suffix`);let r=this.tags[n];if(r)try{return r+decodeURIComponent(i)}catch(o){return t(String(o)),null}return n==="!"?e:(t(`Could not resolve tag: ${e}`),null)}tagString(e){for(let[t,n]of Object.entries(this.tags))if(e.startsWith(n))return t+on(e.substring(n.length));return e[0]==="!"?e:`!<${e}>`}toString(e){let t=this.yaml.explicit?[`%YAML ${this.yaml.version||"1.2"}`]:[],n=Object.entries(this.tags),i;if(e&&n.length>0&&I(e.contents)){let r={};z(e.contents,(o,a)=>{I(a)&&a.tag&&(r[a.tag]=!0)}),i=Object.keys(r)}else i=[];for(let[r,o]of n)r==="!!"&&o==="tag:yaml.org,2002:"||(!e||i.some(a=>a.startsWith(o)))&&t.push(`%TAG ${r} ${o}`);return t.join(` +`)}};Z.defaultYaml={explicit:!1,version:"1.2"};Z.defaultTags={"!!":"tag:yaml.org,2002:"};function Ze(s){if(/[\x00-\x19\s,[\]{}]/.test(s)){let t=`Anchor must not contain whitespace or control characters: ${JSON.stringify(s)}`;throw new Error(t)}return!0}function _t(s){let e=new Set;return z(s,{Value(t,n){n.anchor&&e.add(n.anchor)}}),e}function Pt(s,e){for(let t=1;;++t){let n=`${s}${t}`;if(!e.has(n))return n}}function ps(s,e){let t=[],n=new Map,i=null;return{onAnchor:r=>{t.push(r),i??(i=_t(s));let o=Pt(e,i);return i.add(o),o},setAnchors:()=>{for(let r of t){let o=n.get(r);if(typeof o=="object"&&o.anchor&&(E(o.node)||L(o.node)))o.node.anchor=o.anchor;else{let a=new Error("Failed to resolve repeated object (this should not happen)");throw a.source=r,a}}},sourceObjects:n}}function le(s,e,t,n){if(n&&typeof n=="object")if(Array.isArray(n))for(let i=0,r=n.length;iM(n,String(i),t));if(s&&typeof s.toJSON=="function"){if(!t||!Xe(s))return s.toJSON(e,t);let n={aliasCount:0,count:1,res:void 0};t.anchors.set(s,n),t.onCreate=r=>{n.res=r,delete t.onCreate};let i=s.toJSON(e,t);return t.onCreate&&t.onCreate(i),i}return typeof s=="bigint"&&!t?.keep?Number(s):s}var ce=class{constructor(e){Object.defineProperty(this,K,{value:e})}clone(){let e=Object.create(Object.getPrototypeOf(this),Object.getOwnPropertyDescriptors(this));return this.range&&(e.range=this.range.slice()),e}toJS(e,{mapAsMap:t,maxAliasCount:n,onAnchor:i,reviver:r}={}){if(!H(e))throw new TypeError("A document argument is required");let o={anchors:new Map,doc:e,keep:!0,mapAsMap:t===!0,mapKeyWarned:!1,maxAliasCount:typeof n=="number"?n:100},a=M(this,"",o);if(typeof i=="function")for(let{count:l,res:c}of o.anchors.values())i(c,l);return typeof r=="function"?le(r,{"":a},"",a):a}};var ie=class extends ce{constructor(e){super(We),this.source=e,Object.defineProperty(this,"tag",{set(){throw new Error("Alias nodes cannot have tags")}})}resolve(e,t){if(t?.maxAliasCount===0)throw new ReferenceError("Alias resolution is disabled");let n;t?.aliasResolveCache?n=t.aliasResolveCache:(n=[],z(e,{Node:(r,o)=>{(V(o)||Xe(o))&&n.push(o)}}),t&&(t.aliasResolveCache=n));let i;for(let r of n){if(r===this)break;r.anchor===this.source&&(i=r)}if(i&&t){let{anchors:r,doc:o,maxAliasCount:a}=t,l=r.get(i);if(l||(M(i,null,t),l=r.get(i)),l?.res===void 0){let c="This should not happen: Alias anchor was not resolved?";throw new ReferenceError(c)}if(a>=0&&(l.count+=1,l.aliasCount===0&&(l.aliasCount=et(o,i,r)),l.count*l.aliasCount>a)){let c="Excessive alias count indicates a resource exhaustion attack";throw new ReferenceError(c)}}return i}toJSON(e,t){if(!t)return{source:this.source};let n=this.resolve(t.doc,t);if(!n){let i=`Unresolved alias (the anchor must be set before the alias): ${this.source}`;throw new ReferenceError(i)}return t.anchors.get(n).res}toString(e,t,n){let i=`*${this.source}`;if(e){if(Ze(this.source),e.options.verifyAliasOrder&&!e.anchors.has(this.source)){let r=`Unresolved alias (the anchor must be set before the alias): ${this.source}`;throw new Error(r)}if(e.implicitKey)return`${i} `}return i}};function et(s,e,t){if(V(e)){let n=e.resolve(s),i=t&&n&&t.get(n);return i?i.count*i.aliasCount:0}else if(L(e)){let n=0;for(let i of e.items){let r=et(s,i,t);r>n&&(n=r)}return n}else if(T(e)){let n=et(s,e.key,t),i=et(s,e.value,t);return Math.max(n,i)}return 1}var tt=s=>!s||typeof s!="function"&&typeof s!="object",y=class extends ce{constructor(e){super(q),this.value=e}toJSON(e,t){return t?.keep?this.value:M(this.value,e,t)}toString(){return String(this.value)}};y.BLOCK_FOLDED="BLOCK_FOLDED";y.BLOCK_LITERAL="BLOCK_LITERAL";y.PLAIN="PLAIN";y.QUOTE_DOUBLE="QUOTE_DOUBLE";y.QUOTE_SINGLE="QUOTE_SINGLE";var an="tag:yaml.org,2002:";function ln(s,e,t){if(e){let n=t.filter(r=>r.tag===e),i=n.find(r=>!r.format)??n[0];if(!i)throw new Error(`Tag ${e} not found`);return i}return t.find(n=>n.identify?.(s)&&!n.format)}function re(s,e,t){if(H(s)&&(s=s.contents),I(s))return s;if(T(s)){let f=t.schema[U].createNode?.(t.schema,null,t);return f.items.push(s),f}(s instanceof String||s instanceof Number||s instanceof Boolean||typeof BigInt<"u"&&s instanceof BigInt)&&(s=s.valueOf());let{aliasDuplicateObjects:n,onAnchor:i,onTagObj:r,schema:o,sourceObjects:a}=t,l;if(n&&s&&typeof s=="object"){if(l=a.get(s),l)return l.anchor??(l.anchor=i(s)),new ie(l.anchor);l={anchor:null,node:null},a.set(s,l)}e?.startsWith("!!")&&(e=an+e.slice(2));let c=ln(s,e,o.tags);if(!c){if(s&&typeof s.toJSON=="function"&&(s=s.toJSON()),!s||typeof s!="object"){let f=new y(s);return l&&(l.node=f),f}c=s instanceof Map?o[U]:Symbol.iterator in Object(s)?o[ne]:o[U]}r&&(r(c),delete t.onTagObj);let p=c?.createNode?c.createNode(t.schema,s,t):typeof c?.nodeClass?.from=="function"?c.nodeClass.from(t.schema,s,t):new y(s);return e?p.tag=e:c.default||(p.tag=c.tag),l&&(l.node=p),p}function ve(s,e,t){let n=t;for(let i=e.length-1;i>=0;--i){let r=e[i];if(typeof r=="number"&&Number.isInteger(r)&&r>=0){let o=[];o[r]=n,n=o}else n=new Map([[r,n]])}return re(n,void 0,{aliasDuplicateObjects:!1,keepUndefined:!1,onAnchor:()=>{throw new Error("This should not happen, please report a bug.")},schema:s,sourceObjects:new Map})}var Ne=s=>s==null||typeof s=="object"&&!!s[Symbol.iterator]().next().done,ke=class extends ce{constructor(e,t){super(e),Object.defineProperty(this,"schema",{value:t,configurable:!0,enumerable:!1,writable:!0})}clone(e){let t=Object.create(Object.getPrototypeOf(this),Object.getOwnPropertyDescriptors(this));return e&&(t.schema=e),t.items=t.items.map(n=>I(n)||T(n)?n.clone(e):n),this.range&&(t.range=this.range.slice()),t}addIn(e,t){if(Ne(e))this.add(t);else{let[n,...i]=e,r=this.get(n,!0);if(L(r))r.addIn(i,t);else if(r===void 0&&this.schema)this.set(n,ve(this.schema,i,t));else throw new Error(`Expected YAML collection at ${n}. Remaining path: ${i}`)}}deleteIn(e){let[t,...n]=e;if(n.length===0)return this.delete(t);let i=this.get(t,!0);if(L(i))return i.deleteIn(n);throw new Error(`Expected YAML collection at ${t}. Remaining path: ${n}`)}getIn(e,t){let[n,...i]=e,r=this.get(n,!0);return i.length===0?!t&&E(r)?r.value:r:L(r)?r.getIn(i,t):void 0}hasAllNullValues(e){return this.items.every(t=>{if(!T(t))return!1;let n=t.value;return n==null||e&&E(n)&&n.value==null&&!n.commentBefore&&!n.comment&&!n.tag})}hasIn(e){let[t,...n]=e;if(n.length===0)return this.has(t);let i=this.get(t,!0);return L(i)?i.hasIn(n):!1}setIn(e,t){let[n,...i]=e;if(i.length===0)this.set(n,t);else{let r=this.get(n,!0);if(L(r))r.setIn(i,t);else if(r===void 0&&this.schema)this.set(n,ve(this.schema,i,t));else throw new Error(`Expected YAML collection at ${n}. Remaining path: ${i}`)}}};var ms=s=>s.replace(/^(?!$)(?: $)?/gm,"#");function x(s,e){return/^\n+$/.test(s)?s.substring(1):e?s.replace(/^(?! *$)/gm,e):s}var ee=(s,e,t)=>s.endsWith(` +`)?x(t,e):t.includes(` +`)?` +`+x(t,e):(s.endsWith(" ")?"":" ")+t;var Mt="flow",st="block",$e="quoted";function _e(s,e,t="flow",{indentAtStart:n,lineWidth:i=80,minContentWidth:r=20,onFold:o,onOverflow:a}={}){if(!i||i<0)return s;ii-Math.max(2,r)?c.push(0):f=i-n);let u,d,g=!1,h=-1,m=-1,w=-1;t===st&&(h=ds(s,h,e.length),h!==-1&&(f=h+l));for(let k;k=s[h+=1];){if(t===$e&&k==="\\"){switch(m=h,s[h+1]){case"x":h+=3;break;case"u":h+=5;break;case"U":h+=9;break;default:h+=1}w=h}if(k===` +`)t===st&&(h=ds(s,h,e.length)),f=h+e.length+l,u=void 0;else{if(k===" "&&d&&d!==" "&&d!==` +`&&d!==" "){let N=s[h+1];N&&N!==" "&&N!==` +`&&N!==" "&&(u=h)}if(h>=f)if(u)c.push(u),f=u+l,u=void 0;else if(t===$e){for(;d===" "||d===" ";)d=k,k=s[h+=1],g=!0;let N=h>w+1?h-2:m-1;if(p[N])return s;c.push(N),p[N]=!0,f=N+l,u=void 0}else g=!0}d=k}if(g&&a&&a(),c.length===0)return s;o&&o();let S=s.slice(0,c[0]);for(let k=0;k({indentAtStart:e?s.indent.length:s.indentAtStart,lineWidth:s.options.lineWidth,minContentWidth:s.options.minContentWidth}),rt=s=>/^(%|---|\.\.\.)/m.test(s);function cn(s,e,t){if(!e||e<0)return!1;let n=e-t,i=s.length;if(i<=n)return!1;for(let r=0,o=0;rn)return!0;if(o=r+1,i-o<=n)return!1}return!0}function Pe(s,e){let t=JSON.stringify(s);if(e.options.doubleQuotedAsJSON)return t;let{implicitKey:n}=e,i=e.options.doubleQuotedMinMultiLineLength,r=e.indent||(rt(s)?" ":""),o="",a=0;for(let l=0,c=t[l];c;c=t[++l])if(c===" "&&t[l+1]==="\\"&&t[l+2]==="n"&&(o+=t.slice(a,l)+"\\ ",l+=1,a=l,c="\\"),c==="\\")switch(t[l+1]){case"u":{o+=t.slice(a,l);let p=t.substr(l+2,4);switch(p){case"0000":o+="\\0";break;case"0007":o+="\\a";break;case"000b":o+="\\v";break;case"001b":o+="\\e";break;case"0085":o+="\\N";break;case"00a0":o+="\\_";break;case"2028":o+="\\L";break;case"2029":o+="\\P";break;default:p.substr(0,2)==="00"?o+="\\x"+p.substr(2):o+=t.substr(l,6)}l+=5,a=l+1}break;case"n":if(n||t[l+2]==='"'||t.length +`;let f,u;for(u=t.length;u>0;--u){let O=t[u-1];if(O!==` +`&&O!==" "&&O!==" ")break}let d=t.substring(u),g=d.indexOf(` +`);g===-1?f="-":t===d||g!==d.length-1?(f="+",r&&r()):f="",d&&(t=t.slice(0,-d.length),d[d.length-1]===` +`&&(d=d.slice(0,-1)),d=d.replace(Dt,`$&${c}`));let h=!1,m,w=-1;for(m=0;m{A=!0});let b=_e(`${S}${O}${d}`,c,st,C);if(!A)return`>${N} +${c}${b}`}return t=t.replace(/\n+/g,`$&${c}`),`|${N} +${c}${S}${t}${d}`}function fn(s,e,t,n){let{type:i,value:r}=s,{actualString:o,implicitKey:a,indent:l,indentStep:c,inFlow:p}=e;if(a&&r.includes(` +`)||p&&/[[\]{},]/.test(r))return Oe(r,e);if(/^[\n\t ,[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$/.test(r))return a||p||!r.includes(` +`)?Oe(r,e):nt(s,e,t,n);if(!a&&!p&&i!==y.PLAIN&&r.includes(` +`))return nt(s,e,t,n);if(rt(r)){if(l==="")return e.forceBlockIndent=!0,nt(s,e,t,n);if(a&&l===c)return Oe(r,e)}let f=r.replace(/\n+/g,`$& +${l}`);if(o){let u=h=>h.default&&h.tag!=="tag:yaml.org,2002:str"&&h.test?.test(f),{compat:d,tags:g}=e.doc.schema;if(g.some(u)||d?.some(u))return Oe(r,e)}return a?f:_e(f,l,Mt,it(e,!1))}function de(s,e,t,n){let{implicitKey:i,inFlow:r}=e,o=typeof s.value=="string"?s:Object.assign({},s,{value:String(s.value)}),{type:a}=s;a!==y.QUOTE_DOUBLE&&/[\x00-\x08\x0b-\x1f\x7f-\x9f\u{D800}-\u{DFFF}]/u.test(o.value)&&(a=y.QUOTE_DOUBLE);let l=p=>{switch(p){case y.BLOCK_FOLDED:case y.BLOCK_LITERAL:return i||r?Oe(o.value,e):nt(o,e,t,n);case y.QUOTE_DOUBLE:return Pe(o.value,e);case y.QUOTE_SINGLE:return Bt(o.value,e);case y.PLAIN:return fn(o,e,t,n);default:return null}},c=l(a);if(c===null){let{defaultKeyType:p,defaultStringType:f}=e.options,u=i&&p||f;if(c=l(u),c===null)throw new Error(`Unsupported default string type ${u}`)}return c}function ot(s,e){let t=Object.assign({blockQuote:!0,commentString:ms,defaultKeyType:null,defaultStringType:"PLAIN",directives:null,doubleQuotedAsJSON:!1,doubleQuotedMinMultiLineLength:40,falseStr:"false",flowCollectionPadding:!0,indentSeq:!0,lineWidth:80,minContentWidth:20,nullStr:"null",simpleKeys:!1,singleQuote:null,trailingComma:!1,trueStr:"true",verifyAliasOrder:!0},s.schema.toStringOptions,e),n;switch(t.collectionStyle){case"block":n=!1;break;case"flow":n=!0;break;default:n=null}return{anchors:new Set,doc:s,flowCollectionPadding:t.flowCollectionPadding?" ":"",indent:"",indentStep:typeof t.indent=="number"?" ".repeat(t.indent):" ",inFlow:n,options:t}}function un(s,e){if(e.tag){let i=s.filter(r=>r.tag===e.tag);if(i.length>0)return i.find(r=>r.format===e.format)??i[0]}let t,n;if(E(e)){n=e.value;let i=s.filter(r=>r.identify?.(n));if(i.length>1){let r=i.filter(o=>o.test);r.length>0&&(i=r)}t=i.find(r=>r.format===e.format)??i.find(r=>!r.format)}else n=e,t=s.find(i=>i.nodeClass&&n instanceof i.nodeClass);if(!t){let i=n?.constructor?.name??(n===null?"null":typeof n);throw new Error(`Tag not resolved for ${i} value`)}return t}function hn(s,e,{anchors:t,doc:n}){if(!n.directives)return"";let i=[],r=(E(s)||L(s))&&s.anchor;r&&Ze(r)&&(t.add(r),i.push(`&${r}`));let o=s.tag??(e.default?null:e.tag);return o&&i.push(n.directives.tagString(o)),i.join(" ")}function oe(s,e,t,n){if(T(s))return s.toString(e,t,n);if(V(s)){if(e.doc.directives)return s.toString(e);if(e.resolvedAliases?.has(s))throw new TypeError("Cannot stringify circular structure without alias nodes");e.resolvedAliases?e.resolvedAliases.add(s):e.resolvedAliases=new Set([s]),s=s.resolve(e.doc)}let i,r=I(s)?s:e.doc.createNode(s,{onTagObj:l=>i=l});i??(i=un(e.doc.schema.tags,r));let o=hn(r,i,e);o.length>0&&(e.indentAtStart=(e.indentAtStart??0)+o.length+1);let a=typeof i.stringify=="function"?i.stringify(r,e,t,n):E(r)?de(r,e,t,n):r.toString(e,t,n);return o?E(r)||a[0]==="{"||a[0]==="["?`${o} ${a}`:`${o} +${e.indent}${a}`:a}function gs({key:s,value:e},t,n,i){let{allNullValues:r,doc:o,indent:a,indentStep:l,options:{commentString:c,indentSeq:p,simpleKeys:f}}=t,u=I(s)&&s.comment||null;if(f){if(u)throw new Error("With simple keys, key nodes cannot have comments");if(L(s)||!I(s)&&typeof s=="object"){let C="With simple keys, collection cannot be used as a key value";throw new Error(C)}}let d=!f&&(!s||u&&e==null&&!t.inFlow||L(s)||(E(s)?s.type===y.BLOCK_FOLDED||s.type===y.BLOCK_LITERAL:typeof s=="object"));t=Object.assign({},t,{allNullValues:!1,implicitKey:!d&&(f||!r),indent:a+l});let g=!1,h=!1,m=oe(s,t,()=>g=!0,()=>h=!0);if(!d&&!t.inFlow&&m.length>1024){if(f)throw new Error("With simple keys, single line scalar must not span more than 1024 characters");d=!0}if(t.inFlow){if(r||e==null)return g&&n&&n(),m===""?"?":d?`? ${m}`:m}else if(r&&!f||e==null&&d)return m=`? ${m}`,u&&!g?m+=ee(m,t.indent,c(u)):h&&i&&i(),m;g&&(u=null),d?(u&&(m+=ee(m,t.indent,c(u))),m=`? ${m} +${a}:`):(m=`${m}:`,u&&(m+=ee(m,t.indent,c(u))));let w,S,k;I(e)?(w=!!e.spaceBefore,S=e.commentBefore,k=e.comment):(w=!1,S=null,k=null,e&&typeof e=="object"&&(e=o.createNode(e))),t.implicitKey=!1,!d&&!u&&E(e)&&(t.indentAtStart=m.length+1),h=!1,!p&&l.length>=2&&!t.inFlow&&!d&&F(e)&&!e.flow&&!e.tag&&!e.anchor&&(t.indent=t.indent.substring(2));let N=!1,O=oe(e,t,()=>N=!0,()=>h=!0),A=" ";if(u||w||S){if(A=w?` +`:"",S){let C=c(S);A+=` +${x(C,t.indent)}`}O===""&&!t.inFlow?A===` +`&&k&&(A=` + +`):A+=` +${t.indent}`}else if(!d&&L(e)){let C=O[0],b=O.indexOf(` +`),_=b!==-1,ae=t.inFlow??e.flow??e.items.length===0;if(_||!ae){let be=!1;if(_&&(C==="&"||C==="!")){let P=O.indexOf(" ");C==="&"&&P!==-1&&Ps===at||typeof s=="symbol"&&s.description===at,default:"key",tag:"tag:yaml.org,2002:merge",test:/^<<$/,resolve:()=>Object.assign(new y(Symbol(at)),{addToJSMap:qt}),stringify:()=>at},ys=(s,e)=>(J.identify(e)||E(e)&&(!e.type||e.type===y.PLAIN)&&J.identify(e.value))&&s?.doc.schema.tags.some(t=>t.tag===J.tag&&t.default);function qt(s,e,t){let n=bs(s,t);if(F(n))for(let i of n.items)jt(s,e,i);else if(Array.isArray(n))for(let i of n)jt(s,e,i);else jt(s,e,n)}function jt(s,e,t){let n=bs(s,t);if(!R(n))throw new Error("Merge sources must be maps or map aliases");let i=n.toJSON(null,s,Map);for(let[r,o]of i)e instanceof Map?e.has(r)||e.set(r,o):e instanceof Set?e.add(r):Object.prototype.hasOwnProperty.call(e,r)||Object.defineProperty(e,r,{value:o,writable:!0,enumerable:!0,configurable:!0});return e}function bs(s,e){return s&&V(e)?e.resolve(s.doc,s):e}function lt(s,e,{key:t,value:n}){if(I(t)&&t.addToJSMap)t.addToJSMap(s,e,n);else if(ys(s,t))qt(s,e,n);else{let i=M(t,"",s);if(e instanceof Map)e.set(i,M(n,i,s));else if(e instanceof Set)e.add(i);else{let r=pn(t,i,s),o=M(n,r,s);r in e?Object.defineProperty(e,r,{value:o,writable:!0,enumerable:!0,configurable:!0}):e[r]=o}}return e}function pn(s,e,t){if(e===null)return"";if(typeof e!="object")return String(e);if(I(s)&&t?.doc){let n=ot(t.doc,{});n.anchors=new Set;for(let r of t.anchors.keys())n.anchors.add(r.anchor);n.inFlow=!0,n.inStringifyKey=!0;let i=s.toString(n);if(!t.mapKeyWarned){let r=JSON.stringify(i);r.length>40&&(r=r.substring(0,36)+'..."'),Kt(t.doc.options.logLevel,`Keys with collection values will be stringified due to JS Object restrictions: ${r}. Set mapAsMap: true to use object keys.`),t.mapKeyWarned=!0}return i}return JSON.stringify(e)}function Ae(s,e,t){let n=re(s,void 0,t),i=re(e,void 0,t);return new v(n,i)}var v=class s{constructor(e,t=null){Object.defineProperty(this,K,{value:$t}),this.key=e,this.value=t}clone(e){let{key:t,value:n}=this;return I(t)&&(t=t.clone(e)),I(n)&&(n=n.clone(e)),new s(t,n)}toJSON(e,t){let n=t?.mapAsMap?new Map:{};return lt(t,n,this)}toString(e,t,n){return e?.doc?gs(this,e,t,n):JSON.stringify(this)}};function ft(s,e,t){return(e.inFlow??s.flow?dn:mn)(s,e,t)}function mn({comment:s,items:e},t,{blockItemPrefix:n,flowChars:i,itemIndent:r,onChompKeep:o,onComment:a}){let{indent:l,options:{commentString:c}}=t,p=Object.assign({},t,{indent:r,type:null}),f=!1,u=[];for(let g=0;gm=null,()=>f=!0);m&&(w+=ee(w,r,c(m))),f&&m&&(f=!1),u.push(n+w)}let d;if(u.length===0)d=i.start+i.end;else{d=u[0];for(let g=1;gm=null);c||(c=f.length>p||w.includes(` +`)),g0&&(c||(c=f.reduce((S,k)=>S+k.length+2,2)+(w.length+2)>e.options.lineWidth)),c&&(w+=",")),m&&(w+=ee(w,n,a(m))),f.push(w),p=f.length}let{start:u,end:d}=t;if(f.length===0)return u+d;if(!c){let g=f.reduce((h,m)=>h+m.length+2,2);c=e.options.lineWidth>0&&g>e.options.lineWidth}if(c){let g=u;for(let h of f)g+=h?` +${r}${i}${h}`:` +`;return`${g} +${i}${d}`}else return`${u}${o}${f.join(" ")}${o}${d}`}function ct({indent:s,options:{commentString:e}},t,n,i){if(n&&i&&(n=n.replace(/^\n+/,"")),n){let r=x(e(n),s);t.push(r.trimStart())}}function fe(s,e){let t=E(e)?e.value:e;for(let n of s)if(T(n)&&(n.key===e||n.key===t||E(n.key)&&n.key.value===t))return n}var $=class extends ke{static get tagName(){return"tag:yaml.org,2002:map"}constructor(e){super(U,e),this.items=[]}static from(e,t,n){let{keepUndefined:i,replacer:r}=n,o=new this(e),a=(l,c)=>{if(typeof r=="function")c=r.call(t,l,c);else if(Array.isArray(r)&&!r.includes(l))return;(c!==void 0||i)&&o.items.push(Ae(l,c,n))};if(t instanceof Map)for(let[l,c]of t)a(l,c);else if(t&&typeof t=="object")for(let l of Object.keys(t))a(l,t[l]);return typeof e.sortMapEntries=="function"&&o.items.sort(e.sortMapEntries),o}add(e,t){let n;T(e)?n=e:!e||typeof e!="object"||!("key"in e)?n=new v(e,e?.value):n=new v(e.key,e.value);let i=fe(this.items,n.key),r=this.schema?.sortMapEntries;if(i){if(!t)throw new Error(`Key ${n.key} already set`);E(i.value)&&tt(n.value)?i.value.value=n.value:i.value=n.value}else if(r){let o=this.items.findIndex(a=>r(n,a)<0);o===-1?this.items.push(n):this.items.splice(o,0,n)}else this.items.push(n)}delete(e){let t=fe(this.items,e);return t?this.items.splice(this.items.indexOf(t),1).length>0:!1}get(e,t){let i=fe(this.items,e)?.value;return(!t&&E(i)?i.value:i)??void 0}has(e){return!!fe(this.items,e)}set(e,t){this.add(new v(e,t),!0)}toJSON(e,t,n){let i=n?new n:t?.mapAsMap?new Map:{};t?.onCreate&&t.onCreate(i);for(let r of this.items)lt(t,i,r);return i}toString(e,t,n){if(!e)return JSON.stringify(this);for(let i of this.items)if(!T(i))throw new Error(`Map items must all be pairs; found ${JSON.stringify(i)} instead`);return!e.allNullValues&&this.hasAllNullValues(!1)&&(e=Object.assign({},e,{allNullValues:!0})),ft(this,e,{blockItemPrefix:"",flowChars:{start:"{",end:"}"},itemIndent:e.indent||"",onChompKeep:n,onComment:t})}};var Y={collection:"map",default:!0,nodeClass:$,tag:"tag:yaml.org,2002:map",resolve(s,e){return R(s)||e("Expected a mapping for this tag"),s},createNode:(s,e,t)=>$.from(s,e,t)};var B=class extends ke{static get tagName(){return"tag:yaml.org,2002:seq"}constructor(e){super(ne,e),this.items=[]}add(e){this.items.push(e)}delete(e){let t=ut(e);return typeof t!="number"?!1:this.items.splice(t,1).length>0}get(e,t){let n=ut(e);if(typeof n!="number")return;let i=this.items[n];return!t&&E(i)?i.value:i}has(e){let t=ut(e);return typeof t=="number"&&t=0?e:null}var Q={collection:"seq",default:!0,nodeClass:B,tag:"tag:yaml.org,2002:seq",resolve(s,e){return F(s)||e("Expected a sequence for this tag"),s},createNode:(s,e,t)=>B.from(s,e,t)};var ue={identify:s=>typeof s=="string",default:!0,tag:"tag:yaml.org,2002:str",resolve:s=>s,stringify(s,e,t,n){return e=Object.assign({actualString:!0},e),de(s,e,t,n)}};var ge={identify:s=>s==null,createNode:()=>new y(null),default:!0,tag:"tag:yaml.org,2002:null",test:/^(?:~|[Nn]ull|NULL)?$/,resolve:()=>new y(null),stringify:({source:s},e)=>typeof s=="string"&&ge.test.test(s)?s:e.options.nullStr};var Me={identify:s=>typeof s=="boolean",default:!0,tag:"tag:yaml.org,2002:bool",test:/^(?:[Tt]rue|TRUE|[Ff]alse|FALSE)$/,resolve:s=>new y(s[0]==="t"||s[0]==="T"),stringify({source:s,value:e},t){if(s&&Me.test.test(s)){let n=s[0]==="t"||s[0]==="T";if(e===n)return s}return e?t.options.trueStr:t.options.falseStr}};function D({format:s,minFractionDigits:e,tag:t,value:n}){if(typeof n=="bigint")return String(n);let i=typeof n=="number"?n:Number(n);if(!isFinite(i))return isNaN(i)?".nan":i<0?"-.inf":".inf";let r=Object.is(n,-0)?"-0":JSON.stringify(n);if(!s&&e&&(!t||t==="tag:yaml.org,2002:float")&&/^-?\d/.test(r)&&!r.includes("e")){let o=r.indexOf(".");o<0&&(o=r.length,r+=".");let a=e-(r.length-o-1);for(;a-- >0;)r+="0"}return r}var ht={identify:s=>typeof s=="number",default:!0,tag:"tag:yaml.org,2002:float",test:/^(?:[-+]?\.(?:inf|Inf|INF)|\.nan|\.NaN|\.NAN)$/,resolve:s=>s.slice(-3).toLowerCase()==="nan"?NaN:s[0]==="-"?Number.NEGATIVE_INFINITY:Number.POSITIVE_INFINITY,stringify:D},pt={identify:s=>typeof s=="number",default:!0,tag:"tag:yaml.org,2002:float",format:"EXP",test:/^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)[eE][-+]?[0-9]+$/,resolve:s=>parseFloat(s),stringify(s){let e=Number(s.value);return isFinite(e)?e.toExponential():D(s)}},mt={identify:s=>typeof s=="number",default:!0,tag:"tag:yaml.org,2002:float",test:/^[-+]?(?:\.[0-9]+|[0-9]+\.[0-9]*)$/,resolve(s){let e=new y(parseFloat(s)),t=s.indexOf(".");return t!==-1&&s[s.length-1]==="0"&&(e.minFractionDigits=s.length-t-1),e},stringify:D};var dt=s=>typeof s=="bigint"||Number.isInteger(s),Rt=(s,e,t,{intAsBigInt:n})=>n?BigInt(s):parseInt(s.substring(e),t);function ws(s,e,t){let{value:n}=s;return dt(n)&&n>=0?t+n.toString(e):D(s)}var gt={identify:s=>dt(s)&&s>=0,default:!0,tag:"tag:yaml.org,2002:int",format:"OCT",test:/^0o[0-7]+$/,resolve:(s,e,t)=>Rt(s,2,8,t),stringify:s=>ws(s,8,"0o")},yt={identify:dt,default:!0,tag:"tag:yaml.org,2002:int",test:/^[-+]?[0-9]+$/,resolve:(s,e,t)=>Rt(s,0,10,t),stringify:D},bt={identify:s=>dt(s)&&s>=0,default:!0,tag:"tag:yaml.org,2002:int",format:"HEX",test:/^0x[0-9a-fA-F]+$/,resolve:(s,e,t)=>Rt(s,2,16,t),stringify:s=>ws(s,16,"0x")};var Ss=[Y,Q,ue,ge,Me,gt,yt,bt,ht,pt,mt];function ks(s){return typeof s=="bigint"||Number.isInteger(s)}var wt=({value:s})=>JSON.stringify(s),gn=[{identify:s=>typeof s=="string",default:!0,tag:"tag:yaml.org,2002:str",resolve:s=>s,stringify:wt},{identify:s=>s==null,createNode:()=>new y(null),default:!0,tag:"tag:yaml.org,2002:null",test:/^null$/,resolve:()=>null,stringify:wt},{identify:s=>typeof s=="boolean",default:!0,tag:"tag:yaml.org,2002:bool",test:/^true$|^false$/,resolve:s=>s==="true",stringify:wt},{identify:ks,default:!0,tag:"tag:yaml.org,2002:int",test:/^-?(?:0|[1-9][0-9]*)$/,resolve:(s,e,{intAsBigInt:t})=>t?BigInt(s):parseInt(s,10),stringify:({value:s})=>ks(s)?s.toString():JSON.stringify(s)},{identify:s=>typeof s=="number",default:!0,tag:"tag:yaml.org,2002:float",test:/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*)?(?:[eE][-+]?[0-9]+)?$/,resolve:s=>parseFloat(s),stringify:wt}],yn={default:!0,tag:"",test:/^/,resolve(s,e){return e(`Unresolved plain scalar ${JSON.stringify(s)}`),s}},Ns=[Y,Q].concat(gn,yn);var Be={identify:s=>s instanceof Uint8Array,default:!1,tag:"tag:yaml.org,2002:binary",resolve(s,e){if(typeof atob=="function"){let t=atob(s.replace(/[\n\r]/g,"")),n=new Uint8Array(t.length);for(let i=0;i1&&e("Each pair must have its own sequence indicator");let i=n.items[0]||new v(new y(null));if(n.commentBefore&&(i.key.commentBefore=i.key.commentBefore?`${n.commentBefore} +${i.key.commentBefore}`:n.commentBefore),n.comment){let r=i.value??i.key;r.comment=r.comment?`${n.comment} +${r.comment}`:n.comment}n=i}s.items[t]=T(n)?n:new v(n)}}else e("Expected a sequence for this tag");return s}function xt(s,e,t){let{replacer:n}=t,i=new B(s);i.tag="tag:yaml.org,2002:pairs";let r=0;if(e&&Symbol.iterator in Object(e))for(let o of e){typeof n=="function"&&(o=n.call(e,String(r++),o));let a,l;if(Array.isArray(o))if(o.length===2)a=o[0],l=o[1];else throw new TypeError(`Expected [key, value] tuple: ${o}`);else if(o&&o instanceof Object){let c=Object.keys(o);if(c.length===1)a=c[0],l=o[a];else throw new TypeError(`Expected tuple with one key, not ${c.length} keys`)}else a=o;i.items.push(Ae(a,l,t))}return i}var De={collection:"seq",default:!1,tag:"tag:yaml.org,2002:pairs",resolve:Ft,createNode:xt};var Ee=class s extends B{constructor(){super(),this.add=$.prototype.add.bind(this),this.delete=$.prototype.delete.bind(this),this.get=$.prototype.get.bind(this),this.has=$.prototype.has.bind(this),this.set=$.prototype.set.bind(this),this.tag=s.tag}toJSON(e,t){if(!t)return super.toJSON(e);let n=new Map;t?.onCreate&&t.onCreate(n);for(let i of this.items){let r,o;if(T(i)?(r=M(i.key,"",t),o=M(i.value,r,t)):r=M(i,"",t),n.has(r))throw new Error("Ordered maps must not include duplicate keys");n.set(r,o)}return n}static from(e,t,n){let i=xt(e,t,n),r=new this;return r.items=i.items,r}};Ee.tag="tag:yaml.org,2002:omap";var Ke={collection:"seq",identify:s=>s instanceof Map,nodeClass:Ee,default:!1,tag:"tag:yaml.org,2002:omap",resolve(s,e){let t=Ft(s,e),n=[];for(let{key:i}of t.items)E(i)&&(n.includes(i.value)?e(`Ordered maps must not include duplicate keys: ${i.value}`):n.push(i.value));return Object.assign(new Ee,t)},createNode:(s,e,t)=>Ee.from(s,e,t)};function Os({value:s,source:e},t){return e&&(s?Ut:Vt).test.test(e)?e:s?t.options.trueStr:t.options.falseStr}var Ut={identify:s=>s===!0,default:!0,tag:"tag:yaml.org,2002:bool",test:/^(?:Y|y|[Yy]es|YES|[Tt]rue|TRUE|[Oo]n|ON)$/,resolve:()=>new y(!0),stringify:Os},Vt={identify:s=>s===!1,default:!0,tag:"tag:yaml.org,2002:bool",test:/^(?:N|n|[Nn]o|NO|[Ff]alse|FALSE|[Oo]ff|OFF)$/,resolve:()=>new y(!1),stringify:Os};var As={identify:s=>typeof s=="number",default:!0,tag:"tag:yaml.org,2002:float",test:/^(?:[-+]?\.(?:inf|Inf|INF)|\.nan|\.NaN|\.NAN)$/,resolve:s=>s.slice(-3).toLowerCase()==="nan"?NaN:s[0]==="-"?Number.NEGATIVE_INFINITY:Number.POSITIVE_INFINITY,stringify:D},Es={identify:s=>typeof s=="number",default:!0,tag:"tag:yaml.org,2002:float",format:"EXP",test:/^[-+]?(?:[0-9][0-9_]*)?(?:\.[0-9_]*)?[eE][-+]?[0-9]+$/,resolve:s=>parseFloat(s.replace(/_/g,"")),stringify(s){let e=Number(s.value);return isFinite(e)?e.toExponential():D(s)}},Ts={identify:s=>typeof s=="number",default:!0,tag:"tag:yaml.org,2002:float",test:/^[-+]?(?:[0-9][0-9_]*)?\.[0-9_]*$/,resolve(s){let e=new y(parseFloat(s.replace(/_/g,""))),t=s.indexOf(".");if(t!==-1){let n=s.substring(t+1).replace(/_/g,"");n[n.length-1]==="0"&&(e.minFractionDigits=n.length)}return e},stringify:D};var je=s=>typeof s=="bigint"||Number.isInteger(s);function St(s,e,t,{intAsBigInt:n}){let i=s[0];if((i==="-"||i==="+")&&(e+=1),s=s.substring(e).replace(/_/g,""),n){switch(t){case 2:s=`0b${s}`;break;case 8:s=`0o${s}`;break;case 16:s=`0x${s}`;break}let o=BigInt(s);return i==="-"?BigInt(-1)*o:o}let r=parseInt(s,t);return i==="-"?-1*r:r}function Jt(s,e,t){let{value:n}=s;if(je(n)){let i=n.toString(e);return n<0?"-"+t+i.substr(1):t+i}return D(s)}var Is={identify:je,default:!0,tag:"tag:yaml.org,2002:int",format:"BIN",test:/^[-+]?0b[0-1_]+$/,resolve:(s,e,t)=>St(s,2,2,t),stringify:s=>Jt(s,2,"0b")},Ls={identify:je,default:!0,tag:"tag:yaml.org,2002:int",format:"OCT",test:/^[-+]?0[0-7_]+$/,resolve:(s,e,t)=>St(s,1,8,t),stringify:s=>Jt(s,8,"0")},Cs={identify:je,default:!0,tag:"tag:yaml.org,2002:int",test:/^[-+]?[0-9][0-9_]*$/,resolve:(s,e,t)=>St(s,0,10,t),stringify:D},vs={identify:je,default:!0,tag:"tag:yaml.org,2002:int",format:"HEX",test:/^[-+]?0x[0-9a-fA-F_]+$/,resolve:(s,e,t)=>St(s,2,16,t),stringify:s=>Jt(s,16,"0x")};var Te=class s extends ${constructor(e){super(e),this.tag=s.tag}add(e){let t;T(e)?t=e:e&&typeof e=="object"&&"key"in e&&"value"in e&&e.value===null?t=new v(e.key,null):t=new v(e,null),fe(this.items,t.key)||this.items.push(t)}get(e,t){let n=fe(this.items,e);return!t&&T(n)?E(n.key)?n.key.value:n.key:n}set(e,t){if(typeof t!="boolean")throw new Error(`Expected boolean value for set(key, value) in a YAML set, not ${typeof t}`);let n=fe(this.items,e);n&&!t?this.items.splice(this.items.indexOf(n),1):!n&&t&&this.items.push(new v(e))}toJSON(e,t){return super.toJSON(e,t,Set)}toString(e,t,n){if(!e)return JSON.stringify(this);if(this.hasAllNullValues(!0))return super.toString(Object.assign({},e,{allNullValues:!0}),t,n);throw new Error("Set items must all have null values")}static from(e,t,n){let{replacer:i}=n,r=new this(e);if(t&&Symbol.iterator in Object(t))for(let o of t)typeof i=="function"&&(o=i.call(t,o,o)),r.items.push(Ae(o,null,n));return r}};Te.tag="tag:yaml.org,2002:set";var qe={collection:"map",identify:s=>s instanceof Set,nodeClass:Te,default:!1,tag:"tag:yaml.org,2002:set",createNode:(s,e,t)=>Te.from(s,e,t),resolve(s,e){if(R(s)){if(s.hasAllNullValues(!0))return Object.assign(new Te,s);e("Set items must all have null values")}else e("Expected a mapping for this tag");return s}};function Yt(s,e){let t=s[0],n=t==="-"||t==="+"?s.substring(1):s,i=o=>e?BigInt(o):Number(o),r=n.replace(/_/g,"").split(":").reduce((o,a)=>o*i(60)+i(a),i(0));return t==="-"?i(-1)*r:r}function $s(s){let{value:e}=s,t=o=>o;if(typeof e=="bigint")t=o=>BigInt(o);else if(isNaN(e)||!isFinite(e))return D(s);let n="";e<0&&(n="-",e*=t(-1));let i=t(60),r=[e%i];return e<60?r.unshift(0):(e=(e-r[0])/i,r.unshift(e%i),e>=60&&(e=(e-r[0])/i,r.unshift(e))),n+r.map(o=>String(o).padStart(2,"0")).join(":").replace(/000000\d*$/,"")}var kt={identify:s=>typeof s=="bigint"||Number.isInteger(s),default:!0,tag:"tag:yaml.org,2002:int",format:"TIME",test:/^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+$/,resolve:(s,e,{intAsBigInt:t})=>Yt(s,t),stringify:$s},Nt={identify:s=>typeof s=="number",default:!0,tag:"tag:yaml.org,2002:float",format:"TIME",test:/^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*$/,resolve:s=>Yt(s,!1),stringify:$s},Ie={identify:s=>s instanceof Date,default:!0,tag:"tag:yaml.org,2002:timestamp",test:RegExp("^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})(?:(?:t|T|[ \\t]+)([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2}(\\.[0-9]+)?)(?:[ \\t]*(Z|[-+][012]?[0-9](?::[0-9]{2})?))?)?$"),resolve(s){let e=s.match(Ie.test);if(!e)throw new Error("!!timestamp expects a date, starting with yyyy-mm-dd");let[,t,n,i,r,o,a]=e.map(Number),l=e[7]?Number((e[7]+"00").substr(1,3)):0,c=Date.UTC(t,n-1,i,r||0,o||0,a||0,l),p=e[8];if(p&&p!=="Z"){let f=Yt(p,!1);Math.abs(f)<30&&(f*=60),c-=6e4*f}return new Date(c)},stringify:({value:s})=>s?.toISOString().replace(/(T00:00:00)?\.000Z$/,"")??""};var Qt=[Y,Q,ue,ge,Ut,Vt,Is,Ls,Cs,vs,As,Es,Ts,Be,J,Ke,De,qe,kt,Nt,Ie];var _s=new Map([["core",Ss],["failsafe",[Y,Q,ue]],["json",Ns],["yaml11",Qt],["yaml-1.1",Qt]]),Ps={binary:Be,bool:Me,float:mt,floatExp:pt,floatNaN:ht,floatTime:Nt,int:yt,intHex:bt,intOct:gt,intTime:kt,map:Y,merge:J,null:ge,omap:Ke,pairs:De,seq:Q,set:qe,timestamp:Ie},Ms={"tag:yaml.org,2002:binary":Be,"tag:yaml.org,2002:merge":J,"tag:yaml.org,2002:omap":Ke,"tag:yaml.org,2002:pairs":De,"tag:yaml.org,2002:set":qe,"tag:yaml.org,2002:timestamp":Ie};function Ot(s,e,t){let n=_s.get(e);if(n&&!s)return t&&!n.includes(J)?n.concat(J):n.slice();let i=n;if(!i)if(Array.isArray(s))i=[];else{let r=Array.from(_s.keys()).filter(o=>o!=="yaml11").map(o=>JSON.stringify(o)).join(", ");throw new Error(`Unknown schema "${e}"; use one of ${r} or define customTags array`)}if(Array.isArray(s))for(let r of s)i=i.concat(r);else typeof s=="function"&&(i=s(i.slice()));return t&&(i=i.concat(J)),i.reduce((r,o)=>{let a=typeof o=="string"?Ps[o]:o;if(!a){let l=JSON.stringify(o),c=Object.keys(Ps).map(p=>JSON.stringify(p)).join(", ");throw new Error(`Unknown custom tag ${l}; use one of ${c}`)}return r.includes(a)||r.push(a),r},[])}var bn=(s,e)=>s.keye.key?1:0,Re=class s{constructor({compat:e,customTags:t,merge:n,resolveKnownTags:i,schema:r,sortMapEntries:o,toStringDefaults:a}){this.compat=Array.isArray(e)?Ot(e,"compat"):e?Ot(null,e):null,this.name=typeof r=="string"&&r||"core",this.knownTags=i?Ms:{},this.tags=Ot(t,this.name,n),this.toStringOptions=a??null,Object.defineProperty(this,U,{value:Y}),Object.defineProperty(this,q,{value:ue}),Object.defineProperty(this,ne,{value:Q}),this.sortMapEntries=typeof o=="function"?o:o===!0?bn:null}clone(){let e=Object.create(s.prototype,Object.getOwnPropertyDescriptors(this));return e.tags=this.tags.slice(),e}};function Bs(s,e){let t=[],n=e.directives===!0;if(e.directives!==!1&&s.directives){let l=s.directives.toString(s);l?(t.push(l),n=!0):s.directives.docStart&&(n=!0)}n&&t.push("---");let i=ot(s,e),{commentString:r}=i.options;if(s.commentBefore){t.length!==1&&t.unshift("");let l=r(s.commentBefore);t.unshift(x(l,""))}let o=!1,a=null;if(s.contents){if(I(s.contents)){if(s.contents.spaceBefore&&n&&t.push(""),s.contents.commentBefore){let p=r(s.contents.commentBefore);t.push(x(p,""))}i.forceBlockIndent=!!s.comment,a=s.contents.comment}let l=a?void 0:()=>o=!0,c=oe(s.contents,i,()=>a=null,l);a&&(c+=ee(c,"",r(a))),(c[0]==="|"||c[0]===">")&&t[t.length-1]==="---"?t[t.length-1]=`--- ${c}`:t.push(c)}else t.push(oe(s.contents,i));if(s.directives?.docEnd)if(s.comment){let l=r(s.comment);l.includes(` +`)?(t.push("..."),t.push(x(l,""))):t.push(`... ${l}`)}else t.push("...");else{let l=s.comment;l&&o&&(l=l.replace(/^\n+/,"")),l&&((!o||a)&&t[t.length-1]!==""&&t.push(""),t.push(x(r(l),"")))}return t.join(` +`)+` +`}var he=class s{constructor(e,t,n){this.commentBefore=null,this.comment=null,this.errors=[],this.warnings=[],Object.defineProperty(this,K,{value:He});let i=null;typeof t=="function"||Array.isArray(t)?i=t:n===void 0&&t&&(n=t,t=void 0);let r=Object.assign({intAsBigInt:!1,keepSourceTokens:!1,logLevel:"warn",prettyErrors:!0,strict:!0,stringKeys:!1,uniqueKeys:!0,version:"1.2"},n);this.options=r;let{version:o}=r;n?._directives?(this.directives=n._directives.atDocument(),this.directives.yaml.explicit&&(o=this.directives.yaml.version)):this.directives=new Z({version:o}),this.setSchema(o,n),this.contents=e===void 0?null:this.createNode(e,i,n)}clone(){let e=Object.create(s.prototype,{[K]:{value:He}});return e.commentBefore=this.commentBefore,e.comment=this.comment,e.errors=this.errors.slice(),e.warnings=this.warnings.slice(),e.options=Object.assign({},this.options),this.directives&&(e.directives=this.directives.clone()),e.schema=this.schema.clone(),e.contents=I(this.contents)?this.contents.clone(e.schema):this.contents,this.range&&(e.range=this.range.slice()),e}add(e){Le(this.contents)&&this.contents.add(e)}addIn(e,t){Le(this.contents)&&this.contents.addIn(e,t)}createAlias(e,t){if(!e.anchor){let n=_t(this);e.anchor=!t||n.has(t)?Pt(t||"a",n):t}return new ie(e.anchor)}createNode(e,t,n){let i;if(typeof t=="function")e=t.call({"":e},"",e),i=t;else if(Array.isArray(t)){let m=S=>typeof S=="number"||S instanceof String||S instanceof Number,w=t.filter(m).map(String);w.length>0&&(t=t.concat(w)),i=t}else n===void 0&&t&&(n=t,t=void 0);let{aliasDuplicateObjects:r,anchorPrefix:o,flow:a,keepUndefined:l,onTagObj:c,tag:p}=n??{},{onAnchor:f,setAnchors:u,sourceObjects:d}=ps(this,o||"a"),g={aliasDuplicateObjects:r??!0,keepUndefined:l??!1,onAnchor:f,onTagObj:c,replacer:i,schema:this.schema,sourceObjects:d},h=re(e,p,g);return a&&L(h)&&(h.flow=!0),u(),h}createPair(e,t,n={}){let i=this.createNode(e,null,n),r=this.createNode(t,null,n);return new v(i,r)}delete(e){return Le(this.contents)?this.contents.delete(e):!1}deleteIn(e){return Ne(e)?this.contents==null?!1:(this.contents=null,!0):Le(this.contents)?this.contents.deleteIn(e):!1}get(e,t){return L(this.contents)?this.contents.get(e,t):void 0}getIn(e,t){return Ne(e)?!t&&E(this.contents)?this.contents.value:this.contents:L(this.contents)?this.contents.getIn(e,t):void 0}has(e){return L(this.contents)?this.contents.has(e):!1}hasIn(e){return Ne(e)?this.contents!==void 0:L(this.contents)?this.contents.hasIn(e):!1}set(e,t){this.contents==null?this.contents=ve(this.schema,[e],t):Le(this.contents)&&this.contents.set(e,t)}setIn(e,t){Ne(e)?this.contents=t:this.contents==null?this.contents=ve(this.schema,Array.from(e),t):Le(this.contents)&&this.contents.setIn(e,t)}setSchema(e,t={}){typeof e=="number"&&(e=String(e));let n;switch(e){case"1.1":this.directives?this.directives.yaml.version="1.1":this.directives=new Z({version:"1.1"}),n={resolveKnownTags:!1,schema:"yaml-1.1"};break;case"1.2":case"next":this.directives?this.directives.yaml.version=e:this.directives=new Z({version:e}),n={resolveKnownTags:!0,schema:"core"};break;case null:this.directives&&delete this.directives,n=null;break;default:{let i=JSON.stringify(e);throw new Error(`Expected '1.1', '1.2' or null as first argument, but found: ${i}`)}}if(t.schema instanceof Object)this.schema=t.schema;else if(n)this.schema=new Re(Object.assign(n,t));else throw new Error("With a null YAML version, the { schema: Schema } option is required")}toJS({json:e,jsonArg:t,mapAsMap:n,maxAliasCount:i,onAnchor:r,reviver:o}={}){let a={anchors:new Map,doc:this,keep:!e,mapAsMap:n===!0,mapKeyWarned:!1,maxAliasCount:typeof i=="number"?i:100},l=M(this.contents,t??"",a);if(typeof r=="function")for(let{count:c,res:p}of a.anchors.values())r(p,c);return typeof o=="function"?le(o,{"":l},"",l):l}toJSON(e,t){return this.toJS({json:!0,jsonArg:e,mapAsMap:!1,onAnchor:t})}toString(e={}){if(this.errors.length>0)throw new Error("Document with errors cannot be stringified");if("indent"in e&&(!Number.isInteger(e.indent)||Number(e.indent)<=0)){let t=JSON.stringify(e.indent);throw new Error(`"indent" option must be a positive integer, not ${t}`)}return Bs(this,e)}};function Le(s){if(L(s))return!0;throw new Error("Expected a YAML collection as document contents")}var Fe=class extends Error{constructor(e,t,n,i){super(),this.name=e,this.code=n,this.message=i,this.pos=t}},G=class extends Fe{constructor(e,t,n){super("YAMLParseError",e,t,n)}},xe=class extends Fe{constructor(e,t,n){super("YAMLWarning",e,t,n)}},Gt=(s,e)=>t=>{if(t.pos[0]===-1)return;t.linePos=t.pos.map(a=>e.linePos(a));let{line:n,col:i}=t.linePos[0];t.message+=` at line ${n}, column ${i}`;let r=i-1,o=s.substring(e.lineStarts[n-1],e.lineStarts[n]).replace(/[\n\r]+$/,"");if(r>=60&&o.length>80){let a=Math.min(r-39,o.length-79);o="\u2026"+o.substring(a),r-=a-1}if(o.length>80&&(o=o.substring(0,79)+"\u2026"),n>1&&/^ *$/.test(o.substring(0,r))){let a=s.substring(e.lineStarts[n-2],e.lineStarts[n-1]);a.length>80&&(a=a.substring(0,79)+`\u2026 +`),o=a+o}if(/[^ ]/.test(o)){let a=1,l=t.linePos[1];l?.line===n&&l.col>i&&(a=Math.max(1,Math.min(l.col-i,80-r)));let c=" ".repeat(r)+"^".repeat(a);t.message+=`: + +${o} +${c} +`}};function te(s,{flow:e,indicator:t,next:n,offset:i,onError:r,parentIndent:o,startOnNewline:a}){let l=!1,c=a,p=a,f="",u="",d=!1,g=!1,h=null,m=null,w=null,S=null,k=null,N=null,O=null;for(let b of s)switch(g&&(b.type!=="space"&&b.type!=="newline"&&b.type!=="comma"&&r(b.offset,"MISSING_CHAR","Tags and anchors must be separated from the next token by white space"),g=!1),h&&(c&&b.type!=="comment"&&b.type!=="newline"&&r(h,"TAB_AS_INDENT","Tabs are not allowed as indentation"),h=null),b.type){case"space":!e&&(t!=="doc-start"||n?.type!=="flow-collection")&&b.source.includes(" ")&&(h=b),p=!0;break;case"comment":{p||r(b,"MISSING_CHAR","Comments must be separated from other tokens by white space characters");let _=b.source.substring(1)||" ";f?f+=u+_:f=_,u="",c=!1;break}case"newline":c?f?f+=b.source:(!N||t!=="seq-item-ind")&&(l=!0):u+=b.source,c=!0,d=!0,(m||w)&&(S=b),p=!0;break;case"anchor":m&&r(b,"MULTIPLE_ANCHORS","A node can have at most one anchor"),b.source.endsWith(":")&&r(b.offset+b.source.length-1,"BAD_ALIAS","Anchor ending in : is ambiguous",!0),m=b,O??(O=b.offset),c=!1,p=!1,g=!0;break;case"tag":{w&&r(b,"MULTIPLE_TAGS","A node can have at most one tag"),w=b,O??(O=b.offset),c=!1,p=!1,g=!0;break}case t:(m||w)&&r(b,"BAD_PROP_ORDER",`Anchors and tags must be after the ${b.source} indicator`),N&&r(b,"UNEXPECTED_TOKEN",`Unexpected ${b.source} in ${e??"collection"}`),N=b,c=t==="seq-item-ind"||t==="explicit-key-ind",p=!1;break;case"comma":if(e){k&&r(b,"UNEXPECTED_TOKEN",`Unexpected , in ${e}`),k=b,c=!1,p=!1;break}default:r(b,"UNEXPECTED_TOKEN",`Unexpected ${b.type} token`),c=!1,p=!1}let A=s[s.length-1],C=A?A.offset+A.source.length:i;return g&&n&&n.type!=="space"&&n.type!=="newline"&&n.type!=="comma"&&(n.type!=="scalar"||n.source!=="")&&r(n.offset,"MISSING_CHAR","Tags and anchors must be separated from the next token by white space"),h&&(c&&h.indent<=o||n?.type==="block-map"||n?.type==="block-seq")&&r(h,"TAB_AS_INDENT","Tabs are not allowed as indentation"),{comma:k,found:N,spaceBefore:l,comment:f,hasNewline:d,anchor:m,tag:w,newlineAfterProp:S,end:C,start:O??C}}function pe(s){if(!s)return null;switch(s.type){case"alias":case"scalar":case"double-quoted-scalar":case"single-quoted-scalar":if(s.source.includes(` +`))return!0;if(s.end){for(let e of s.end)if(e.type==="newline")return!0}return!1;case"flow-collection":for(let e of s.items){for(let t of e.start)if(t.type==="newline")return!0;if(e.sep){for(let t of e.sep)if(t.type==="newline")return!0}if(pe(e.key)||pe(e.value))return!0}return!1;default:return!0}}function Ue(s,e,t){if(e?.type==="flow-collection"){let n=e.end[0];n.indent===s&&(n.source==="]"||n.source==="}")&&pe(e)&&t(n,"BAD_INDENT","Flow end indicator should be more indented than parent",!0)}}function At(s,e,t){let{uniqueKeys:n}=s.options;if(n===!1)return!1;let i=typeof n=="function"?n:(r,o)=>r===o||E(r)&&E(o)&&r.value===o.value;return e.some(r=>i(r.key,t))}var Ds="All mapping items must start at the same column";function Ks({composeNode:s,composeEmptyNode:e},t,n,i,r){let o=r?.nodeClass??$,a=new o(t.schema);t.atRoot&&(t.atRoot=!1);let l=n.offset,c=null;for(let p of n.items){let{start:f,key:u,sep:d,value:g}=p,h=te(f,{indicator:"explicit-key-ind",next:u??d?.[0],offset:l,onError:i,parentIndent:n.indent,startOnNewline:!0}),m=!h.found;if(m){if(u&&(u.type==="block-seq"?i(l,"BLOCK_AS_IMPLICIT_KEY","A block sequence may not be used as an implicit map key"):"indent"in u&&u.indent!==n.indent&&i(l,"BAD_INDENT",Ds)),!h.anchor&&!h.tag&&!d){c=h.end,h.comment&&(a.comment?a.comment+=` +`+h.comment:a.comment=h.comment);continue}(h.newlineAfterProp||pe(u))&&i(u??f[f.length-1],"MULTILINE_IMPLICIT_KEY","Implicit keys need to be on a single line")}else h.found?.indent!==n.indent&&i(l,"BAD_INDENT",Ds);t.atKey=!0;let w=h.end,S=u?s(t,u,h,i):e(t,w,f,null,h,i);t.schema.compat&&Ue(n.indent,u,i),t.atKey=!1,At(t,a.items,S)&&i(w,"DUPLICATE_KEY","Map keys must be unique");let k=te(d??[],{indicator:"map-value-ind",next:g,offset:S.range[2],onError:i,parentIndent:n.indent,startOnNewline:!u||u.type==="block-scalar"});if(l=k.end,k.found){m&&(g?.type==="block-map"&&!k.hasNewline&&i(l,"BLOCK_AS_IMPLICIT_KEY","Nested mappings are not allowed in compact mappings"),t.options.strict&&h.starts&&(s.type==="block-map"||s.type==="block-seq");function qs({composeNode:s,composeEmptyNode:e},t,n,i,r){let o=n.start.source==="{",a=o?"flow map":"flow sequence",l=r?.nodeClass??(o?$:B),c=new l(t.schema);c.flow=!0;let p=t.atRoot;p&&(t.atRoot=!1),t.atKey&&(t.atKey=!1);let f=n.offset+n.start.source.length;for(let m=0;m0){let m=se(g,h,t.options.strict,i);m.comment&&(c.comment?c.comment+=` +`+m.comment:c.comment=m.comment),c.range=[n.offset,h,m.offset]}else c.range=[n.offset,h,h];return c}function Xt(s,e,t,n,i,r){let o=t.type==="block-map"?Ks(s,e,t,n,r):t.type==="block-seq"?js(s,e,t,n,r):qs(s,e,t,n,r),a=o.constructor;return i==="!"||i===a.tagName?(o.tag=a.tagName,o):(i&&(o.tag=i),o)}function Rs(s,e,t,n,i){let r=n.tag,o=r?e.directives.tagName(r.source,u=>i(r,"TAG_RESOLVE_FAILED",u)):null;if(t.type==="block-seq"){let{anchor:u,newlineAfterProp:d}=n,g=u&&r?u.offset>r.offset?u:r:u??r;g&&(!d||d.offsetu.tag===o&&u.collection===a);if(!l){let u=e.schema.knownTags[o];if(u?.collection===a)e.schema.tags.push(Object.assign({},u,{default:!1})),l=u;else return u?i(r,"BAD_COLLECTION_TYPE",`${u.tag} used for ${a} collection, but expects ${u.collection??"scalar"}`,!0):i(r,"TAG_RESOLVE_FAILED",`Unresolved tag: ${o}`,!0),Xt(s,e,t,i,o)}let c=Xt(s,e,t,i,o,l),p=l.resolve?.(c,u=>i(r,"TAG_RESOLVE_FAILED",u),e.options)??c,f=I(p)?p:new y(p);return f.range=c.range,f.tag=o,l?.format&&(f.format=l.format),f}function zt(s,e,t){let n=e.offset,i=wn(e,s.options.strict,t);if(!i)return{value:"",type:null,comment:"",range:[n,n,n]};let r=i.mode===">"?y.BLOCK_FOLDED:y.BLOCK_LITERAL,o=e.source?Sn(e.source):[],a=o.length;for(let h=o.length-1;h>=0;--h){let m=o[h][1];if(m===""||m==="\r")a=h;else break}if(a===0){let h=i.chomp==="+"&&o.length>0?` +`.repeat(Math.max(1,o.length-1)):"",m=n+i.length;return e.source&&(m+=e.source.length),{value:h,type:r,comment:i.comment,range:[n,m,m]}}let l=e.indent+i.indent,c=e.offset+i.length,p=0;for(let h=0;hl&&(l=m.length);else{m.length=a;--h)o[h][0].length>l&&(a=h+1);let f="",u="",d=!1;for(let h=0;hl||w[0]===" "?(u===" "?u=` +`:!d&&u===` +`&&(u=` + +`),f+=u+m.slice(l)+w,u=` +`,d=!0):w===""?u===` +`?f+=` +`:u=` +`:(f+=u+w,u=" ",d=!1)}switch(i.chomp){case"-":break;case"+":for(let h=a;ht(n+u,d,g);switch(i){case"scalar":a=y.PLAIN,l=kn(r,c);break;case"single-quoted-scalar":a=y.QUOTE_SINGLE,l=Nn(r,c);break;case"double-quoted-scalar":a=y.QUOTE_DOUBLE,l=On(r,c);break;default:return t(s,"UNEXPECTED_TOKEN",`Expected a flow scalar value, but found: ${i}`),{value:"",type:null,comment:"",range:[n,n+r.length,n+r.length]}}let p=n+r.length,f=se(o,p,e,t);return{value:l,type:a,comment:f.comment,range:[n,p,f.offset]}}function kn(s,e){let t="";switch(s[0]){case" ":t="a tab character";break;case",":t="flow indicator character ,";break;case"%":t="directive indicator character %";break;case"|":case">":{t=`block scalar indicator ${s[0]}`;break}case"@":case"`":{t=`reserved character ${s[0]}`;break}}return t&&e(0,"BAD_SCALAR_START",`Plain value cannot start with ${t}`),Fs(s)}function Nn(s,e){return(s[s.length-1]!=="'"||s.length===1)&&e(s.length,"MISSING_CHAR","Missing closing 'quote"),Fs(s.slice(1,-1)).replace(/''/g,"'")}function Fs(s){let e=/(.*?)\r?\n/sy,t=e.exec(s);if(!t)return s;let n,i;try{n=new RegExp("(?r?s.slice(r,n+1):i)}else t+=i}return(s[s.length-1]!=='"'||s.length===1)&&e(s.length,"MISSING_CHAR",'Missing closing "quote'),t}function An(s,e){let t="",n=s[e+1];for(;(n===" "||n===" "||n===` +`||n==="\r")&&!(n==="\r"&&s[e+2]!==` +`);)n===` +`&&(t+=` +`),e+=1,n=s[e+1];return t||(t=" "),{fold:t,offset:e}}var En={0:"\0",a:"\x07",b:"\b",e:"\x1B",f:"\f",n:` +`,r:"\r",t:" ",v:"\v",N:"\x85",_:"\xA0",L:"\u2028",P:"\u2029"," ":" ",'"':'"',"/":"/","\\":"\\"," ":" "};function Tn(s,e,t,n){let i=s.substr(e,t),o=i.length===t&&/^[0-9a-fA-F]+$/.test(i)?parseInt(i,16):NaN;try{return String.fromCodePoint(o)}catch{let a=s.substr(e-2,t+2);return n(e-2,"BAD_DQ_ESCAPE",`Invalid escape sequence ${a}`),a}}function es(s,e,t,n){let{value:i,type:r,comment:o,range:a}=e.type==="block-scalar"?zt(s,e,n):Zt(e,s.options.strict,n),l=t?s.directives.tagName(t.source,f=>n(t,"TAG_RESOLVE_FAILED",f)):null,c;s.options.stringKeys&&s.atKey?c=s.schema[q]:l?c=In(s.schema,i,l,t,n):e.type==="scalar"?c=Ln(s,i,e,n):c=s.schema[q];let p;try{let f=c.resolve(i,u=>n(t??e,"TAG_RESOLVE_FAILED",u),s.options);p=E(f)?f:new y(f)}catch(f){let u=f instanceof Error?f.message:String(f);n(t??e,"TAG_RESOLVE_FAILED",u),p=new y(i)}return p.range=a,p.source=i,r&&(p.type=r),l&&(p.tag=l),c.format&&(p.format=c.format),o&&(p.comment=o),p}function In(s,e,t,n,i){if(t==="!")return s[q];let r=[];for(let a of s.tags)if(!a.collection&&a.tag===t)if(a.default&&a.test)r.push(a);else return a;for(let a of r)if(a.test?.test(e))return a;let o=s.knownTags[t];return o&&!o.collection?(s.tags.push(Object.assign({},o,{default:!1,test:void 0})),o):(i(n,"TAG_RESOLVE_FAILED",`Unresolved tag: ${t}`,t!=="tag:yaml.org,2002:str"),s[q])}function Ln({atKey:s,directives:e,schema:t},n,i,r){let o=t.tags.find(a=>(a.default===!0||s&&a.default==="key")&&a.test?.test(n))||t[q];if(t.compat){let a=t.compat.find(l=>l.default&&l.test?.test(n))??t[q];if(o.tag!==a.tag){let l=e.tagString(o.tag),c=e.tagString(a.tag),p=`Value may be parsed as either ${l} or ${c}`;r(i,"TAG_RESOLVE_FAILED",p,!0)}}return o}function xs(s,e,t){if(e){t??(t=e.length);for(let n=t-1;n>=0;--n){let i=e[n];switch(i.type){case"space":case"comment":case"newline":s-=i.source.length;continue}for(i=e[++n];i?.type==="space";)s+=i.source.length,i=e[++n];break}}return s}var Cn={composeNode:ts,composeEmptyNode:Et};function ts(s,e,t,n){let i=s.atKey,{spaceBefore:r,comment:o,anchor:a,tag:l}=t,c,p=!0;switch(e.type){case"alias":c=vn(s,e,n),(a||l)&&n(e,"ALIAS_PROPS","An alias node must not specify any properties");break;case"scalar":case"single-quoted-scalar":case"double-quoted-scalar":case"block-scalar":c=es(s,e,l,n),a&&(c.anchor=a.source.substring(1));break;case"block-map":case"block-seq":case"flow-collection":try{c=Rs(Cn,s,e,t,n),a&&(c.anchor=a.source.substring(1))}catch(f){let u=f instanceof Error?f.message:String(f);n(e,"RESOURCE_EXHAUSTION",u)}break;default:{let f=e.type==="error"?e.message:`Unsupported token (type: ${e.type})`;n(e,"UNEXPECTED_TOKEN",f),p=!1}}return c??(c=Et(s,e.offset,void 0,null,t,n)),a&&c.anchor===""&&n(a,"BAD_ALIAS","Anchor cannot be an empty string"),i&&s.options.stringKeys&&(!E(c)||typeof c.value!="string"||c.tag&&c.tag!=="tag:yaml.org,2002:str")&&n(l??e,"NON_STRING_KEY","With stringKeys, all keys must be strings"),r&&(c.spaceBefore=!0),o&&(e.type==="scalar"&&e.source===""?c.comment=o:c.commentBefore=o),s.options.keepSourceTokens&&p&&(c.srcToken=e),c}function Et(s,e,t,n,{spaceBefore:i,comment:r,anchor:o,tag:a,end:l},c){let p={type:"scalar",offset:xs(e,t,n),indent:-1,source:""},f=es(s,p,a,c);return o&&(f.anchor=o.source.substring(1),f.anchor===""&&c(o,"BAD_ALIAS","Anchor cannot be an empty string")),i&&(f.spaceBefore=!0),r&&(f.comment=r,f.range[2]=l),f}function vn({options:s},{offset:e,source:t,end:n},i){let r=new ie(t.substring(1));r.source===""&&i(e,"BAD_ALIAS","Alias cannot be an empty string"),r.source.endsWith(":")&&i(e+t.length-1,"BAD_ALIAS","Alias ending in : is ambiguous",!0);let o=e+t.length,a=se(n,o,s.strict,i);return r.range=[e,o,a.offset],a.comment&&(r.comment=a.comment),r}function Us(s,e,{offset:t,start:n,value:i,end:r},o){let a=Object.assign({_directives:e},s),l=new he(void 0,a),c={atKey:!1,atRoot:!0,directives:l.directives,options:l.options,schema:l.schema},p=te(n,{indicator:"doc-start",next:i??r?.[0],offset:t,onError:o,parentIndent:0,startOnNewline:!0});p.found&&(l.directives.docStart=!0,i&&(i.type==="block-map"||i.type==="block-seq")&&!p.hasNewline&&o(p.end,"MISSING_CHAR","Block collection cannot start on same line with directives-end marker")),l.contents=i?ts(c,i,p,o):Et(c,p.end,n,null,p,o);let f=l.contents.range[2],u=se(r,f,!1,o);return u.comment&&(l.comment=u.comment),l.range=[t,f,u.offset],l}function Ve(s){if(typeof s=="number")return[s,s+1];if(Array.isArray(s))return s.length===2?s:[s[0],s[1]];let{offset:e,source:t}=s;return[e,e+(typeof t=="string"?t.length:1)]}function Vs(s){let e="",t=!1,n=!1;for(let i=0;i{let o=Ve(t);r?this.warnings.push(new xe(o,n,i)):this.errors.push(new G(o,n,i))},this.directives=new Z({version:e.version||"1.2"}),this.options=e}decorate(e,t){let{comment:n,afterEmptyLine:i}=Vs(this.prelude);if(n){let r=e.contents;if(t)e.comment=e.comment?`${e.comment} +${n}`:n;else if(i||e.directives.docStart||!r)e.commentBefore=n;else if(L(r)&&!r.flow&&r.items.length>0){let o=r.items[0];T(o)&&(o=o.key);let a=o.commentBefore;o.commentBefore=a?`${n} +${a}`:n}else{let o=r.commentBefore;r.commentBefore=o?`${n} +${o}`:n}}if(t){for(let r=0;r{let r=Ve(e);r[0]+=t,this.onError(r,"BAD_DIRECTIVE",n,i)}),this.prelude.push(e.source),this.atDirectives=!0;break;case"document":{let t=Us(this.options,this.directives,e,this.onError);this.atDirectives&&!t.directives.docStart&&this.onError(e,"MISSING_CHAR","Missing directives-end/doc-start indicator line"),this.decorate(t,!1),this.doc&&(yield this.doc),this.doc=t,this.atDirectives=!1;break}case"byte-order-mark":case"space":break;case"comment":case"newline":this.prelude.push(e.source);break;case"error":{let t=e.source?`${e.message}: ${JSON.stringify(e.source)}`:e.message,n=new G(Ve(e),"UNEXPECTED_TOKEN",t);this.atDirectives||!this.doc?this.errors.push(n):this.doc.errors.push(n);break}case"doc-end":{if(!this.doc){let n="Unexpected doc-end without preceding document";this.errors.push(new G(Ve(e),"UNEXPECTED_TOKEN",n));break}this.doc.directives.docEnd=!0;let t=se(e.end,e.offset+e.source.length,this.doc.options.strict,this.onError);if(this.decorate(this.doc,!0),t.comment){let n=this.doc.comment;this.doc.comment=n?`${n} +${t.comment}`:t.comment}this.doc.range[2]=t.offset;break}default:this.errors.push(new G(Ve(e),"UNEXPECTED_TOKEN",`Unsupported token ${e.type}`))}}*end(e=!1,t=-1){if(this.doc)this.decorate(this.doc,!0),yield this.doc,this.doc=null;else if(e){let n=Object.assign({_directives:this.directives},this.options),i=new he(void 0,n);this.atDirectives&&this.onError(t,"MISSING_CHAR","Missing directives-end indicator line"),i.range=[0,t,t],this.decorate(i,!1),yield i}}};var ss=Symbol("break visit"),$n=Symbol("skip children"),Js=Symbol("remove item");function ye(s,e){"type"in s&&s.type==="document"&&(s={start:s.start,value:s.value}),Ys(Object.freeze([]),s,e)}ye.BREAK=ss;ye.SKIP=$n;ye.REMOVE=Js;ye.itemAtPath=(s,e)=>{let t=s;for(let[n,i]of e){let r=t?.[n];if(r&&"items"in r)t=r.items[i];else return}return t};ye.parentCollection=(s,e)=>{let t=ye.itemAtPath(s,e.slice(0,-1)),n=e[e.length-1][0],i=t?.[n];if(i&&"items"in i)return i;throw new Error("Parent collection not found")};function Ys(s,e,t){let n=t(e,s);if(typeof n=="symbol")return n;for(let i of["key","value"]){let r=e[i];if(r&&"items"in r){for(let o=0;o":return"block-scalar-header"}return null}function W(s){switch(s){case void 0:case" ":case` +`:case"\r":case" ":return!0;default:return!1}}var Gs=new Set("0123456789ABCDEFabcdef"),Pn=new Set("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-#;/?:@&=+$_.!~*'()"),It=new Set(",[]{}"),Mn=new Set(` ,[]{} +\r `),os=s=>!s||Mn.has(s),Ye=class{constructor(){this.atEnd=!1,this.blockScalarIndent=-1,this.blockScalarKeep=!1,this.buffer="",this.flowKey=!1,this.flowLevel=0,this.indentNext=0,this.indentValue=0,this.lineEndPos=null,this.next=null,this.pos=0}*lex(e,t=!1){if(e){if(typeof e!="string")throw TypeError("source is not a string");this.buffer=this.buffer?this.buffer+e:e,this.lineEndPos=null}this.atEnd=!t;let n=this.next??"stream";for(;n&&(t||this.hasChars(1));)n=yield*this.parseNext(n)}atLineEnd(){let e=this.pos,t=this.buffer[e];for(;t===" "||t===" ";)t=this.buffer[++e];return!t||t==="#"||t===` +`?!0:t==="\r"?this.buffer[e+1]===` +`:!1}charAt(e){return this.buffer[this.pos+e]}continueScalar(e){let t=this.buffer[e];if(this.indentNext>0){let n=0;for(;t===" ";)t=this.buffer[++n+e];if(t==="\r"){let i=this.buffer[n+e+1];if(i===` +`||!i&&!this.atEnd)return e+n+1}return t===` +`||n>=this.indentNext||!t&&!this.atEnd?e+n:-1}if(t==="-"||t==="."){let n=this.buffer.substr(e,3);if((n==="---"||n==="...")&&W(this.buffer[e+3]))return-1}return e}getLine(){let e=this.lineEndPos;return(typeof e!="number"||e!==-1&&ethis.indentValue&&!W(this.charAt(1))&&(this.indentNext=this.indentValue),yield*this.parseBlockStart()}*parseBlockStart(){let[e,t]=this.peek(2);if(!t&&!this.atEnd)return this.setNext("block-start");if((e==="-"||e==="?"||e===":")&&W(t)){let n=(yield*this.pushCount(1))+(yield*this.pushSpaces(!0));return this.indentNext=this.indentValue+1,this.indentValue+=n,"block-start"}return"doc"}*parseDocument(){yield*this.pushSpaces(!0);let e=this.getLine();if(e===null)return this.setNext("doc");let t=yield*this.pushIndicators();switch(e[t]){case"#":yield*this.pushCount(e.length-t);case void 0:return yield*this.pushNewline(),yield*this.parseLineStart();case"{":case"[":return yield*this.pushCount(1),this.flowKey=!1,this.flowLevel=1,"flow";case"}":case"]":return yield*this.pushCount(1),"doc";case"*":return yield*this.pushUntil(os),"doc";case'"':case"'":return yield*this.parseQuotedScalar();case"|":case">":return t+=yield*this.parseBlockScalarHeader(),t+=yield*this.pushSpaces(!0),yield*this.pushCount(e.length-t),yield*this.pushNewline(),yield*this.parseBlockScalar();default:return yield*this.parsePlainScalar()}}*parseFlowCollection(){let e,t,n=-1;do e=yield*this.pushNewline(),e>0?(t=yield*this.pushSpaces(!1),this.indentValue=n=t):t=0,t+=yield*this.pushSpaces(!0);while(e+t>0);let i=this.getLine();if(i===null)return this.setNext("flow");if((n!==-1&&n"0"&&t<="9")this.blockScalarIndent=Number(t)-1;else if(t!=="-")break}return yield*this.pushUntil(t=>W(t)||t==="#")}*parseBlockScalar(){let e=this.pos-1,t=0,n;e:for(let r=this.pos;n=this.buffer[r];++r)switch(n){case" ":t+=1;break;case` +`:e=r,t=0;break;case"\r":{let o=this.buffer[r+1];if(!o&&!this.atEnd)return this.setNext("block-scalar");if(o===` +`)break}default:break e}if(!n&&!this.atEnd)return this.setNext("block-scalar");if(t>=this.indentNext){this.blockScalarIndent===-1?this.indentNext=t:this.indentNext=this.blockScalarIndent+(this.indentNext===0?1:this.indentNext);do{let r=this.continueScalar(e+1);if(r===-1)break;e=this.buffer.indexOf(` +`,r)}while(e!==-1);if(e===-1){if(!this.atEnd)return this.setNext("block-scalar");e=this.buffer.length}}let i=e+1;for(n=this.buffer[i];n===" ";)n=this.buffer[++i];if(n===" "){for(;n===" "||n===" "||n==="\r"||n===` +`;)n=this.buffer[++i];e=i-1}else if(!this.blockScalarKeep)do{let r=e-1,o=this.buffer[r];o==="\r"&&(o=this.buffer[--r]);let a=r;for(;o===" ";)o=this.buffer[--r];if(o===` +`&&r>=this.pos&&r+1+t>a)e=r;else break}while(!0);return yield Tt,yield*this.pushToIndex(e+1,!0),yield*this.parseLineStart()}*parsePlainScalar(){let e=this.flowLevel>0,t=this.pos-1,n=this.pos-1,i;for(;i=this.buffer[++n];)if(i===":"){let r=this.buffer[n+1];if(W(r)||e&&It.has(r))break;t=n}else if(W(i)){let r=this.buffer[n+1];if(i==="\r"&&(r===` +`?(n+=1,i=` +`,r=this.buffer[n+1]):t=n),r==="#"||e&&It.has(r))break;if(i===` +`){let o=this.continueScalar(n+1);if(o===-1)break;n=Math.max(n,o-2)}}else{if(e&&It.has(i))break;t=n}return!i&&!this.atEnd?this.setNext("plain-scalar"):(yield Tt,yield*this.pushToIndex(t+1,!0),e?"flow":"doc")}*pushCount(e){return e>0?(yield this.buffer.substr(this.pos,e),this.pos+=e,e):0}*pushToIndex(e,t){let n=this.buffer.slice(this.pos,e);return n?(yield n,this.pos+=n.length,n.length):(t&&(yield""),0)}*pushIndicators(){let e=0;e:for(;;){switch(this.charAt(0)){case"!":e+=yield*this.pushTag(),e+=yield*this.pushSpaces(!0);continue e;case"&":e+=yield*this.pushUntil(os),e+=yield*this.pushSpaces(!0);continue e;case"-":case"?":case":":{let t=this.flowLevel>0,n=this.charAt(1);if(W(n)||t&&It.has(n)){t?this.flowKey&&(this.flowKey=!1):this.indentNext=this.indentValue+1,e+=yield*this.pushCount(1),e+=yield*this.pushSpaces(!0);continue e}}}break e}return e}*pushTag(){if(this.charAt(1)==="<"){let e=this.pos+2,t=this.buffer[e];for(;!W(t)&&t!==">";)t=this.buffer[++e];return yield*this.pushToIndex(t===">"?e+1:e,!1)}else{let e=this.pos+1,t=this.buffer[e];for(;t;)if(Pn.has(t))t=this.buffer[++e];else if(t==="%"&&Gs.has(this.buffer[e+1])&&Gs.has(this.buffer[e+2]))t=this.buffer[e+=3];else break;return yield*this.pushToIndex(e,!1)}}*pushNewline(){let e=this.buffer[this.pos];return e===` +`?yield*this.pushCount(1):e==="\r"&&this.charAt(1)===` +`?yield*this.pushCount(2):0}*pushSpaces(e){let t=this.pos-1,n;do n=this.buffer[++t];while(n===" "||e&&n===" ");let i=t-this.pos;return i>0&&(yield this.buffer.substr(this.pos,i),this.pos=t),i}*pushUntil(e){let t=this.pos,n=this.buffer[t];for(;!e(n);)n=this.buffer[++t];return yield*this.pushToIndex(t,!1)}};var Qe=class{constructor(){this.lineStarts=[],this.addNewLine=e=>this.lineStarts.push(e),this.linePos=e=>{let t=0,n=this.lineStarts.length;for(;t>1;this.lineStarts[r]=0;)switch(s[e].type){case"doc-start":case"explicit-key-ind":case"map-value-ind":case"seq-item-ind":case"newline":break e}for(;s[++e]?.type==="space";);return s.splice(e,s.length)}function Ct(s,e){if(e.length<1e5)Array.prototype.push.apply(s,e);else for(let t=0;t0;)yield*this.pop()}get sourceToken(){return{type:this.type,offset:this.offset,indent:this.indent,source:this.source}}*step(){let e=this.peek(1);if(this.type==="doc-end"&&e?.type!=="doc-end"){for(;this.stack.length>0;)yield*this.pop();this.stack.push({type:"doc-end",offset:this.offset,source:this.source});return}if(!e)return yield*this.stream();switch(e.type){case"document":return yield*this.document(e);case"alias":case"scalar":case"single-quoted-scalar":case"double-quoted-scalar":return yield*this.scalar(e);case"block-scalar":return yield*this.blockScalar(e);case"block-map":return yield*this.blockMap(e);case"block-seq":return yield*this.blockSequence(e);case"flow-collection":return yield*this.flowCollection(e);case"doc-end":return yield*this.documentEnd(e)}yield*this.pop()}peek(e){return this.stack[this.stack.length-e]}*pop(e){let t=e??this.stack.pop();if(!t)yield{type:"error",offset:this.offset,source:"",message:"Tried to pop an empty stack"};else if(this.stack.length===0)yield t;else{let n=this.peek(1);switch(t.type==="block-scalar"?t.indent="indent"in n?n.indent:0:t.type==="flow-collection"&&n.type==="document"&&(t.indent=0),t.type==="flow-collection"&&Hs(t),n.type){case"document":n.value=t;break;case"block-scalar":n.props.push(t);break;case"block-map":{let i=n.items[n.items.length-1];if(i.value){n.items.push({start:[],key:t,sep:[]}),this.onKeyLine=!0;return}else if(i.sep)i.value=t;else{Object.assign(i,{key:t,sep:[]}),this.onKeyLine=!i.explicitKey;return}break}case"block-seq":{let i=n.items[n.items.length-1];i.value?n.items.push({start:[],value:t}):i.value=t;break}case"flow-collection":{let i=n.items[n.items.length-1];!i||i.value?n.items.push({start:[],key:t,sep:[]}):i.sep?i.value=t:Object.assign(i,{key:t,sep:[]});return}default:yield*this.pop(),yield*this.pop(t)}if((n.type==="document"||n.type==="block-map"||n.type==="block-seq")&&(t.type==="block-map"||t.type==="block-seq")){let i=t.items[t.items.length-1];i&&!i.sep&&!i.value&&i.start.length>0&&Ws(i.start)===-1&&(t.indent===0||i.start.every(r=>r.type!=="comment"||r.indent=e.indent){let n=!this.onKeyLine&&this.indent===e.indent,i=n&&(t.sep||t.explicitKey)&&this.type!=="seq-item-ind",r=[];if(i&&t.sep&&!t.value){let o=[];for(let a=0;ae.indent&&(o.length=0);break;default:o.length=0}}o.length>=2&&(r=t.sep.splice(o[1]))}switch(this.type){case"anchor":case"tag":i||t.value?(r.push(this.sourceToken),e.items.push({start:r}),this.onKeyLine=!0):t.sep?t.sep.push(this.sourceToken):t.start.push(this.sourceToken);return;case"explicit-key-ind":!t.sep&&!t.explicitKey?(t.start.push(this.sourceToken),t.explicitKey=!0):i||t.value?(r.push(this.sourceToken),e.items.push({start:r,explicitKey:!0})):this.stack.push({type:"block-map",offset:this.offset,indent:this.indent,items:[{start:[this.sourceToken],explicitKey:!0}]}),this.onKeyLine=!0;return;case"map-value-ind":if(t.explicitKey)if(t.sep)if(t.value)e.items.push({start:[],key:null,sep:[this.sourceToken]});else if(me(t.sep,"map-value-ind"))this.stack.push({type:"block-map",offset:this.offset,indent:this.indent,items:[{start:r,key:null,sep:[this.sourceToken]}]});else if(Xs(t.key)&&!me(t.sep,"newline")){let o=Ce(t.start),a=t.key,l=t.sep;l.push(this.sourceToken),delete t.key,delete t.sep,this.stack.push({type:"block-map",offset:this.offset,indent:this.indent,items:[{start:o,key:a,sep:l}]})}else r.length>0?t.sep=t.sep.concat(r,this.sourceToken):t.sep.push(this.sourceToken);else if(me(t.start,"newline"))Object.assign(t,{key:null,sep:[this.sourceToken]});else{let o=Ce(t.start);this.stack.push({type:"block-map",offset:this.offset,indent:this.indent,items:[{start:o,key:null,sep:[this.sourceToken]}]})}else t.sep?t.value||i?e.items.push({start:r,key:null,sep:[this.sourceToken]}):me(t.sep,"map-value-ind")?this.stack.push({type:"block-map",offset:this.offset,indent:this.indent,items:[{start:[],key:null,sep:[this.sourceToken]}]}):t.sep.push(this.sourceToken):Object.assign(t,{key:null,sep:[this.sourceToken]});this.onKeyLine=!0;return;case"alias":case"scalar":case"single-quoted-scalar":case"double-quoted-scalar":{let o=this.flowScalar(this.type);i||t.value?(e.items.push({start:r,key:o,sep:[]}),this.onKeyLine=!0):t.sep?this.stack.push(o):(Object.assign(t,{key:o,sep:[]}),this.onKeyLine=!0);return}default:{let o=this.startBlockValue(e);if(o){if(o.type==="block-seq"){if(!t.explicitKey&&t.sep&&!me(t.sep,"newline")){yield*this.pop({type:"error",offset:this.offset,message:"Unexpected block-seq-ind on same line with key",source:this.source});return}}else n&&e.items.push({start:r});this.stack.push(o);return}}}}yield*this.pop(),yield*this.step()}*blockSequence(e){let t=e.items[e.items.length-1];switch(this.type){case"newline":if(t.value){let n="end"in t.value?t.value.end:void 0;(Array.isArray(n)?n[n.length-1]:void 0)?.type==="comment"?n?.push(this.sourceToken):e.items.push({start:[this.sourceToken]})}else t.start.push(this.sourceToken);return;case"space":case"comment":if(t.value)e.items.push({start:[this.sourceToken]});else{if(this.atIndentedComment(t.start,e.indent)){let i=e.items[e.items.length-2]?.value?.end;if(Array.isArray(i)){Ct(i,t.start),i.push(this.sourceToken),e.items.pop();return}}t.start.push(this.sourceToken)}return;case"anchor":case"tag":if(t.value||this.indent<=e.indent)break;t.start.push(this.sourceToken);return;case"seq-item-ind":if(this.indent!==e.indent)break;t.value||me(t.start,"seq-item-ind")?e.items.push({start:[this.sourceToken]}):t.start.push(this.sourceToken);return}if(this.indent>e.indent){let n=this.startBlockValue(e);if(n){this.stack.push(n);return}}yield*this.pop(),yield*this.step()}*flowCollection(e){let t=e.items[e.items.length-1];if(this.type==="flow-error-end"){let n;do yield*this.pop(),n=this.peek(1);while(n?.type==="flow-collection")}else if(e.end.length===0){switch(this.type){case"comma":case"explicit-key-ind":!t||t.sep?e.items.push({start:[this.sourceToken]}):t.start.push(this.sourceToken);return;case"map-value-ind":!t||t.value?e.items.push({start:[],key:null,sep:[this.sourceToken]}):t.sep?t.sep.push(this.sourceToken):Object.assign(t,{key:null,sep:[this.sourceToken]});return;case"space":case"comment":case"newline":case"anchor":case"tag":!t||t.value?e.items.push({start:[this.sourceToken]}):t.sep?t.sep.push(this.sourceToken):t.start.push(this.sourceToken);return;case"alias":case"scalar":case"single-quoted-scalar":case"double-quoted-scalar":{let i=this.flowScalar(this.type);!t||t.value?e.items.push({start:[],key:i,sep:[]}):t.sep?this.stack.push(i):Object.assign(t,{key:i,sep:[]});return}case"flow-map-end":case"flow-seq-end":e.end.push(this.sourceToken);return}let n=this.startBlockValue(e);n?this.stack.push(n):(yield*this.pop(),yield*this.step())}else{let n=this.peek(2);if(n.type==="block-map"&&(this.type==="map-value-ind"&&n.indent===e.indent||this.type==="newline"&&!n.items[n.items.length-1].sep))yield*this.pop(),yield*this.step();else if(this.type==="map-value-ind"&&n.type!=="flow-collection"){let i=Lt(n),r=Ce(i);Hs(e);let o=e.end.splice(1,e.end.length);o.push(this.sourceToken);let a={type:"block-map",offset:e.offset,indent:e.indent,items:[{start:r,key:e,sep:o}]};this.onKeyLine=!0,this.stack[this.stack.length-1]=a}else yield*this.lineEnd(e)}}flowScalar(e){if(this.onNewLine){let t=this.source.indexOf(` +`)+1;for(;t!==0;)this.onNewLine(this.offset+t),t=this.source.indexOf(` +`,t)+1}return{type:e,offset:this.offset,indent:this.indent,source:this.source}}startBlockValue(e){switch(this.type){case"alias":case"scalar":case"single-quoted-scalar":case"double-quoted-scalar":return this.flowScalar(this.type);case"block-scalar-header":return{type:"block-scalar",offset:this.offset,indent:this.indent,props:[this.sourceToken],source:""};case"flow-map-start":case"flow-seq-start":return{type:"flow-collection",offset:this.offset,indent:this.indent,start:this.sourceToken,items:[],end:[]};case"seq-item-ind":return{type:"block-seq",offset:this.offset,indent:this.indent,items:[{start:[this.sourceToken]}]};case"explicit-key-ind":{this.onKeyLine=!0;let t=Lt(e),n=Ce(t);return n.push(this.sourceToken),{type:"block-map",offset:this.offset,indent:this.indent,items:[{start:n,explicitKey:!0}]}}case"map-value-ind":{this.onKeyLine=!0;let t=Lt(e),n=Ce(t);return{type:"block-map",offset:this.offset,indent:this.indent,items:[{start:n,key:null,sep:[this.sourceToken]}]}}}return null}atIndentedComment(e,t){return this.type!=="comment"||this.indent<=t?!1:e.every(n=>n.type==="newline"||n.type==="space")}*documentEnd(e){this.type!=="doc-mode"&&(e.end?e.end.push(this.sourceToken):e.end=[this.sourceToken],this.type==="newline"&&(yield*this.pop()))}*lineEnd(e){switch(this.type){case"comma":case"doc-start":case"doc-end":case"flow-seq-end":case"flow-map-end":case"map-value-ind":yield*this.pop(),yield*this.step();break;case"newline":this.onKeyLine=!1;default:e.end?e.end.push(this.sourceToken):e.end=[this.sourceToken],this.type==="newline"&&(yield*this.pop())}}};function Bn(s){let e=s.prettyErrors!==!1;return{lineCounter:s.lineCounter||e&&new Qe||null,prettyErrors:e}}function as(s,e={}){let{lineCounter:t,prettyErrors:n}=Bn(e),i=new Ge(t?.addNewLine),r=new Je(e),o=null;for(let a of r.compose(i.parse(s),!0,s.length))if(!o)o=a;else if(o.options.logLevel!=="silent"){o.errors.push(new G(a.range.slice(0,2),"MULTIPLE_DOCS","Source contains multiple documents; please use YAML.parseAllDocuments()"));break}return n&&t&&(o.errors.forEach(Gt(s,t)),o.warnings.forEach(Gt(s,t))),o}return nn(Kn);})(); +return baseclass.extend(MeowYaml); diff --git a/luci-app-meow/htdocs/luci-static/resources/view/meow/clients.js b/luci-app-meow/htdocs/luci-static/resources/view/meow/clients.js index 5259025a0..e51849b35 100644 --- a/luci-app-meow/htdocs/luci-static/resources/view/meow/clients.js +++ b/luci-app-meow/htdocs/luci-static/resources/view/meow/clients.js @@ -6,11 +6,25 @@ 'require uci'; 'require ui'; +// LAN clients known to this router. The main control is a per-client proxy +// bypass: ticked MACs are stored as `tproxy.bypass_mac` in /etc/config/meow and +// gateway.sh returns their traffic before both the proxy capture and the DNS +// hijack. ARP-based client steering (ARP spoofing, `arp.client`, enforced by +// the meow-arp service) is an opt-in advanced section; its Steer column is only +// shown while steering is enabled. + +// MAC -> { name, ipaddrs, ip6addrs }: DHCP leases, static hosts, /etc/ethers +// and neighbours merged by LuCI, so side routers without DHCP still get rows. var callHostHints = rpc.declare({ - object: 'luci-rpc', method: 'getHostHints', expect: { '': {} } + object: 'luci-rpc', + method: 'getHostHints', + expect: { '': {} } }); + var callDHCPLeases = rpc.declare({ - object: 'luci-rpc', method: 'getDHCPLeases', expect: { '': {} } + object: 'luci-rpc', + method: 'getDHCPLeases', + expect: { '': {} } }); function macList(section, option) { @@ -19,111 +33,305 @@ function macList(section, option) { return (Array.isArray(l) ? l : [ l ]).map(function(m) { return m.toLowerCase(); }); } +// MACs of static leases (`config host` in /etc/config/dhcp). `mac` may be a +// list or a space-separated string of several MACs. +function staticMacs() { + var out = {}; + uci.sections('dhcp', 'host', function(s) { + var m = s.mac; + (Array.isArray(m) ? m : String(m || '').split(/\s+/)).forEach(function(x) { + if (x) out[x.toLowerCase()] = true; + }); + }); + return out; +} + +function ip4ToInt(ip) { + var p = String(ip).split('.'); + if (p.length !== 4) return null; + return ((+p[0] << 24) | (+p[1] << 16) | (+p[2] << 8) | +p[3]) >>> 0; +} + +// ['192.168.1.1/24', ...] -> [{ net, mask }] for the proxied LAN interface. +function parseSubnets(cidrs) { + return (cidrs || []).map(function(c) { + var parts = String(c).split('/'), ip = ip4ToInt(parts[0]); + var bits = parts.length > 1 ? +parts[1] : 32; + if (ip === null || !(bits >= 0 && bits <= 32)) return null; + var mask = bits ? (0xffffffff << (32 - bits)) >>> 0 : 0; + return { self: ip, net: (ip & mask) >>> 0, mask: mask }; + }).filter(Boolean); +} + +function inSubnets(ip, subnets) { + var n = ip4ToInt(ip); + return n !== null && subnets.some(function(s) { + return n !== s.self && ((n & s.mask) >>> 0) === s.net; + }); +} + +// IPv4 subnets of the interface transparent proxying listens on (tproxy.interface). +function lanSubnets() { + var iface = uci.get('meow', 'tproxy', 'interface') || 'lan'; + return L.resolveDefault(network.getNetwork(iface), null).then(function(net) { + return net ? net.getIPAddrs() : []; + }).catch(function() { return []; }); +} + return view.extend({ load: function() { return uci.load('meow').then(function() { return Promise.all([ - null, uci.load('dhcp').catch(function() {}), + null, + uci.load('dhcp').catch(function() {}), L.resolveDefault(callHostHints(), {}), - L.resolveDefault(callDHCPLeases(), {}) + L.resolveDefault(callDHCPLeases(), {}), + lanSubnets() ]); }); }, + // One row per LAN client, including selected MACs that are not currently + // seen so they can still be released. Host hints also cover WAN-side + // neighbours, the router itself, the all-zero MAC and IPv6-link-local-only + // peers; a hint is kept only with an IPv4 on the LAN subnet other than the + // router's own address (any IPv4 when the subnet is unknown). DHCP leases and selected MACs are always kept. + buildClients: function(hints, leases, statics, bypass, steer, cidrs) { + var byMac = {}; + var subnets = parseSubnets(cidrs); + + function row(mac) { + return byMac[mac] || (byMac[mac] = { + mac: mac, name: '', ip4: [], ip6: [], source: 'neighbour' + }); + } + + Object.keys(hints || {}).forEach(function(m) { + var h = hints[m] || {}; + var ip4 = (h.ipaddrs || h.ipv4 || []).filter(function(ip) { + return ip && (!subnets.length || inSubnets(ip, subnets)); + }); + if (!ip4.length || /^(00:){5}00$/i.test(m)) return; + var r = row(m.toLowerCase()); + r.name = h.name || ''; + r.ip4 = ip4; + r.ip6 = (h.ip6addrs || h.ipv6 || []).slice(); + }); + + ((leases || {}).dhcp_leases || []).forEach(function(l) { + if (!l.macaddr) return; + var r = row(l.macaddr.toLowerCase()); + r.source = 'dhcp'; + if (!r.name && l.hostname && l.hostname !== '*') r.name = l.hostname; + if (l.ipaddr && r.ip4.indexOf(l.ipaddr) === -1) r.ip4.unshift(l.ipaddr); + }); + + Object.keys(statics || {}).forEach(function(m) { + if (byMac[m] && byMac[m].source !== 'dhcp') byMac[m].source = 'static'; + }); + + bypass.concat(steer).forEach(function(m) { + if (!byMac[m]) row(m).source = 'offline'; + }); + + return Object.keys(byMac).map(function(m) { return byMac[m]; }) + .sort(function(a, b) { + var ao = a.source === 'offline', bo = b.source === 'offline'; + if (ao !== bo) return ao ? 1 : -1; + if (!a.name !== !b.name) return a.name ? -1 : 1; + return a.name.localeCompare(b.name) || + (a.ip4[0] || '').localeCompare(b.ip4[0] || '', undefined, { numeric: true }) || + a.mac.localeCompare(b.mac); + }); + }, + render: function(data) { var self = this; + var statics = staticMacs(); var bypass = macList('tproxy', 'bypass_mac'); var steer = macList('arp', 'client'); var arpEnabled = uci.get('meow', 'arp', 'enabled') === '1'; + var tproxyEnabled = uci.get('meow', 'tproxy', 'enabled') === '1'; + var clients = this.buildClients(data[2], data[3], statics, bypass, steer, data[4]); + var hidden = 'display: none;'; + var sourceLabel = { + dhcp: _('DHCP'), static: _('Static'), neighbour: _('Neighbour'), offline: _('Offline') + }; this.bypassChecks = {}; this.steerChecks = {}; + // Steer cells and the DHCP-only empty state follow the ARP toggle. + // Neighbour rows also stay visible when their current Bypass tick is set. + this.arpOnly = []; + this.dhcpOnly = []; + this.neighbourRows = []; + this.initial = { arpEnabled: arpEnabled, steer: steer.slice().sort().join(' ') }; - var rows = [E('tr', { 'class': 'tr table-titles' }, [ - E('th', { 'class': 'th' }, _('Bypass')), - E('th', { 'class': 'th' }, _('Steer')), - E('th', { 'class': 'th' }, _('Hostname')), - E('th', { 'class': 'th' }, _('IP address')), - E('th', { 'class': 'th' }, _('MAC address')) - ])]; + function arpOnly(node) { self.arpOnly.push(node); return node; } + function steerCell(tag, content) { + return arpOnly(E(tag, { 'class': tag === 'th' ? 'th' : 'td', 'style': arpEnabled ? '' : hidden }, content)); + } + // Without ARP steering only DHCP clients (leases and static leases) are + // listed; neighbour-only hosts stay visible when already bypassed. + function dhcpVisible(c) { + return c.source !== 'neighbour' || bypass.indexOf(c.mac) !== -1; + } - var hints = data[2] || {}, leases = (data[3] || {}).dhcp_leases || []; - var seen = {}; - Object.keys(hints).forEach(function(mac) { - var h = hints[mac] || {}; - var ip = (h.ipaddrs || h.ipv4 || [])[0]; - if (!ip || /^(00:){5}00$/i.test(mac)) return; - seen[mac.toLowerCase()] = { mac: mac.toLowerCase(), name: h.name || '', ip: ip }; - }); - leases.forEach(function(l) { - if (!l.macaddr) return; - var mac = l.macaddr.toLowerCase(); - seen[mac] = seen[mac] || { mac: mac, name: '', ip: '' }; - if (!seen[mac].name && l.hostname && l.hostname !== '*') seen[mac].name = l.hostname; - if (l.ipaddr) seen[mac].ip = l.ipaddr; - }); - bypass.concat(steer).forEach(function(mac) { - seen[mac] = seen[mac] || { mac: mac, name: '', ip: '' }; - }); + var rows = [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th', 'style': 'width: 4em;' }, _('Bypass')), + steerCell('th', _('Steer')), + E('th', { 'class': 'th' }, _('Hostname')), + E('th', { 'class': 'th' }, _('IP address')), + E('th', { 'class': 'th' }, _('MAC address')), + E('th', { 'class': 'th' }, _('Source')) + ]) + ]; - Object.keys(seen).sort().forEach(function(mac) { - var c = seen[mac]; + if (!clients.length) { + rows.push(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'colspan': '6' }, + E('em', {}, _('No LAN clients known yet. DHCP leases and neighbours appear here once clients are active.'))) + ])); + } else if (!clients.some(dhcpVisible)) { + rows.push(this.dhcpOnly[this.dhcpOnly.push(E('tr', { 'class': 'tr', 'style': arpEnabled ? hidden : '' }, [ + E('td', { 'class': 'td', 'colspan': '6' }, + E('em', {}, _('No DHCP clients. Enable ARP client steering below to list other LAN neighbours.'))) + ])) - 1]); + } + + clients.forEach(function(c) { + // Bypass and Steer are exclusive: bypassing a steered client would + // only route it through this router unproxied. var bp = E('input', { - type: 'checkbox', - checked: bypass.indexOf(mac) !== -1 ? '' : null, - change: function() { if (bp.checked) st.checked = false; } + 'type': 'checkbox', + 'checked': bypass.indexOf(c.mac) !== -1 ? '' : null, + 'change': function() { if (bp.checked) st.checked = false; } }); var st = E('input', { - type: 'checkbox', - checked: steer.indexOf(mac) !== -1 ? '' : null, - change: function() { if (st.checked) bp.checked = false; } + 'type': 'checkbox', + 'checked': steer.indexOf(c.mac) !== -1 ? '' : null, + 'change': function() { if (st.checked) bp.checked = false; } }); - self.bypassChecks[mac] = bp; - self.steerChecks[mac] = st; - rows.push(E('tr', { 'class': 'tr' }, [ + self.bypassChecks[c.mac] = bp; + self.steerChecks[c.mac] = st; + + var ips = [ E('span', {}, c.ip4.length ? c.ip4.join(', ') : '-') ]; + if (c.ip6.length) + ips.push(E('br'), E('small', { 'style': 'color: #888;' }, c.ip6.join(', '))); + + var tr = E('tr', { 'class': 'tr', 'style': arpEnabled || dhcpVisible(c) ? '' : hidden }, [ E('td', { 'class': 'td' }, bp), - E('td', { 'class': 'td', 'style': arpEnabled ? '' : 'display:none;' }, st), + steerCell('td', st), E('td', { 'class': 'td' }, c.name || '-'), - E('td', { 'class': 'td' }, c.ip || '-'), - E('td', { 'class': 'td' }, c.mac) - ])); + E('td', { 'class': 'td' }, ips), + E('td', { 'class': 'td' }, c.mac), + E('td', { 'class': 'td' }, sourceLabel[c.source]) + ]); + rows.push(tr); + if (c.source === 'neighbour') + self.neighbourRows.push({ node: tr, bypass: bp }); }); + var intro = E('p', {}, [ + _('Tick a client to bypass the transparent proxy: its traffic (including DNS) ' + + 'goes out directly. Clients are matched by MAC address, so a new DHCP ' + + 'lease keeps the setting.'), + tproxyEnabled ? '' : E('strong', {}, [ ' ', + _('Transparent proxy is disabled (Settings), so no client is proxied right now.') ]) + ]); + this.arpToggle = E('input', { - type: 'checkbox', checked: arpEnabled ? '' : null, - change: function(ev) { - var on = ev.target.checked; - Object.keys(self.steerChecks).forEach(function(mac) { - self.steerChecks[mac].parentNode.style.display = on ? '' : 'none'; - }); - } + 'type': 'checkbox', + 'id': 'arp-enabled', + 'checked': arpEnabled ? '' : null, + 'change': function(ev) { self.showSteer(ev.target.checked); } }); + var arp = E('details', { 'class': 'cbi-section', 'open': arpEnabled ? '' : null }, [ + E('summary', {}, E('strong', {}, _('ARP client steering (advanced)'))), + E('div', { 'class': 'alert-message warning', 'style': 'margin: 1em 0;' }, [ + _('For the clients you tick in the Steer column, this router announces itself ' + + 'as their gateway (ARP spoofing) so meow transparently proxies them without ' + + 'touching the client or the main router. Only use it for devices you ' + + 'administer on a network you control. Untick a client to release it. ' + + 'Transparent proxy must be enabled (Settings) for steered traffic to be handled.') + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title', 'for': 'arp-enabled' }, _('Enable steering')), + E('div', { 'class': 'cbi-value-field' }, [ + this.arpToggle, + E('span', { 'style': 'margin-left: .5em; color: #888;' }, + _('Master switch. Shows the Steer column and non-DHCP LAN neighbours; when off, no client is steered.')) + ]) + ]) + ]); + return E('div', { 'class': 'cbi-map' }, [ E('h2', {}, _('meow Clients')), - E('p', {}, _('Tick a client to bypass the transparent proxy, or use Steer for ARP-based client steering.')), + intro, E('div', { 'class': 'cbi-section' }, [ E('table', { 'class': 'table cbi-section-table' }, rows) ]), - E('div', { 'class': 'cbi-section' }, [ - E('label', {}, [this.arpToggle, ' ', _('Enable ARP client steering')]) - ]) + // Saving uses LuCI's standard page footer (Save & Apply). + E('div', { 'style': 'margin: .5em 0;' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { location.reload(); } + }, _('Reload list')) + ]), + arp ]); }, + showSteer: function(on) { + this.arpOnly.forEach(function(n) { n.style.display = on ? '' : 'none'; }); + this.neighbourRows.forEach(function(row) { + row.node.style.display = on || row.bypass.checked ? '' : 'none'; + }); + var hasBypass = this.neighbourRows.some(function(row) { return row.bypass.checked; }); + this.dhcpOnly.forEach(function(n) { n.style.display = on || hasBypass ? 'none' : ''; }); + }, + handleSaveApply: function() { + var self = this; + var arpEnabled = this.arpToggle.checked; function ticked(checks) { return Object.keys(checks).filter(function(m) { return checks[m].checked; }); } var bypass = ticked(this.bypassChecks); + // Hidden Steer ticks are kept, so disabling steering does not forget them. var steer = ticked(this.steerChecks).filter(function(m) { return bypass.indexOf(m) === -1; }); - uci.set('meow', 'tproxy', 'bypass_mac', bypass); - uci.set('meow', 'arp', 'enabled', this.arpToggle.checked ? '1' : '0'); - uci.set('meow', 'arp', 'client', steer); - return uci.save().then(function() { return uci.apply(); }).then(function() { - return fs.exec('/etc/init.d/meow-arp', ['restart']).catch(function() {}); - }).then(function() { - ui.addTimeLimitedNotification(null, E('p', _('Client settings applied.')), 3000, 'info'); - }); - } + var arpChanged = arpEnabled !== this.initial.arpEnabled || + steer.slice().sort().join(' ') !== this.initial.steer; + + if (bypass.length) + uci.set('meow', 'tproxy', 'bypass_mac', bypass); + else + uci.unset('meow', 'tproxy', 'bypass_mac'); + + uci.set('meow', 'arp', 'enabled', arpEnabled ? '1' : '0'); + if (steer.length) + uci.set('meow', 'arp', 'client', steer); + else + uci.unset('meow', 'arp', 'client'); + + return uci.save() + .then(function() { return uci.apply(); }) + .then(function() { + if (arpChanged) + return fs.exec('/etc/init.d/meow-arp', [ 'restart' ]).catch(function() {}); + }) + .then(function() { + ui.addTimeLimitedNotification(null, E('p', arpEnabled + ? _('Bypassing %d client(s); steering %d.').format(bypass.length, steer.length) + : _('Bypassing %d client(s); steering disabled.').format(bypass.length)), 4000, 'info'); + }) + .catch(function(e) { + ui.addNotification(null, E('p', _('Failed to apply: %s').format(e.message))); + }); + }, + + handleSave: null, + handleReset: null }); diff --git a/luci-app-meow/htdocs/luci-static/resources/view/meow/config.js b/luci-app-meow/htdocs/luci-static/resources/view/meow/config.js index 0919b02b4..aaf55157f 100644 --- a/luci-app-meow/htdocs/luci-static/resources/view/meow/config.js +++ b/luci-app-meow/htdocs/luci-static/resources/view/meow/config.js @@ -7,15 +7,25 @@ 'require tools.meow_settings as settings'; 'require tools.meow as meow'; +// Raw editor for the meow YAML configuration. Edits are validated with +// `meow -t` against a scratch copy before they replace the real file. A running +// service is explicitly restarted after saving. Runtime changes through the +// panel are written back to this file by its "Save Config" button. + +// YAML subscriptions can exceed ubus request limits. Use the same authenticated +// multipart upload as LuCI's file picker; cgi-io enforces the file ACLs. function writeConfig(path, content) { var data = new FormData(); data.append('sessionid', L.env.sessionid); data.append('filename', path); data.append('filedata', new Blob([ content ], { type: 'text/plain' }), 'config.yaml'); + return request.post(L.env.cgi_base + '/cgi-upload', data, { timeout: 0 }).then(function(res) { - if (!res.ok) throw new Error(res.statusText || _('Upload request failed')); + if (!res.ok) + throw new Error(res.statusText || _('Upload request failed')); var reply = res.json(); - if (!reply || reply.failure) throw new Error((reply && reply.message) || _('Upload request failed')); + if (!reply || reply.failure) + throw new Error((reply && reply.message) || _('Upload request failed')); }); } @@ -23,21 +33,30 @@ return view.extend({ load: function() { return uci.load('meow').then(function() { var path = uci.get('meow', 'main', 'config_file') || '/etc/meow/config.yaml'; - return fs.read_direct(path).then(function(content) { return { path: path, content: content }; }); + return fs.read_direct(path).then(function(content) { + return { path: path, content: content }; + }); }); }, validate: function(content) { + // Isolate simultaneous saves/validations, including other browser tabs. + // Reuse the token path supported by the validator and upload ACL. var token = Array.from(crypto.getRandomValues(new Uint32Array(4)), function(n) { return n.toString(16).padStart(8, '0'); }).join(''); var scratch = '/tmp/meow-luci-settings-' + token + '.yaml'; + return writeConfig(scratch, content).then(function() { return fs.exec('/usr/libexec/meow-validate', [token]); }).then(function(res) { - if (res.code === 0) return null; - return ((res.stdout || '') + (res.stderr || '')).replace(/\x1b\[[0-9;]*m/g, '').trim() || - _('Configuration test failed'); + if (res.code === 0) + return null; + // meow logs to stdout with ANSI colors; keep the error lines. + var out = ((res.stdout || '') + (res.stderr || '')) + .replace(/\x1b\[[0-9;]*m/g, '').trim().split('\n'); + var errors = out.filter(function(l) { return /ERROR|Error/.test(l); }); + return (errors.length ? errors : out).join('\n') || _('Configuration test failed'); }).finally(function() { return fs.remove(scratch).catch(function() {}); }); @@ -45,9 +64,14 @@ return view.extend({ handleValidate: function() { var content = document.getElementById('meow-yaml').value; - return this.validate(content).then(function(err) { - if (err) ui.addNotification(_('Invalid configuration'), E('pre', {}, err), 'error'); - else ui.addTimeLimitedNotification(null, E('p', _('Configuration test passed')), 3000, 'info'); + return Promise.resolve().then(function() { + content = settings.prepare(content); + return this.validate(content); + }.bind(this)).then(function(err) { + if (err) + ui.addNotification(_('Invalid configuration'), E('pre', {}, err), 'error'); + else + ui.addTimeLimitedNotification(null, E('p', _('Configuration test passed')), 3000, 'info'); }).catch(function(e) { ui.addNotification(null, E('p', _('Unable to validate: %s').format(e.message)), 'error'); }); @@ -57,28 +81,50 @@ return view.extend({ if (this.saving) return this.saving; var self = this; var textarea = document.getElementById('meow-yaml'); - var content = textarea.value.replace(/\r\n/g, '\n'); - if (!/\n$/.test(content)) content += '\n'; - return this.saving = this.validate(content).then(function(err) { + var original = textarea.value; + var content = original.replace(/\r\n/g, '\n'); + if (!/\n$/.test(content)) + content += '\n'; + + return this.saving = Promise.resolve().then(function() { + content = settings.prepare(content); + return this.validate(content); + }.bind(this)).then(function(err) { if (err) { ui.addNotification(_('Invalid configuration, not saved'), E('pre', {}, err), 'error'); return; } return writeConfig(path, content).then(function() { + // Keep edits entered while validation/upload was in progress. + if (textarea.value === original) textarea.value = content; self.saved = content; self.updateStatus(); return meow.serviceRunning().then(function(running) { - if (!running) return; - return fs.exec('/etc/init.d/meow', ['restart']); + if (!running) return false; + return fs.exec('/etc/init.d/meow', ['restart']).then(function(res) { + if (res.code !== 0) throw new Error(res.stderr || _('Service restart failed')); + return meow.serviceRunning().then(function(active) { + if (!active) throw new Error(_('Service did not start')); + return true; + }); + }); + }).then(function(restarted) { + ui.addTimeLimitedNotification(null, E('p', restarted + ? _('Configuration saved and service restarted.') + : _('Configuration saved; service remains stopped.')), 5000, 'info'); + }).catch(function(error) { + ui.addNotification(null, E('p', _('Configuration saved, but restart failed: %s').format(error.message)), 'error'); }); - }).then(function() { - ui.addTimeLimitedNotification(null, E('p', _('Configuration saved.')), 3000, 'info'); }); }).catch(function(e) { - ui.addNotification(null, E('p', _('Unable to save: %s').format(e.message)), 'error'); - }).finally(function() { self.saving = null; }); + ui.addNotification(null, E('p', _('Unable to save: %s').format(e.message))); + }).finally(function() { + self.saving = null; + }); }, + // Editor status line: size, line count and whether the text differs from + // the file on disk. `saved` tracks the last content written or loaded. updateStatus: function(text) { if (text == null) { var ta = document.getElementById('meow-yaml'); @@ -87,51 +133,93 @@ return view.extend({ } if (!this.status) return; this.dirty = text !== this.saved; - this.status.textContent = _('%d lines · %s').format(text ? text.split('\n').length : 0, '%1024.1mB'.format(text.length)); + var lines = text ? text.split('\n').length : 0; + this.status.textContent = _('%d lines · %s').format(lines, '%1024.1mB'.format(text.length)) + + ' · ' + (this.dirty ? _('Unsaved changes') : _('Saved')); this.status.style.color = this.dirty ? '#ef6c00' : '#888'; if (this.revert) this.revert.disabled = !this.dirty; }, + handleRevert: function() { + var ta = document.getElementById('meow-yaml'); + ta.value = this.saved; + this.updateStatus(); + }, + + // Tab indents with two spaces (YAML forbids tabs); Ctrl/Cmd+S saves. + handleKey: function(path, ev) { + var ta = ev.target; + if (ev.key === 'Tab' && !ev.ctrlKey && !ev.metaKey && !ev.altKey) { + ev.preventDefault(); + var start = ta.selectionStart, end = ta.selectionEnd; + ta.value = ta.value.slice(0, start) + ' ' + ta.value.slice(end); + ta.selectionStart = ta.selectionEnd = start + 2; + this.updateStatus(); + } else if (ev.key === 's' && (ev.ctrlKey || ev.metaKey)) { + ev.preventDefault(); + this.handleSave(ev, path); + } + }, + render: function(data) { var self = this; this.saved = data.content; - this.status = E('span', { style: 'color:#888;' }); + this.status = E('span', { 'style': 'color: #888;' }); + + // Leaving with unsaved edits asks first. + if (typeof window !== 'undefined' && window.addEventListener) + window.addEventListener('beforeunload', function(ev) { + if (self.dirty) { ev.preventDefault(); ev.returnValue = ''; } + }); + this.revert = E('button', { - type: 'button', class: 'cbi-button cbi-button-reset', disabled: true, - click: function() { document.getElementById('meow-yaml').value = self.saved; self.updateStatus(); } + 'type': 'button', + 'class': 'cbi-button cbi-button-reset', + 'disabled': true, + // Plain handler: createHandlerFn would re-enable the button afterwards. + 'click': function() { self.handleRevert(); } }, _('Revert')); - var node = E('div', { class: 'cbi-map' }, [ + + var node = E('div', { 'class': 'cbi-map' }, [ E('h2', {}, _('meow Configuration')), - E('div', { class: 'cbi-map-descr' }, - _('Raw YAML configuration at %s. Validate before saving; invalid configuration is never written.').format(data.path)), - E('div', { class: 'cbi-section' }, [ - E('textarea', { - id: 'meow-yaml', class: 'cbi-input-textarea', - style: 'width:100%; min-height:60vh; font-family:monospace; font-size:12px; tab-size:2;', - spellcheck: false, wrap: 'off', - input: function() { self.updateStatus(); }, - keydown: function(ev) { - if (ev.key === 'Tab' && !ev.ctrlKey && !ev.metaKey && !ev.altKey) { - ev.preventDefault(); - var ta = ev.target, start = ta.selectionStart; - ta.value = ta.value.slice(0, start) + ' ' + ta.value.slice(ta.selectionEnd); - ta.selectionStart = ta.selectionEnd = start + 2; - self.updateStatus(); - } else if (ev.key === 's' && (ev.ctrlKey || ev.metaKey)) { - ev.preventDefault(); self.handleSave(ev, data.path); - } - } - }, [data.content]), - E('div', { style: 'margin-top:.3em; font-size:12px;' }, [this.status]) + E('div', { 'class': 'cbi-map-descr' }, [ + _('Raw YAML configuration at %s (mihomo / Clash Meta format). ' + + 'Settings from the Settings tab are applied and the result is validated before saving; ' + + 'an invalid configuration is never written. Tab indents, Ctrl/Cmd+S saves.').format(data.path) ]), - E('div', { class: 'cbi-page-actions' }, [ - this.revert, ' ', - E('button', { type: 'button', class: 'cbi-button cbi-button-neutral', click: ui.createHandlerFn(this, 'handleValidate') }, _('Validate')), + E('div', { 'class': 'cbi-section' }, [ + E('textarea', { + 'id': 'meow-yaml', + 'class': 'cbi-input-textarea', + 'style': 'width: 100%; min-height: 60vh; font-family: monospace; font-size: 12px; tab-size: 2;', + 'spellcheck': 'false', + 'wrap': 'off', + 'input': function() { self.updateStatus(); }, + 'keydown': function(ev) { self.handleKey(data.path, ev); } + }, [ data.content ]), + E('div', { 'style': 'margin-top: .3em; font-size: 12px;' }, [ this.status ]) + ]), + E('div', { 'class': 'cbi-page-actions' }, [ + this.revert, ' ', - E('button', { type: 'button', class: 'cbi-button cbi-button-save', click: ui.createHandlerFn(this, 'handleSave', null, data.path) }, _('Save')) + E('button', { + 'type': 'button', + 'class': 'cbi-button cbi-button-neutral', + 'click': ui.createHandlerFn(this, 'handleValidate') + }, _('Validate')), + ' ', + E('button', { + 'type': 'button', + 'class': 'cbi-button cbi-button-save', + 'click': ui.createHandlerFn(this, 'handleSave', null, data.path) + }, _('Save')) ]) ]); this.updateStatus(data.content); return node; - } + }, + + handleSaveApply: null, + handleReset: null, + addFooter: function() { return E('div'); } }); diff --git a/luci-app-meow/htdocs/luci-static/resources/view/meow/log.js b/luci-app-meow/htdocs/luci-static/resources/view/meow/log.js index caa052af5..8583915d1 100644 --- a/luci-app-meow/htdocs/luci-static/resources/view/meow/log.js +++ b/luci-app-meow/htdocs/luci-static/resources/view/meow/log.js @@ -5,8 +5,16 @@ 'require poll'; 'require ui'; +// meow runs under procd with stdout/stderr captured into the system log. + var MAX_LINES = 1000; +var LEVELS = { + all: function() { return true; }, + warn: function(l) { return level(l) !== ''; }, + error: function(l) { return level(l) === 'error'; } +}; + function level(line) { if (/\bERROR\b|\.err\b|\.crit\b|panicked/.test(line)) return 'error'; if (/\bWARN\b|\.warn\b/.test(line)) return 'warn'; @@ -14,7 +22,7 @@ function level(line) { } function fetchLog() { - return fs.exec_direct('/sbin/logread', ['-e', 'meow']).then(function(out) { + return fs.exec_direct('/sbin/logread', [ '-e', 'meow' ]).then(function(out) { var lines = (out || '').replace(/\x1b\[[0-9;]*m/g, '').trim().split('\n'); return lines.filter(Boolean).slice(-MAX_LINES); }).catch(function(e) { @@ -24,63 +32,104 @@ function fetchLog() { return view.extend({ load: fetchLog, + + // Oldest first, errors and warnings highlighted. Follows the tail unless + // the reader has scrolled up. show: function() { - var pre = this.pre; - var filter = (this.filter.value || '').toLowerCase(); - var levelValue = this.levelSel.value; + var pre = this.pre, filter = (this.filter.value || '').toLowerCase(); + var match = LEVELS[this.levelSel.value] || LEVELS.all; var lines = this.lines.filter(function(l) { - if (levelValue === 'warn' && !level(l)) return false; - if (levelValue === 'error' && level(l) !== 'error') return false; - return !filter || l.toLowerCase().indexOf(filter) !== -1; + return match(l) && (!filter || l.toLowerCase().indexOf(filter) !== -1); }); var atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40; + dom.content(pre, lines.length ? lines.map(function(l) { var lv = level(l); - return E('div', { style: lv === 'error' ? 'color:#c62828;' : lv === 'warn' ? 'color:#ef6c00;' : '' }, l); - }) : E('em', {}, _('No log entries.'))); + return E('div', { 'style': lv === 'error' ? 'color: #c62828;' : lv === 'warn' ? 'color: #ef6c00;' : '' }, l); + }) : E('em', {}, this.lines.length ? _('No entries match the filter.') : _('No log entries.'))); dom.content(this.count, _('%d of %d entries').format(lines.length, this.lines.length)); + + // The first render happens before the node is attached (no height yet); + // jump to the newest entries once it is on the page. if (atBottom || !this.scrolled) { pre.scrollTop = pre.scrollHeight; if (pre.isConnected && pre.scrollHeight > 0) this.scrolled = true; } }, + refresh: function() { if (this.paused) return Promise.resolve(); - return fetchLog().then(L.bind(function(lines) { this.lines = lines; this.show(); }, this)); + return fetchLog().then(L.bind(function(lines) { + this.lines = lines; + this.show(); + }, this)); }, + handlePause: function() { this.paused = !this.paused; this.pauseBtn.textContent = this.paused ? _('Resume') : _('Pause'); + this.pauseBtn.classList.toggle('cbi-button-positive', this.paused); if (!this.paused) return this.refresh(); }, + handleDownload: function() { - var blob = new Blob([this.lines.join('\n') + '\n'], { type: 'text/plain' }); - var a = E('a', { href: URL.createObjectURL(blob), download: 'meow.log' }); - document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(a.href); + var blob = new Blob([ this.lines.join('\n') + '\n' ], { type: 'text/plain' }); + var a = E('a', { 'href': URL.createObjectURL(blob), 'download': 'meow.log' }); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(a.href); }, + render: function(lines) { var self = this; this.lines = lines || []; - this.pre = E('pre', { style: 'white-space:pre-wrap; font-size:12px; max-height:70vh; overflow:auto; margin:0;' }); - this.count = E('span', { style: 'color:#888; margin-left:auto;' }); - this.filter = E('input', { type:'text', class:'cbi-input-text', placeholder:_('Filter…'), style:'max-width:16em;', input:function(){self.show();} }); - this.levelSel = E('select', { class:'cbi-input-select', change:function(){self.show();} }, [ - E('option', { value:'all' }, _('All levels')), - E('option', { value:'warn' }, _('Warnings and errors')), - E('option', { value:'error' }, _('Errors only')) + + this.pre = E('pre', { + 'style': 'white-space: pre-wrap; font-size: 12px; max-height: 70vh; overflow: auto; margin: 0;' + }); + this.count = E('span', { 'style': 'color: #888; margin-left: auto;' }); + this.filter = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'placeholder': _('Filter…'), + 'style': 'max-width: 16em;', + 'input': function() { self.show(); } + }); + this.levelSel = E('select', { 'class': 'cbi-input-select', 'change': function() { self.show(); } }, [ + E('option', { 'value': 'all' }, _('All levels')), + E('option', { 'value': 'warn' }, _('Warnings and errors')), + E('option', { 'value': 'error' }, _('Errors only')) ]); - this.pauseBtn = E('button', { class:'cbi-button', click:ui.createHandlerFn(this, 'handlePause') }, _('Pause')); + this.pauseBtn = E('button', { + 'class': 'cbi-button', + 'click': ui.createHandlerFn(this, 'handlePause') + }, _('Pause')); + poll.add(function() { return self.refresh(); }, 5); - var node = E('div', { class:'cbi-map' }, [ + if (typeof requestAnimationFrame === 'function') + requestAnimationFrame(function() { self.show(); }); + + var node = E('div', { 'class': 'cbi-map' }, [ E('h2', {}, _('meow Log')), - E('div', { class:'cbi-map-descr' }, _('meow entries from the system log; refreshes every 5 seconds.')), - E('div', { style:'display:flex; flex-wrap:wrap; gap:.5em; align-items:center; margin-bottom:.5em;' }, - [this.filter, this.levelSel, this.pauseBtn, - E('button', { class:'cbi-button cbi-button-action', click:ui.createHandlerFn(this,'handleDownload') }, _('Download')), - this.count]), - E('div', { class:'cbi-section' }, [this.pre]) + E('div', { 'class': 'cbi-map-descr' }, + _('meow entries from the system log, oldest first; refreshes every 5 seconds. ' + + 'Errors are shown in red and warnings in orange.')), + E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: .5em; align-items: center; margin-bottom: .5em;' }, [ + this.filter, this.levelSel, this.pauseBtn, + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': ui.createHandlerFn(this, 'handleDownload') + }, _('Download')), + this.count + ]), + E('div', { 'class': 'cbi-section' }, [ this.pre ]) ]); this.show(); return node; - } + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null }); diff --git a/luci-app-meow/htdocs/luci-static/resources/view/meow/overview.js b/luci-app-meow/htdocs/luci-static/resources/view/meow/overview.js index c5ad318f5..2fc593cf2 100644 --- a/luci-app-meow/htdocs/luci-static/resources/view/meow/overview.js +++ b/luci-app-meow/htdocs/luci-static/resources/view/meow/overview.js @@ -7,36 +7,73 @@ 'require uci'; 'require tools.meow as meow'; -var MODES = ['rule', 'global', 'direct']; +// Status page: service state from procd, everything else from the meow REST +// API (/version, /configs, /connections). Proxy selection, rules, groups and +// subscriptions live in the built-in panel (Panel tab). + +var MODES = [ 'rule', 'global', 'direct' ]; + var MODE_HELP = { - rule: _('Traffic follows your rules.'), + rule: _('Traffic follows your rules (recommended).'), global: _('All traffic goes through the proxy selected in the GLOBAL group.'), direct: _('All traffic bypasses the proxies.') }; -function badge(text) { - return E('span', { class:'label', style:'color:#fff; padding:2px 8px; border-radius:3px; background:#2e7d32;' }, text); +var COLORS = { ok: '#2e7d32', warn: '#ef6c00', bad: '#c62828' }; + +function badge(kind, text) { + return E('span', { + 'class': 'label', + 'style': 'color: #fff; padding: 2px 8px; border-radius: 3px; background: ' + COLORS[kind] + }, text); } + function row(label, value) { - return E('tr', { class:'tr' }, [ - E('td', { class:'td left', width:'33%' }, label), - E('td', { class:'td left' }, value) + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left', 'width': '33%' }, label), + E('td', { 'class': 'td left' }, value) ]); } + function tabLink(tab, text) { - return E('a', { href:L.url('admin/services/meow', tab) }, text); + return E('a', { 'href': L.url('admin/services/meow', tab) }, text); } return view.extend({ - load: function() { return uci.load('meow'); }, - gatewayActive: function() { - return fs.exec('/usr/share/meow/gateway.sh', ['status']).then(function(res) { - return res.code === 0; - }).catch(function() { return false; }); + load: function() { + return uci.load('meow'); }, + + gatewayActive: function() { + return fs.exec('/usr/share/meow/gateway.sh', [ 'status' ]) + .then(function(res) { return res.code === 0; }) + .catch(function() { return false; }); + }, + + // First-run and failure guidance above the status table. + renderBanner: function(enabled, running) { + if (running) + return ''; + if (!enabled) + return E('div', { 'class': 'alert-message notice' }, [ + E('strong', {}, _('meow is not enabled.')), ' ', + _('Add your proxies in the Configuration tab (or import a subscription in the Panel), then enable the service. '), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': ui.createHandlerFn(this, 'handleEnable') + }, _('Enable and start')) + ]); + return E('div', { 'class': 'alert-message warning' }, [ + E('strong', {}, _('meow is stopped.')), ' ', + _('If you did not stop it, the configuration probably failed to load; the reason is in the '), + tabLink('log', _('Log')), '.' + ]); + }, + refresh: function(nodes) { var self = this; var enabled = uci.get('meow', 'main', 'enabled') === '1'; + return Promise.all([ meow.serviceRunning(), L.resolveDefault(meow.api('GET', '/version'), null), @@ -44,14 +81,22 @@ return view.extend({ L.resolveDefault(meow.api('GET', '/connections'), null), this.gatewayActive() ]).then(function(r) { - var running=r[0], version=r[1], cfg=r[2], conns=r[3], gateway=r[4], now=Date.now(); - dom.content(nodes.banner, running ? '' : E('div', {class:'alert-message notice'}, [ - E('strong', {}, enabled ? _('meow is stopped.') : _('meow is disabled.')), - ' ', enabled ? _('Check the Log tab if it did not stop intentionally.') : _('Enable the service to start it.') - ])); - dom.content(nodes.service, running ? [badge(_('Running')), ' ', version ? 'v'+String(version.version).replace(/^v/,'') : ''] : _('Not running')); - var tproxy = uci.get('meow','tproxy','enabled') === '1'; - dom.content(nodes.gateway, !tproxy ? E('em',{},_('Off')) : (gateway ? badge(_('Active')) : _('Waiting'))); + var running = r[0], version = r[1], cfg = r[2], conns = r[3], + gateway = r[4], now = Date.now(); + + dom.content(nodes.banner, self.renderBanner(enabled, running)); + dom.content(nodes.service, running + ? [ badge('ok', _('Running')), ' ', version ? 'v' + String(version.version).replace(/^v/, '') : '' ] + : badge(enabled ? 'bad' : 'warn', enabled ? _('Not running') : _('Disabled'))); + + var tproxy = uci.get('meow', 'tproxy', 'enabled') === '1'; + dom.content(nodes.gateway, tproxy + ? (gateway + ? badge('ok', _('Active')) + : [ badge('warn', _('Waiting')), ' ', + E('small', {}, _('Rules load once meow is listening.')) ]) + : [ E('em', {}, _('Off')), ' — ', tabLink('settings', _('enable in Settings')) ]); + var mode = cfg && cfg.mode; nodes.modeButtons.forEach(function(btn) { var active = mode === btn.getAttribute('data-mode'); @@ -59,68 +104,152 @@ return view.extend({ btn.disabled = !cfg; }); dom.content(nodes.modeHelp, MODE_HELP[mode] || (running ? '' : _('Available while meow is running.'))); + nodes.actions.start.style.display = running ? 'none' : ''; nodes.actions.stop.style.display = running ? '' : 'none'; nodes.actions.restart.style.display = running ? '' : 'none'; + if (conns) { - var up=conns.uploadTotal||0, down=conns.downloadTotal||0; + var up = conns.uploadTotal || 0, down = conns.downloadTotal || 0; if (self.last) { - var dt=(now-self.last.t)/1000; - dom.content(nodes.rate,'↑ %s/s ↓ %s/s'.format(meow.formatBytes(Math.max(0,up-self.last.up)/dt),meow.formatBytes(Math.max(0,down-self.last.down)/dt))); + var dt = (now - self.last.t) / 1000; + dom.content(nodes.rate, '↑ %s/s ↓ %s/s'.format( + meow.formatBytes(Math.max(0, up - self.last.up) / dt), + meow.formatBytes(Math.max(0, down - self.last.down) / dt))); } - self.last={t:now,up:up,down:down}; - dom.content(nodes.totals,'↑ %s ↓ %s'.format(meow.formatBytes(up),meow.formatBytes(down))); - dom.content(nodes.conns,String((conns.connections||[]).length)); + self.last = { t: now, up: up, down: down }; + dom.content(nodes.totals, '↑ %s ↓ %s'.format(meow.formatBytes(up), meow.formatBytes(down))); + dom.content(nodes.conns, String((conns.connections || []).length)); } else { - self.last=null; dom.content(nodes.rate,'-'); dom.content(nodes.totals,'-'); dom.content(nodes.conns,'-'); + self.last = null; + dom.content(nodes.rate, '-'); + dom.content(nodes.totals, '-'); + dom.content(nodes.conns, '-'); } }); }, + handleMode: function(mode) { - return meow.api('PATCH','/configs',{mode:mode}).then(L.bind(function(){return this.refresh(this.nodes);},this)) - .catch(function(e){ui.addNotification(null,E('p',_('Failed to switch mode: %s').format(e.message)),'error');}); + return meow.api('PATCH', '/configs', { mode: mode }).then(L.bind(function() { + return this.refresh(this.nodes); + }, this)).catch(function(e) { + ui.addNotification(null, E('p', _('Failed to switch mode: %s').format(e.message)), 'error'); + }); }, + + // Runs an init action, then waits briefly for procd to settle and reports + // the resulting state instead of assuming success. service: function(action) { - var self=this, want=action!=='stop'; - return fs.exec('/etc/init.d/meow',[action]).then(function(res){ - if(res.code!==0) throw new Error(res.stderr||_('Command failed')); - return new Promise(function(resolve){setTimeout(resolve,1500);}); - }).then(function(){return meow.serviceRunning();}).then(function(running){ - if(running!==want) throw new Error(want? _('meow did not start; see the Log tab.') : _('meow is still running.')); - }).catch(function(e){ui.addNotification(null,E('p',e.message),'error');}).then(function(){return self.refresh(self.nodes);}); + var self = this; + var want = action !== 'stop'; + return fs.exec('/etc/init.d/meow', [ action ]).then(function(res) { + if (res.code !== 0) + throw new Error(res.stderr || _('Command failed')); + return new Promise(function(resolve) { setTimeout(resolve, 1500); }); + }).then(function() { + return meow.serviceRunning(); + }).then(function(running) { + if (running !== want) + throw new Error(want ? _('meow did not start; see the Log tab.') : _('meow is still running.')); + ui.addTimeLimitedNotification(null, E('p', { + start: _('meow started.'), stop: _('meow stopped.'), restart: _('meow restarted.') + }[action]), 3000, 'info'); + }).catch(function(e) { + ui.addNotification(null, E('p', e.message), 'error'); + }).then(function() { + return self.refresh(self.nodes); + }); }, - handleStart:function(){return this.service('start');}, - handleStop:function(){return this.service('stop');}, - handleRestart:function(){return this.service('restart');}, - handleEnable:function(){ - var self=this; - uci.set('meow','main','enabled','1'); - return uci.save().then(function(){return uci.apply();}).then(function(){return self.service('start');}); + + handleStart: function() { return this.service('start'); }, + handleStop: function() { return this.service('stop'); }, + handleRestart: function() { return this.service('restart'); }, + + handleEnable: function() { + var self = this; + uci.set('meow', 'main', 'enabled', '1'); + return uci.save() + .then(function() { return uci.apply(); }) + .then(function() { return self.service('start'); }) + .catch(function(e) { + ui.addNotification(null, E('p', _('Failed to enable: %s').format(e.message)), 'error'); + }); }, - render:function(){ - var self=this; - function action(name,cls,text){return E('button',{class:'cbi-button '+cls,style:'display:none;',click:ui.createHandlerFn(self,name)},text);} - var nodes=this.nodes={ - banner:E('div'), service:E('span',{},_('Collecting data…')), gateway:E('span',{},'-'), - modeHelp:E('div',{style:'margin-top:.4em; color:#888;'}), rate:E('span',{},'-'), totals:E('span',{},'-'), conns:E('span',{},'-'), - modeButtons:MODES.map(function(mode){return E('button',{class:'cbi-button','data-mode':mode,disabled:true,click:ui.createHandlerFn(self,'handleMode',mode)},mode);}), - actions:{start:action('handleStart','cbi-button-positive',_('Start')),stop:action('handleStop','cbi-button-negative',_('Stop')),restart:action('handleRestart','cbi-button-apply',_('Restart'))} + + render: function() { + var self = this; + function action(name, cls, text) { + return E('button', { + 'class': 'cbi-button ' + cls, + 'style': 'display: none;', + 'click': ui.createHandlerFn(self, name) + }, text); + } + var nodes = this.nodes = { + banner: E('div'), + service: E('span', {}, _('Collecting data…')), + gateway: E('span', {}, '-'), + modeHelp: E('div', { 'style': 'margin-top: .4em; color: #888;' }), + rate: E('span', {}, '-'), + totals: E('span', {}, '-'), + conns: E('span', {}, '-'), + modeButtons: MODES.map(function(mode) { + return E('button', { + 'class': 'cbi-button', + 'data-mode': mode, + 'disabled': true, + 'click': ui.createHandlerFn(self, 'handleMode', mode) + }, mode.charAt(0).toUpperCase() + mode.slice(1)); + }), + actions: { + start: action('handleStart', 'cbi-button-positive', _('Start')), + stop: action('handleStop', 'cbi-button-negative', _('Stop')), + restart: action('handleRestart', 'cbi-button-apply', _('Restart')) + } }; - poll.add(function(){return self.refresh(nodes);},3); - return E('div',{class:'cbi-map'},[ - E('h2',{},_('meow')), - E('div',{class:'cbi-map-descr'},_('Rule-based proxy compatible with mihomo (Clash Meta) configurations.')), + + poll.add(function() { return self.refresh(nodes); }, 3); + + return E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, _('meow')), + E('div', { 'class': 'cbi-map-descr' }, + _('Rule-based proxy, compatible with mihomo (Clash Meta) configurations. ' + + 'Pick proxies, manage subscriptions and inspect connections in the Panel tab.')), nodes.banner, - E('div',{class:'cbi-section'},[E('table',{class:'table'},[ - row(_('Service'),nodes.service),row(_('Transparent proxy'),nodes.gateway), - row(_('Proxy mode'),E('div',{},[E('div',{class:'cbi-page-actions'},nodes.modeButtons),nodes.modeHelp])), - row(_('Current speed'),nodes.rate),row(_('Total traffic'),nodes.totals),row(_('Active connections'),nodes.conns) - ])]), - E('div',{class:'cbi-page-actions'},[ - meow.secret()?E('a',{class:'cbi-button cbi-button-action',href:meow.panelURL(),target:'_blank',rel:'noopener'},_('Open panel')): - E('a',{class:'cbi-button cbi-button-action',href:L.url('admin/services/meow/panel')},_('Open panel')), - ' ',nodes.actions.start,' ',nodes.actions.stop,' ',nodes.actions.restart + E('div', { 'class': 'cbi-section' }, [ + E('table', { 'class': 'table' }, [ + row(_('Service'), nodes.service), + row(_('Transparent proxy'), nodes.gateway), + row(_('Proxy mode'), E('div', {}, [ + E('div', { 'class': 'cbi-page-actions', 'style': 'text-align: left; padding: 0;' }, + nodes.modeButtons), + nodes.modeHelp + ])), + row(_('Current speed'), nodes.rate), + row(_('Total traffic'), nodes.totals), + row(_('Active connections'), nodes.conns) + ]) + ]), + E('div', { 'class': 'cbi-page-actions' }, [ + // Without a secret the API is loopback-only; the Panel tab explains + // that and offers to set one. + meow.secret() + ? E('a', { + 'class': 'cbi-button cbi-button-action', + 'href': meow.panelURL(), + 'target': '_blank', + 'rel': 'noopener' + }, _('Open panel')) + : E('a', { + 'class': 'cbi-button cbi-button-action', + 'href': L.url('admin/services/meow/panel') + }, _('Open panel')), + ' ', nodes.actions.start, ' ', nodes.actions.stop, ' ', nodes.actions.restart ]) ]); - } + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null }); diff --git a/luci-app-meow/htdocs/luci-static/resources/view/meow/panel.js b/luci-app-meow/htdocs/luci-static/resources/view/meow/panel.js index e9cacd74c..f0ef5e54f 100644 --- a/luci-app-meow/htdocs/luci-static/resources/view/meow/panel.js +++ b/luci-app-meow/htdocs/luci-static/resources/view/meow/panel.js @@ -4,38 +4,92 @@ 'require uci'; 'require tools.meow as meow'; +// Embeds meow's built-in web panel (served by the meow REST API at /ui) +// instead of reimplementing a dashboard in LuCI. The API secret, if any, is +// handed over in the URL fragment so the panel works without re-entering it. +// +// Without a secret the init script binds the API to 127.0.0.1 only, so the +// browser cannot reach it: explain that and offer to set a password instead +// of embedding a frame that can only fail. + function randomSecret() { var bytes = new Uint8Array(32); window.crypto.getRandomValues(bytes); - return Array.prototype.map.call(bytes, function(b) { return ('0'+b.toString(16)).slice(-2); }).join(''); + return Array.prototype.map.call(bytes, function(b) { + return ('0' + b.toString(16)).slice(-2); + }).join(''); } return view.extend({ - load:function(){return uci.load('meow');}, - handleEnableLan:function(){ - uci.set('meow','main','secret',randomSecret()); - return uci.save().then(function(){return uci.apply();}) - .then(function(){return new Promise(function(r){setTimeout(r,3000);});}) - .then(function(){location.reload();}) - .catch(function(e){ui.addNotification(null,E('p',_('Failed to set the panel password: %s').format(e.message)),'error');}); + load: function() { + return uci.load('meow'); }, - renderLocalOnly:function(){ - return E('div',{class:'alert-message notice'},[ - E('p',{},[E('strong',{},_('The panel is only reachable from the router itself.')),' ',_('Set an API secret to open it from this browser.')]), - E('button',{class:'cbi-button cbi-button-positive',click:ui.createHandlerFn(this,'handleEnableLan')},_('Generate a password and enable the panel')) + + // Setting the secret makes meow listen on the LAN (with authentication); + // applying the UCI change restarts the service through its reload trigger. + handleEnableLan: function() { + uci.set('meow', 'main', 'secret', randomSecret()); + return uci.save() + .then(function() { return uci.apply(); }) + .then(function() { return new Promise(function(r) { setTimeout(r, 3000); }); }) + .then(function() { location.reload(); }) + .catch(function(e) { + ui.addNotification(null, E('p', _('Failed to set the panel password: %s').format(e.message)), 'error'); + }); + }, + + renderLocalOnly: function() { + return E('div', { 'class': 'alert-message notice' }, [ + E('p', {}, [ + E('strong', {}, _('The panel is only reachable from the router itself.')), ' ', + _('No panel password (API secret) is set, so meow keeps its web panel off the LAN. ' + + 'Set a password to open the panel from this browser; LuCI passes it to the panel automatically.') + ]), + E('p', {}, [ + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': ui.createHandlerFn(this, 'handleEnableLan') + }, _('Generate a password and enable the panel')), + ' ', + E('a', { 'href': L.url('admin/services/meow/settings') }, _('or choose one in Settings')), + ' ', + E('small', {}, _('(meow restarts briefly)')) + ]) ]); }, - render:function(){ - var url=meow.panelURL(); + + render: function() { + var url = meow.panelURL(); + var plain = meow.apiBase() + '/ui'; var body; - if(!meow.secret()) body=this.renderLocalOnly(); - else if(window.location.protocol==='https:') - body=E('p',{},_('Open the panel in a new tab. The standalone panel uses HTTP.')); - else body=E('iframe',{src:url,style:'width:100%; min-height:75vh; border:none; border-radius:3px;'}); - return E('div',{class:'cbi-map'},[ - E('h2',{},_('meow Panel')), - E('div',{class:'cbi-map-descr'},meow.secret()?[_('Built-in web panel served by the meow REST API. '),E('a',{href:url,target:'_blank',rel:'noopener'},_('Open in a new tab'))]:_('Built-in web panel served by the meow REST API.')), + + if (!meow.secret()) + body = this.renderLocalOnly(); + else if (window.location.protocol === 'https:') + body = E('p', {}, _('Open the panel in a new tab. The standalone panel uses HTTP and cannot be embedded in an HTTPS page.')); + else + body = E('iframe', { + 'src': url, + 'style': 'width: 100%; min-height: 75vh; border: none;' + + ' border-radius: 3px; background: #0f1923;' + }); + + return E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, _('meow Panel')), + E('div', { 'class': 'cbi-map-descr' }, meow.secret() ? [ + _('Built-in web panel served by the meow REST API. '), + E('a', { 'href': url, 'target': '_blank', 'rel': 'noopener' }, + _('Open in a new tab')), + ' — ', plain, + E('br'), + E('small', {}, _('Blank frame? meow may be stopped (see Overview), or a firewall blocks port %s from your device.') + .format(uci.get('meow', 'main', 'panel_port') || '9090')) + ] : _('Built-in web panel served by the meow REST API.')), body ]); - } + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null }); diff --git a/luci-app-meow/htdocs/luci-static/resources/view/meow/settings.js b/luci-app-meow/htdocs/luci-static/resources/view/meow/settings.js index 5d2464e06..fe710d1ac 100644 --- a/luci-app-meow/htdocs/luci-static/resources/view/meow/settings.js +++ b/luci-app-meow/htdocs/luci-static/resources/view/meow/settings.js @@ -9,69 +9,149 @@ 'require tools.meow as meow'; function renderStatus(running) { - return running ? E('span',{style:'color:#2e7d32; font-weight:bold;'},_('RUNNING')) : - E('span',{style:'color:#c62828; font-weight:bold;'},_('NOT RUNNING')); + return running + ? E('span', { 'style': 'color: #2e7d32; font-weight: bold;' }, + _('RUNNING')) + : E('span', { 'style': 'color: #c62828; font-weight: bold;' }, + _('NOT RUNNING')); } return view.extend({ - load:function(){return Promise.all([uci.load('meow'),uci.load('network')]);}, - render:function(){ - var m=new form.Map('meow',_('meow'), - _('Service and transparent-proxy settings. Saving validates the YAML; use Configuration for raw YAML edits.')); - var save=m.save; - m.save=function(callback,silent){ + load: function() { + return Promise.all([ uci.load('meow'), uci.load('network') ]); + }, + + render: function() { + var m, s, o; + + m = new form.Map('meow', _('meow'), + _('Service and transparent-proxy settings. Saving also updates the YAML ' + + 'configuration to match; Save & Apply restarts meow. Proxies, rules and ' + + 'upstream DNS are edited in the Configuration tab.')); + + var save = m.save; + m.save = function(callback, silent) { var rollback; - return save.call(this,function(){ - return Promise.resolve().then(callback).then(function(){return settings.save();}).then(function(undo){rollback=undo;}); - },silent).catch(function(error){return Promise.resolve(rollback&&rollback()).then(function(){throw error;});}); + return save.call(this, function() { + return Promise.resolve().then(callback).then(function() { + return settings.save(); + }).then(function(undo) { rollback = undo; }); + }, silent).catch(function(error) { + return Promise.resolve(rollback && rollback()).then(function() { throw error; }); + }); }; - var s=m.section(form.NamedSection,'main','meow',_('Service')); - s.tab('basic',_('Basic')); s.tab('advanced',_('Advanced')); + s = m.section(form.NamedSection, 'main', 'meow', _('Service')); + s.tab('basic', _('Basic')); + s.tab('advanced', _('Advanced')); - var o=s.taboption('basic',form.DummyValue,'_status',_('Status')); - o.rawhtml=true; - o.cfgvalue=function(){ - var node=E('span',{},_('Collecting data…')); - poll.add(function(){return meow.serviceRunning().then(function(r){dom.content(node,renderStatus(r));});}); + o = s.taboption('basic', form.DummyValue, '_status', _('Status')); + o.rawhtml = true; + o.cfgvalue = function() { + var node = E('span', {}, _('Collecting data…')); + poll.add(function() { + return meow.serviceRunning().then(function(running) { + dom.content(node, renderStatus(running)); + }); + }); return node; }; - o=s.taboption('basic',form.Flag,'enabled',_('Enable meow'),_('Start meow now and at boot.')); - o.rmempty=false; - o=s.taboption('basic',form.Value,'panel_port',_('Web panel port'),_('Port of the built-in web panel and REST API.')); - o.datatype='port'; o.default='9090'; o.rmempty=false; - o=s.taboption('basic',form.Value,'secret',_('Panel password'),_('API secret; leave empty to keep the panel loopback-only.')); - o.password=true; + o = s.taboption('basic', form.Flag, 'enabled', _('Enable meow'), + _('Start meow now and at every boot. Save & Apply restarts it.')); + o.rmempty = false; - o=s.taboption('advanced',form.Value,'config_file',_('Configuration file')); - o.default='/etc/meow/config.yaml'; o.readonly=true; o.rmempty=false; - o=s.taboption('advanced',form.Value,'work_dir',_('Working directory')); - o.default='/etc/meow'; o.readonly=true; o.rmempty=false; + o = s.taboption('basic', form.Value, 'panel_port', _('Web panel port'), + _('Port of the built-in web panel and REST API (external-controller in the YAML).')); + o.datatype = 'port'; + o.default = '9090'; + o.rmempty = false; - s=m.section(form.NamedSection,'tproxy','transparent',_('Transparent proxy'), - _('Proxy LAN devices without configuring each one.')); - s.addremove=false; s.tab('basic',_('Basic')); s.tab('advanced',_('Advanced')); + o = s.taboption('basic', form.Value, 'secret', _('Panel password'), + _('The API secret. Leave empty to allow the panel only from this router; ' + + 'set it to open the panel from other LAN devices.')); + o.password = true; - o=s.taboption('basic',form.DummyValue,'_gateway',_('Status')); - o.rawhtml=true; - o.cfgvalue=function(){ - var node=E('span',{},_('Collecting data…')); - poll.add(function(){return fs.exec('/usr/share/meow/gateway.sh',['status']).catch(function(){return {code:1};}).then(function(res){ - dom.content(node,uci.get('meow','tproxy','enabled')!=='1'?E('em',{},_('Off')):res.code===0?renderStatus(true):E('span',{style:'color:#ef6c00;'},_('Waiting'))); - });}); + o = s.taboption('advanced', form.Value, 'config_file', _('Configuration file'), + _('Fixed path used by the managed service; edit it in the Configuration tab.')); + o.default = '/etc/meow/config.yaml'; + o.readonly = true; + o.rmempty = false; + + o = s.taboption('advanced', form.Value, 'work_dir', _('Working directory'), + _('GeoIP databases, caches and downloaded rule sets are kept here.')); + o.default = '/etc/meow'; + o.readonly = true; + o.rmempty = false; + + s = m.section(form.NamedSection, 'tproxy', 'transparent', _('Transparent proxy'), + _('Proxy LAN devices without configuring each of them: devices that use this ' + + 'router as their gateway (and DNS) are proxied automatically. Local and ' + + 'private destinations are never proxied. Individual devices can be excluded ' + + 'in the Clients tab. The YAML configuration is updated to match on save.')); + s.addremove = false; + s.tab('basic', _('Basic')); + s.tab('advanced', _('Advanced')); + + o = s.taboption('basic', form.DummyValue, '_gateway', _('Status')); + o.rawhtml = true; + o.cfgvalue = function() { + var node = E('span', {}, _('Collecting data…')); + poll.add(function() { + return fs.exec('/usr/share/meow/gateway.sh', [ 'status' ]).catch(function() { + return { code: 1 }; + }).then(function(res) { + dom.content(node, uci.get('meow', 'tproxy', 'enabled') !== '1' + ? E('em', {}, _('Off')) + : res.code === 0 + ? E('span', { 'style': 'color: #2e7d32; font-weight: bold;' }, _('Active')) + : E('span', { 'style': 'color: #ef6c00; font-weight: bold;' }, + _('Not active — waiting for meow (check the Log tab if this persists)'))); + }); + }); return node; }; - o=s.taboption('basic',form.Flag,'enabled',_('Proxy LAN devices')); o.rmempty=false; - o=s.taboption('basic',form.ListValue,'mode',_('Traffic to proxy')); - o.value('tproxy',_('TCP and UDP')); o.value('redirect',_('TCP only')); o.default='tproxy'; - o=s.taboption('basic',form.ListValue,'interface',_('LAN interface')); - uci.sections('network','interface',function(sec){if(sec['.name']!=='loopback')o.value(sec['.name']);}); o.default='lan'; - o=s.taboption('basic',form.Flag,'dns_hijack',_('Handle LAN DNS')); o.default='1'; o.rmempty=false; - o=s.taboption('advanced',form.Value,'tproxy_port',_('Listener port')); o.datatype='port'; o.default='7893'; - o=s.taboption('advanced',form.Value,'dns_port',_('DNS port')); o.datatype='port'; o.default='1053'; o.depends('dns_hijack','1'); - o=s.taboption('advanced',form.Flag,'ipv6',_('Proxy IPv6')); o.rmempty=false; o.depends('mode','redirect'); - o=s.taboption('advanced',form.DynamicList,'bypass',_('Never proxy these destinations')); o.datatype='cidr'; + + o = s.taboption('basic', form.Flag, 'enabled', _('Proxy LAN devices')); + o.rmempty = false; + + o = s.taboption('basic', form.ListValue, 'mode', _('Traffic to proxy')); + o.value('tproxy', _('TCP and UDP (recommended)')); + o.value('redirect', _('TCP only (for kernels without TPROXY support)')); + o.default = 'tproxy'; + + o = s.taboption('basic', form.ListValue, 'interface', _('LAN interface'), + _('Devices connected through this interface are proxied.')); + uci.sections('network', 'interface', function(sec) { + if (sec['.name'] !== 'loopback') + o.value(sec['.name']); + }); + o.default = 'lan'; + + o = s.taboption('basic', form.Flag, 'dns_hijack', _('Handle LAN DNS'), + _('Answer every DNS query from LAN devices with meow. Required for fake-ip mode.')); + o.default = '1'; + o.rmempty = false; + + o = s.taboption('advanced', form.Value, 'tproxy_port', _('Listener port'), + _('Port of the transparent-proxy listener created in the YAML configuration.')); + o.datatype = 'port'; + o.default = '7893'; + + o = s.taboption('advanced', form.Value, 'dns_port', _('DNS port'), + _('Port meow\'s DNS server listens on (dns.listen in the YAML).')); + o.datatype = 'port'; + o.default = '1053'; + o.depends('dns_hijack', '1'); + + o = s.taboption('advanced', form.Flag, 'ipv6', _('Proxy IPv6'), + _('Also proxy IPv6 TCP. Leave off with fake-ip, which hides IPv6 answers.')); + o.rmempty = false; + o.depends('mode', 'redirect'); + + o = s.taboption('advanced', form.DynamicList, 'bypass', _('Never proxy these destinations'), + _('Extra networks (CIDR, e.g. 203.0.113.0/24) that always go direct, on top of private and reserved ranges.')); + o.datatype = 'cidr'; return m.render(); } diff --git a/luci-app-meow/root/usr/libexec/meow-api b/luci-app-meow/root/usr/libexec/meow-api index 0adbb3231..213333b64 100755 --- a/luci-app-meow/root/usr/libexec/meow-api +++ b/luci-app-meow/root/usr/libexec/meow-api @@ -1,4 +1,6 @@ #!/bin/sh +# Small, allowlisted bridge from authenticated LuCI RPC to the local HTTP API. +# Caller-controlled hosts, ports and arbitrary API paths are never accepted. set -eu case "$#:${1:-}:${2:-}" in 2:GET:/version|2:GET:/configs|2:GET:/connections) body='' ;; diff --git a/luci-app-meow/root/usr/libexec/meow-validate b/luci-app-meow/root/usr/libexec/meow-validate index 9f0acfa47..6acacadae 100755 --- a/luci-app-meow/root/usr/libexec/meow-validate +++ b/luci-app-meow/root/usr/libexec/meow-validate @@ -1,13 +1,14 @@ #!/bin/sh +# No caller-supplied CLI flags or paths: only the generated scratch token. set -eu [ "$#" -eq 1 ] || exit 1 case "$1" in check) scratch=/tmp/meow-luci-check.yaml ;; *) [ "${#1}" -eq 32 ] || exit 1 - case "$1" in *[!0-9a-f]*) exit 1 ;; - esac + case "$1" in *[!0-9a-f]*) exit 1 ;; esac scratch="/tmp/meow-luci-settings-$1.yaml" ;; esac +# Delegated LuCI configuration is deliberately confined to /etc/meow. exec /usr/bin/meow --no-external-plugins -d /etc/meow -f "$scratch" -t diff --git a/luci-app-meow/root/usr/share/luci/menu.d/luci-app-meow.json b/luci-app-meow/root/usr/share/luci/menu.d/luci-app-meow.json index 0b9cc7069..b6e7054e5 100644 --- a/luci-app-meow/root/usr/share/luci/menu.d/luci-app-meow.json +++ b/luci-app-meow/root/usr/share/luci/menu.d/luci-app-meow.json @@ -2,37 +2,66 @@ "admin/services/meow": { "title": "meow", "order": 60, - "action": { "type": "firstchild" }, - "depends": { "acl": [ "luci-app-meow" ], "uci": { "meow": true } } + "action": { + "type": "firstchild" + }, + "depends": { + "acl": [ "luci-app-meow" ], + "uci": { "meow": true } + } }, + "admin/services/meow/overview": { "title": "Overview", "order": 10, - "action": { "type": "view", "path": "meow/overview" } + "action": { + "type": "view", + "path": "meow/overview" + } }, + "admin/services/meow/panel": { "title": "Panel", "order": 20, - "action": { "type": "view", "path": "meow/panel" } + "action": { + "type": "view", + "path": "meow/panel" + } }, + "admin/services/meow/config": { "title": "Configuration", "order": 30, - "action": { "type": "view", "path": "meow/config" } + "action": { + "type": "view", + "path": "meow/config" + } }, + "admin/services/meow/clients": { "title": "Clients", "order": 35, - "action": { "type": "view", "path": "meow/clients" } + "action": { + "type": "view", + "path": "meow/clients" + } }, + "admin/services/meow/settings": { "title": "Settings", "order": 40, - "action": { "type": "view", "path": "meow/settings" } + "action": { + "type": "view", + "path": "meow/settings" + } }, + "admin/services/meow/log": { "title": "Log", "order": 50, - "action": { "type": "view", "path": "meow/log" } + "action": { + "type": "view", + "path": "meow/log" + } } } diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index 89d171919..f6f46aa32 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -7,8 +7,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall -PKG_VERSION:=26.9.27 -PKG_RELEASE:=304 +PKG_VERSION:=26.10.1 +PKG_RELEASE:=305 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/acl_config.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/acl_config.lua index aeb5febce..7ddfdf29d 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/acl_config.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/acl_config.lua @@ -324,6 +324,13 @@ o.default = "chinadns-ng" o:value("dnsmasq", "Dnsmasq") o:value("chinadns-ng", translate("ChinaDNS-NG (recommended)")) o:depends({ _acl_node_bool = "1", _diff_global_node = "1" }) +o.write = function(self, section, value) + local old = m:get(section, self.option) + if old and old ~= value then + m:set("@global[0]", "flush_set", "1") + end + return ListValue.write(self, section, value) +end o = s:taboption("DNS", Flag, "filter_proxy_ipv6", translate("Filter Proxy Host IPv6"), translate("Experimental feature.")) o.default = "0" diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua index cfd8b1b07..5ec4d6dd1 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua @@ -182,14 +182,18 @@ s:tab("DNS", translate("DNS")) o = s:taboption("DNS", ListValue, "dns_shunt", "DNS " .. translate("Shunt")) o:value("dnsmasq", "Dnsmasq") o:value("chinadns-ng", translate("ChinaDNS-NG (recommended)")) +o.write = function(self, section, value) + local old = m:get(section, self.option) + if old and old ~= value then + m:set(section, "flush_set", "1") + end + if value ~= "smartdns" then + m:del(section, "group_domestic") + end + return ListValue.write(self, section, value) +end if api.is_finded("smartdns") then o:value("smartdns", "SmartDNS") - o.write = function(self, section, value) - if value ~= "smartdns" then - m:del(section, "group_domestic") - end - return ListValue.write(self, section, value) - end o = s:taboption("DNS", Value, "group_domestic", translate("Domestic group name")) o.placeholder = "local" diff --git a/luci-app-passwall/luasrc/passwall/api.lua b/luci-app-passwall/luasrc/passwall/api.lua index 31b3d6df8..21ff282df 100644 --- a/luci-app-passwall/luasrc/passwall/api.lua +++ b/luci-app-passwall/luasrc/passwall/api.lua @@ -399,7 +399,7 @@ function remove(...) for i = 1, select("#", ...) do local value = select(i, ...) if type(value) == "string" and #value > 0 and value ~= "/" then - local quoted = util.shellsqescape(value) + local quoted = "'" .. value:gsub("'", "'\\''") .. "'" sys.call("rm -rf " .. quoted) end end diff --git a/luci-app-passwall/root/usr/share/passwall/helper_chinadns_add.lua b/luci-app-passwall/root/usr/share/passwall/helper_chinadns_add.lua index fd2ae8adb..c5d6da11a 100644 --- a/luci-app-passwall/root/usr/share/passwall/helper_chinadns_add.lua +++ b/luci-app-passwall/root/usr/share/passwall/helper_chinadns_add.lua @@ -24,10 +24,10 @@ local fs = api.fs local datatypes = api.datatypes local TMP_PATH = api.TMP_PATH -local TMP_ACL_PATH = TMP_PATH .. "/acl" local RULES_PATH = "/usr/share/passwall/rules" local USER_RULES_PATH = "/etc/passwall/rules" -local FLAG_PATH = TMP_ACL_PATH .. "/" .. FLAG +local CACHE_RULES_PATH = api.CACHE_PATH .. "/user_rules" +local FLAG_PATH = TMP_PATH .. "/acl/" .. FLAG local config_lines = {} local tmp_lines = {} local USE_GEOVIEW = api.uci_get_c("@global_rules[0]", "enable_geoview") @@ -104,9 +104,8 @@ local function get_geosite(list_arg, out_path) return 1 end -if not fs.access(FLAG_PATH) then - fs.mkdir(FLAG_PATH) -end +sys.call("mkdir -p %s" % FLAG_PATH) +sys.call("mkdir -p %s" % CACHE_RULES_PATH) local setflag = (NFTFLAG == "1") and "inet@passwall@" or "" @@ -127,7 +126,7 @@ end --自定义规则组,后声明的组具有更高优先级 --屏蔽列表 -local file_block_host = TMP_ACL_PATH .. "/block_host" +local file_block_host = CACHE_RULES_PATH .. "/block_host" if USE_BLOCK_LIST == "1" and not fs.access(file_block_host) then local block_domain, lookup_block_domain = {}, {} local geosite_arg = "" @@ -170,7 +169,7 @@ if USE_BLOCK_LIST == "1" and is_file_nonzero(file_block_host) then end --始终用国内DNS解析节点域名 -local file_vpslist = TMP_ACL_PATH .. "/vpslist" +local file_vpslist = TMP_PATH .. "/vpslist" if not is_file_nonzero(file_vpslist) then local f_out = io.open(file_vpslist, "w") local written_domains = {} @@ -214,7 +213,7 @@ if is_file_nonzero(file_vpslist) then end --直连(白名单)列表 -local file_direct_host = TMP_ACL_PATH .. "/direct_host" +local file_direct_host = CACHE_RULES_PATH .. "/direct_host" if USE_DIRECT_LIST == "1" and not fs.access(file_direct_host) then local direct_domain, lookup_direct_domain = {}, {} local geosite_arg = "" @@ -264,7 +263,7 @@ if USE_DIRECT_LIST == "1" and is_file_nonzero(file_direct_host) then end --代理(黑名单)列表 -local file_proxy_host = TMP_ACL_PATH .. "/proxy_host" +local file_proxy_host = CACHE_RULES_PATH .. "/proxy_host" if USE_PROXY_LIST == "1" and not fs.access(file_proxy_host) then local proxy_domain, lookup_proxy_domain = {}, {} local geosite_arg = "" @@ -382,9 +381,11 @@ end if IS_SHUNT_NODE then local white_domain, lookup_white_domain = {}, {} local shunt_domain, lookup_shunt_domain = {}, {} - local file_white_host = FLAG_PATH .. "/shunt_direct_host" - local file_shunt_host = FLAG_PATH .. "/shunt_proxy_host" + local CACHE_FLAG_PATH = CACHE_RULES_PATH .. "/" .. FLAG + local file_white_host = CACHE_FLAG_PATH .. "/shunt_direct_host" + local file_shunt_host = CACHE_FLAG_PATH .. "/shunt_proxy_host" local geosite_white_arg, geosite_shunt_arg = "", "" + local SHUNT_LIST = "" local t = api.uci_get_c(NODE) local default_node_id = t["default_node"] or "_direct" @@ -421,12 +422,26 @@ if IS_SHUNT_NODE then end end + SHUNT_LIST = SHUNT_LIST .. domain_list .. _node_id + if _node_id ~= "_direct" then log(string.format(" - Sing-Box/Xray分流规则(%s):%s", s.remarks, DNS_TRUST or "默认")) end end end) + local MD5_FILE = CACHE_FLAG_PATH .. "/md5.txt" + local cache_md5 = "" + if fs.access(MD5_FILE) then + cache_md5 = fs.readfile(MD5_FILE) + end + local new_md5 = api.md5_string(SHUNT_LIST) + if cache_md5 == "" or new_md5 == "" or cache_md5 ~= new_md5 then + api.remove(CACHE_FLAG_PATH) + sys.call("mkdir -p %s" % CACHE_FLAG_PATH) + fs.writefile(MD5_FILE, new_md5) + end + if is_file_nonzero(file_white_host) == nil then if #white_domain > 0 then local f_out = io.open(file_white_host, "w") diff --git a/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua b/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua index 6e9945ed8..dbb00638a 100644 --- a/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua +++ b/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua @@ -29,10 +29,10 @@ local fs = api.fs local datatypes = api.datatypes local TMP_PATH = api.TMP_PATH -local TMP_ACL_PATH = TMP_PATH .. "/acl" local RULES_PATH = "/usr/share/passwall/rules" local USER_RULES_PATH = "/etc/passwall/rules" -local FLAG_PATH = TMP_ACL_PATH .. "/" .. FLAG +local CACHE_RULES_PATH = api.CACHE_PATH .. "/user_rules" +local FLAG_PATH = TMP_PATH .. "/acl/" .. FLAG local TMP_CONF_FILE = FLAG_PATH .. "/smartdns.conf" local config_lines = {} local tmp_lines = {} @@ -110,9 +110,8 @@ local function get_geosite(list_arg, out_path) return 1 end -if not fs.access(FLAG_PATH) then - fs.mkdir(FLAG_PATH) -end +sys.call("mkdir -p %s" % FLAG_PATH) +sys.call("mkdir -p %s" % CACHE_RULES_PATH) local LOCAL_EXTEND_ARG = "" if LOCAL_GROUP == "null" or LOCAL_GROUP == "" then @@ -285,7 +284,7 @@ for i = 1, 8 do end --屏蔽列表 -local file_block_host = TMP_ACL_PATH .. "/block_host" +local file_block_host = CACHE_RULES_PATH .. "/block_host" if USE_BLOCK_LIST == "1" and not fs.access(file_block_host) then local block_domain, lookup_block_domain = {}, {} local geosite_arg = "" @@ -329,7 +328,7 @@ if USE_BLOCK_LIST == "1" and is_file_nonzero(file_block_host) then end --始终用国内DNS解析节点域名 -local file_vpslist = TMP_ACL_PATH .. "/vpslist" +local file_vpslist = TMP_PATH .. "/vpslist" if not is_file_nonzero(file_vpslist) then local f_out = io.open(file_vpslist, "w") local written_domains = {} @@ -375,7 +374,7 @@ if is_file_nonzero(file_vpslist) then end --直连(白名单)列表 -local file_direct_host = TMP_ACL_PATH .. "/direct_host" +local file_direct_host = CACHE_RULES_PATH .. "/direct_host" if USE_DIRECT_LIST == "1" and not fs.access(file_direct_host) then local direct_domain, lookup_direct_domain = {}, {} local geosite_arg = "" @@ -427,7 +426,7 @@ if USE_DIRECT_LIST == "1" and is_file_nonzero(file_direct_host) then end --代理(黑名单)列表 -local file_proxy_host = TMP_ACL_PATH .. "/proxy_host" +local file_proxy_host = CACHE_RULES_PATH .. "/proxy_host" if USE_PROXY_LIST == "1" and not fs.access(file_proxy_host) then local proxy_domain, lookup_proxy_domain = {}, {} local geosite_arg = "" @@ -550,9 +549,11 @@ end if IS_SHUNT_NODE then local white_domain, lookup_white_domain = {}, {} local shunt_domain, lookup_shunt_domain = {}, {} - local file_white_host = FLAG_PATH .. "/shunt_direct_host" - local file_shunt_host = FLAG_PATH .. "/shunt_proxy_host" + local CACHE_FLAG_PATH = CACHE_RULES_PATH .. "/" .. FLAG + local file_white_host = CACHE_FLAG_PATH .. "/shunt_direct_host" + local file_shunt_host = CACHE_FLAG_PATH .. "/shunt_proxy_host" local geosite_white_arg, geosite_shunt_arg = "", "" + local SHUNT_LIST = "" local t = api.uci_get_c(NODE) local default_node_id = t["default_node"] or "_direct" @@ -589,12 +590,26 @@ if IS_SHUNT_NODE then end end + SHUNT_LIST = SHUNT_LIST .. domain_list .. _node_id + if _node_id ~= "_direct" then log(string.format(" - Sing-Box/Xray分流规则(%s)使用分组:%s", s.remarks, REMOTE_GROUP or "默认")) end end end) + local MD5_FILE = CACHE_FLAG_PATH .. "/md5.txt" + local cache_md5 = "" + if fs.access(MD5_FILE) then + cache_md5 = fs.readfile(MD5_FILE) + end + local new_md5 = api.md5_string(SHUNT_LIST) + if cache_md5 == "" or new_md5 == "" or cache_md5 ~= new_md5 then + api.remove(CACHE_FLAG_PATH) + sys.call("mkdir -p %s" % CACHE_FLAG_PATH) + fs.writefile(MD5_FILE, new_md5) + end + if is_file_nonzero(file_white_host) == nil then if #white_domain > 0 then local f_out = io.open(file_white_host, "w") diff --git a/luci-app-passwall/root/usr/share/passwall/iptables.sh b/luci-app-passwall/root/usr/share/passwall/iptables.sh index 8136c8573..cb341c323 100755 --- a/luci-app-passwall/root/usr/share/passwall/iptables.sh +++ b/luci-app-passwall/root/usr/share/passwall/iptables.sh @@ -1567,6 +1567,7 @@ stop() { rm -rf $TMP_PATH2/singbox* rm -rf $TMP_PATH2/dnsmasq* rm -rf $TMP_PATH2/geo_output + rm -rf $TMP_PATH2/user_rules } flush_include } diff --git a/luci-app-passwall/root/usr/share/passwall/nftables.sh b/luci-app-passwall/root/usr/share/passwall/nftables.sh index d19ca4044..b62d7a9e9 100755 --- a/luci-app-passwall/root/usr/share/passwall/nftables.sh +++ b/luci-app-passwall/root/usr/share/passwall/nftables.sh @@ -1606,6 +1606,7 @@ stop() { rm -rf $TMP_PATH2/singbox* rm -rf $TMP_PATH2/dnsmasq* rm -rf $TMP_PATH2/geo_output + rm -rf $TMP_PATH2/user_rules } flush_include } diff --git a/luci-app-substore/CHANGELOG.md b/luci-app-substore/CHANGELOG.md index 6293e4521..f68cedd83 100644 --- a/luci-app-substore/CHANGELOG.md +++ b/luci-app-substore/CHANGELOG.md @@ -2,6 +2,642 @@ All notable changes to this project will be documented in this file. +## [2.6.8-r1] - P2 批次六:网络安全与健壮性(L10 / L11 / L12 / L14) + +P2 批次六,四项都围绕「**检查的强度不能低于被检查者**」这条线: +前两项让校验真正拦得住,后两项让失败路径不再留下副作用。 + +### L10 DNS 解析失败改为 fail-closed(`http.lua`) + +`M.check_public` 在 `resolve()` 返回 nil 时**放行**,理由写的是「无 DNS 解析能力时 +尽力而为」。那是一个 SSRF 绕过口: + +- 检查侧:`resolve()` 返回 nil → 跳过全部私网判定 → 放行 +- 实际连接侧:curl 自己做解析,`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名 + 会被它解析到 `127.0.0.1` 并连上去 + +只要本机这一刻解析不出来(nixio 缺失、解析器临时故障、超时),两者就分叉。 +检查的强度不能低于被检查者,现在解析失败一律拒绝。 + +代价为零:本包依赖 `luci-lua-runtime`,后者在 Makefile 里硬依赖 `+luci-lib-nixio` +(已核对上游),所以 `resolve()` 返回 nil 只意味着「真的解析不了」—— +那种情况下 curl 同样解析不了,下载本来就会失败,只是错误信息会从「连接失败」 +变成「无法解析目标主机名」,反而指对了方向。 + +### L11 `parse_url` 补端口范围校验(`http.lua`) + +`hostport:match("^([^:]+):(%d+)$")` 只保证端口是数字,`http://host:99999/` 与 +`http://host:0/` 都能通过。这两个端口连不出去,下载必然失败,却会先经过一轮 +DNS/SSRF 检查,最终报出「连接失败」这种指错方向的原因。`parse_proxy` 早已做了 +同样的 1–65535 校验,这里对齐。 + +IPv6 字面量分支(`[::1]:port`)走的是另一条赋值路径,同样纳入校验。 + +### L12 探测目标拒绝以 `-` 开头的主机名(`probe.lua`) + +命令形如 `ping -c 1 -W 2 `,`host` 若为 `--help` / `-c`,会被 busybox 的 +**getopt 当成选项**而不是参数。`util.shq` 的单引号由 shell 剥掉,getopt 看到的 +仍然是 `-x` —— 引号挡不住这一层。 + +合法主机名(RFC 1123 要求首字符为字母或数字)与 IP 都不会以 `-` 开头,所以直接拒绝。 +`ping` / `tcping` / `url_test` / 批量 `probe` 四条路径共用 `safe_host`,一处收口。 + +### L14 下载临时文件在每一条退出路径上清理(`http.lua`) + +`fetch_curl` 此前只在「本轮开始」和「2xx 成功」两处 `os.remove`,其余失败路径 +(curl 报错、超过大小限制、重定向无 Location、重定向目标无效/不安全、 +HTTP 4xx-5xx、重定向次数过多)都把 `/tmp/substore_dl_*.tmp{,.hdr,.err}` 留在原地。 + +OpenWrt 的 `/tmp` 是 tmpfs —— 占的是内存。订阅更新失败后 cron 会定时重试, +于是一轮轮往内存里堆文件,其中 `.tmp` 可能是部分下载的响应体,最大到 `max-size`。 + +现在收敛成一个 `cleanup()` 闭包,在**每一条** `return` 之前调用。 + +### 测试 + +- 新增 `tests/network_security_test.lua`(38 条断言):L10 的 fail-closed 与 + 「公网字面量仍放行」对照;L11 的 `:0` / `:65536` / `:99999` 拒绝与 + `:1` / `:65535` / 默认端口接受(含 IPv6 分支);L12 用 `io.popen` 替身断言 + 「以 `-` 开头的主机名**一个子进程都不启动**」,并用合法主机名反向确认替身有效; + L14 用模拟 curl 写盘的替身,逐条验证三条失败路径后 `/tmp` 无残留 +- 全部用例不触网 +- `tests/http_proxy_test.lua` 的重定向链用例改用**公网 IP 字面量**而非 + `cdn.example.com`:`check_public` 现在解析失败即拒绝,用域名会让 + 「公网目标应放行」变成「取决于本机有没有 DNS」,测试不可复现 +- 反向验证:新断言在修复前的 `HEAD` 上 **19 条失败**(L10×2 / L11×4 / L12×4 / L14×9), + 在修复后全部通过 + +## [2.6.7-r1] - P2 批次五:控制器健壮性(L3 / L4) + +P2 批次五,修控制器的两项缺陷:一类是**未登录可达的 500**,一类是 +**点了按钮却什么都没发生**。两者都属于「静默」——不报错、看起来正常。 + +### L3 重复表单字段让 `formvalue` 返回 table,直接崩在 `:gsub` 上 + +- LuCI 的 `formvalue` **不保证返回字符串**。依据上游 `luci/http.lua` 的 + `urldecode_message_body`: + + ```lua + elseif what == parser.VALUE and name then + local val = msg.params[name] + if type(val) == "table" then val[#val+1] = ... + elseif val ~= nil then msg.params[name] = { val, ... } -- ← 第二次出现变成 table + ``` + + 而 `formvalue` 原样返回 `msg.params[name]`;上游 luadoc 也写着 + `@return HTTP input value or table of all input value`。 +- 控制器有 ~30 处直接对返回值做 `:gsub` / `util.trim` / `urlencode`, + 遇到 table 会抛 `attempt to call method 'gsub'` → **HTTP 500**。 + 实测复现:`action_create` / `action_save` / `action_local_create` / + `action_local_save` / `action_combo_save` / `action_node_set_group` / + `action_probe` / `action_download` 共 8 个 action 崩溃。 +- **攻击面不限于已登录用户**:`/substore/download` 是无需登录的入口 + (供 Passwall / OpenClash 拉取),对它 POST 一个重复的 `token` 字段即可触发。 +- 现新增 `fv(http, key)` 统一取值:table 取最后一个(与「同名参数后者覆盖 + 前者」一致),`nil` 保持 `nil`(`post_ok` 依赖它区分「无 token」与 + 「空 token」)。全部调用点收敛到这一处。 +- **更正审计表的表述**:触发条件是 **POST 重复字段**,不是 GET。GET 查询串 + 走的是 `urldecode_params`,它只做 `params[name] = ...` 覆盖、**不建表**。 +- 顺带清理:`action_node_delete` 里 `if type(idx_param) == "table"` 的兜底 + 在 `fv` 之后已成为死代码,移除。 + +### L4 `combo_save` / `delete` / `update` / `node_delete` 静默失败 + +违反项目 §18(失败必须让用户看见,不能「失败了却看起来像成功」): + +- `action_delete`:`core.remove` 的返回值被整个丢弃。非法 ID / 订阅不存在时 + 返回 `false`,页面照常跳回列表 —— 用户点了删除,订阅还在。 +- `action_combo_save`:名称留空、或一个来源都没勾选时整段跳过直接跳回列表; + `add_combo` / `save_combo` 的返回值同样被丢弃(非法 ID、未选来源、写入失败 + 一律静默)。 +- `action_update`:订阅不存在(ID 拼错 / 已被删除)时静默跳回列表。 +- `action_node_delete`:下标解析不出数字、或下标全部越界时静默跳回列表; + `write_nodes` 失败也不提示。 +- 现四处全部补上原因回显。 + +### 测试 + +- 新增 `tests/controller_robustness_test.lua`,27 项断言: + - 12 个 action 逐个在「所有字段都重复提交」下调用,断言**不抛错**; + - 重复字段取值语义(取最后一个); + - L4 四处的失败回显 + 成功路径不带 `err` 的守卫。 +- **反向验证**:指向 `HEAD` 版控制器重跑,**27 项中 18 项失败**, + 崩溃点正是审计表标注的行(`substore.lua:90/118/146/170/321` 与 + `util.lua:9`),确认测试覆盖的是真实缺陷而非恒真。 +- 全量回归:41 个 Lua 测试文件(1809 项断言)+ `cron_result_test.sh`(14) + 全部通过。 +- 版本号 2.6.6-r1 → 2.6.7-r1。 + +## [2.6.6-r1] - P2 批次四:核心数据层(代理解析 / 去重 / cron 退出码 / 规则字段) + +P2 批次四,修四项缺陷(审计表 M16 / M17 / M18 / M30)。前三项在数据通路上, +症状都是「静默失效」——不报错、看起来正常,但结果不对或故障无人知晓; +M30 则是「字段读得到、界面写不了」的静默数据丢失。 + +### M16 未加方括号的 IPv6 代理地址被拼成非法代理串 + +- `M.parse_proxy` 用 `^([^:]+):(%d+)$` 拆 host:port。IPv6 字面量里全是冒号, + 这个模式必然失配,于是整个 `::1:1080` 落进「无端口」分支当作**主机名**, + 再原样拼回去得到 `http://::1:1080` —— 冒号歧义,`curl -x` 与 `http_proxy=` + 都解析不了(curl 会把 `::1:1080` 整个当主机名),**代理静默失效**。 + 更糟的是走 `[::1]:1080` 这条正确写法时,为做主机校验把方括号拆掉后 + **没有拼回去**,同样得到 `http://::1:1080`。 +- 现分三处修正:① 记录是否带方括号;② 未加方括号却含多个冒号时**明确报错** + 并给出正确写法(`::1:1080` 到底是「地址 ::1 + 端口 1080」还是地址 + `::1:1080`,语法上无法判定 —— 与其猜一个再拼一条解析不了的串,不如让用户 + 补方括号);③ 拼回代理串时,主机含冒号就补上方括号。 + +### M17 去重键不含凭据,同入口的多账号被误删 + +- `M.dedup` 此前只按 `proto|server|port` 去重。同一台服务器上的**多账号**是 + 极常见的形态(同一入口不同 uuid / 不同密码),这些节点会被判为重复而 + 只剩第一个 —— 用户看到节点数莫名变少,且**丢的是哪个不可预期**。 +- 现抽出 `identity_key()`,按协议取各自的凭据参与去重键: + vmess/vless → uuid;shadowsocks → method+password;ssr → method+password+ + protocol+obfs;trojan/hysteria/hysteria2 → password;tuic → uuid+password; + socks/http → username+password;wireguard → peer 公钥(沿用原有逻辑)。 + 凭据完全相同的节点仍然照常去重。 + +### M18 cron 脚本把「Lua 根本没跑完」当成成功 + +- `substore-cron.sh` 的退出码判定是 `''|0) exit 0`。结果行**缺失**被和 + 「0 个失败」归为一类 —— 而结果行缺失的真实含义是 Lua 没跑到最后: + 模块加载失败、`core.list()` 抛异常(它在 `pcall` 之外)、解释器中途死掉。 + 于是一次彻底失败的订阅更新在 cron 与外部监控看来**与成功无异**, + 故障永远不会被发现。 +- 现把空结果行单列:写 stderr 诊断并 `exit 1`。 + +### M30 「协议筛选 / 重命名」字段读得到、界面写不了 + +- 控制器 `read_rules_fields` 一直在读 `proto_filter_*` 与 `rename_map`, + 但三个表单(form.htm / local_form.htm / combo.htm)**都没有提交它们的控件**。 + 后果有两层:① 功能不可达,用户永远设不了这两项;② 更严重的是 + **静默数据丢失** —— 控制器每次把 `formvalue` 的 `nil` 落成 `""`, + 于是通过 UCI 手工设过的值在下一次保存时被抹掉。 +- 现三个表单补齐控件(协议勾选框 + 重命名文本域),并把协议清单收敛到 + `core.RULE_PROTOS` 单一来源,由控制器与三个模板共用:两边各写一份的话, + 一旦漂移,勾选框就会生成一个永远匹配不到任何节点的 `proto_filter`, + 而症状是「勾了没用」——不报错,最难查。清单用的是 `node.normalize` 产出的 + **规范**协议名(`socks5` 在解析阶段已归一成 `socks`,故不在清单内)。 + 已有值在编辑页回填,避免「打开编辑页看不到当前设置,一保存就被覆盖」。 + +### 测试 + +- 新增 `tests/rules_fields_test.lua`,42 项断言:`RULE_PROTOS` 的形态 / + 唯一性 / 规范性,三个模板与控制器的静态检查,以及「控制器收集逻辑产出的 + CSV 真的能被 `node.apply_rules` 用来筛选、`rename_map` 真的生效」的行为检查。 + **反向验证**:指向 `HEAD` 版模板与控制器重跑,**17 项失败**。 +- `tests/node_extended_test.lua` 扩充 dedup 段(6 项新断言)。原有用例 + 「两个 uuid 不同的 vmess 应合并成 1 个」正是 M17 要修的**错误行为**, + 已改为断言修复后的正确结果。 + **反向验证**:指向 `HEAD` 版 `node.lua` 重跑,**6 项失败**。 +- `tests/cron_result_test.sh` 增加两项用例(`core.list()` 抛异常、 + 模块加载失败),共 14 项。**反向验证**:指向 `HEAD` 版脚本重跑,**3 项失败**。 +- `tests/controller_local_test.lua` 的 `substore.core` stub 补上 `RULE_PROTOS` + (控制器现在会遍历它)。 +- 全量回归:40 个 Lua 测试文件(1782 项断言)+ `cron_result_test.sh`(14) + 全部通过。 +- 版本号 2.6.5-r1 → 2.6.6-r1。 + +## [2.6.5-r1] - P2 批次三:输出层合法性(配置能否被目标客户端加载) + +P2 批次三,修输出层七项缺陷(审计表 M19 / M23 / M24 / M25 / M27 / L21 / L22)。 +共同判据只有一条:**生成的文件/链接必须能被目标客户端真正加载**, +而不是「看起来像那么回事」。 + +### M23 wireguard 数组字段以字符串形态原样透传 + +- 节点模型里 `allowed-ips` / `reserved` / `dns` 的形态取决于来源:Clash YAML 的 + 嵌套列表解析后是 table,表单导入 / URI 导入 / `.conf` 导入后是 + `"0.0.0.0/0, ::/0"` 这样的**字符串**。输出层此前不看类型直接透传,于是 + sing-box 出 `"allowed_ips":"0.0.0.0/0"`、`"reserved":"1,2,3"`, + clash-meta 出 `allowed-ips: 0.0.0.0/0` 这个**标量**。 +- 而 mihomo 的 `allowed-ips` / `dns` 是 `[]string`、`reserved` 是 `[]uint8`, + sing-box 同名字段亦然 —— 标量反序列化失败,**整份配置拒绝加载**。 +- 现统一归一为列表:clash-meta 的 `yaml_value` 改为「数组或逗号分隔字符串 → + YAML 列表」;sing-box 的 `allowed_ips` / `dns` 走 `as_list`, + `reserved` 走 `as_num_list`(sing-box 要求 `[1,2,3]` 数字,字符串数组同样失败)。 + 空值不输出该键(`allowed-ips: []` 也是非法值)。 + +### M27 未加引号的 YAML 标量里反斜杠被静默翻倍 + +- `esc_yaml` 先无条件执行 `gsub("\\", "\\\\")` 再判断是否需要引号。而反斜杠 + **不在** `need_quote` 的触发集里,所以 `pa\ss` 走的是「不加引号」这条路: + 输出 `password: pa\ss` 的字面文本是 `pa\\ss`,YAML 按 plain scalar 回读 + 得到**两个反斜杠** —— 密码 / 路径直接错。 +- 现把转义链移进 `if need_quote` 分支:未加引号时反斜杠就是字面反斜杠, + 加引号时才需要转义。`pa\ss` → `pa\ss`;`pa\ss: x`(含 `:`,需引号) + → `"pa\\ss: x"`。 + +### M24 hysteria2/hysteria 分享链接丢掉「跳过证书校验」 + +- 该分支只读 `n.insecure`。但按 `parser.lua` 自身的注释,模型里的**权威字段是 + `skip-cert-verify`**(sing-box JSON 的 `tls.insecure` 也映射到它), + 而 `insecure` 只是 URI 参数名、**只有 URI 解析器会写它**。 +- 于是 Clash YAML / sing-box JSON / 表单导入的节点在导出分享链接时, + 「跳过证书校验」被整个丢掉,客户端按严格校验握手直接失败。 +- 现按 `skip-cert-verify` → `skip_cert_verify` → `insecure` 的优先级读取, + 统一归一为 `insecure=1` / `insecure=0`(falsy 判定与 `output_singbox.lua` + 的 `bool()` 一致:`false` / `"false"` / `0` / `"0"` 视为否)。 + +### M25 trojan/tuic 分享链接的 alpn 数组未归一 + +- 这两个分支把 `n.alpn` 直接交给 `url_encode`,而 `url_encode` 会 + `tostring()` —— alpn 为 table 时(Clash YAML 的 alpn 列表、sing-box JSON 的 + `tls.alpn` 导入后都是 table)链接里出现 `alpn=table%3A%200x...`, + 客户端解析失败。vless 分支早已做了归一,这两处漏了。 +- 现抽出 `alpn_str()` 统一处理,三处共用。 + +### L21 成员列表里含逗号的名字被当成两个成员 + +- Surge 家族 `[Proxy Group]` 与 QX `[policy]` 的成员列表是 + `NAME = select, X, Y, DIRECT`,语法里**没有引号 / 转义机制**。名字里的逗号 + 会被当成成员分隔符:`A,B` 被读成两个成员 `A` 与 `B`,两个都不存在 —— + Surge / QX 会因「引用不存在的代理」**拒绝加载整份配置**。 +- 现 `names_of()` 排除含逗号的名字(节点定义仍留在 `[Proxy]` / `[server_local]` + 中,只是不进成员列表),并统一先过 `util.one_line()`:定义行本就经过 + `one_line`(换行→空格),成员列表若用原始名就对不上定义行,同样是悬空引用。 + +### M19 QX `[policy]` 引用未定义的服务器 + +- QX 的 `[server_local]` 只输出 shadowsocks / vmess / vless / trojan 四类协议, + 而 `[policy]` 用**未过滤**的 `names_of(nodes)` 收集全部节点名 —— + hysteria2 / tuic / socks / wireguard 等没有定义行,列进 `static=` 就是 + **悬空引用**。 +- 现按「真正写出了 `[server_local]` 行的节点」收集成员。 + +### L22 非数字 port 原样输出 + +- `port: abc` / `port: 443/tcp` 这类非数字值会让 mihomo 拒绝加载整份配置。 + sing-box / v2ray 输出一直用 `tonumber() or 0` 兜底,clash-meta 漏了。 + 现对齐。 + +### 已核实无需修改 + +- **M26**(hysteria/hysteria2/tuic 缺 `security` 时不输出 TLS)已在 P0 批次四 + 修复(`fb51e6e` 为三者补了 `node.normalize` 的 `security` 默认值), + 经探针复核:raw 节点经 `normalize` 后 sing-box / clash 均正确输出 TLS。 + +### 测试 + +- 新增 `tests/output_legal_test.lua`,39 项断言,覆盖上述七项, + 并对「本就正确的行为」加了守卫(数组形态不被破坏、字符串 alpn 不受影响、 + 数字端口不被改写、无 `insecure` 时不输出该参数)。 +- **反向验证**:把测试指向 `HEAD` 版输出模块重跑,39 项中 **28 项失败**, + 确认它们覆盖了缺陷而非恒真;其余 11 项是行为守卫,本就应当通过。 +- 全量回归:41 个 Lua 测试文件(1786 项断言)+ `cron_result_test.sh`(11) 全部通过。 +- 版本号 2.6.4-r1 → 2.6.5-r1。 + +## [2.6.4-r1] - P2 批次二:wg-quick 导入健壮性与 IPv6 内网判定 + +P2 批次二,修 `parser.lua` 的五项缺陷(审计表 M9 / L20 / L16 / L17 / L18)。 +每项均先复现、后修改,并新增回归测试。 + +### M9 wg-quick `.conf` 一个坏 `[Peer]` 废掉整份文件 + +- **首个失败对端即中止**:`if not host … then return nil, … end` 位于 + `for _, p in ipairs(peers)` **循环体内**,于是第一个缺 `Endpoint`(或 + `Endpoint` 解析不出端口)的 `[Peer]` 会让整份 `.conf` 返回 `nil`, + 同文件里其它完好的对端全部丢失。现在只跳过该对端;若一个可用对端都没有 + (`#peers > 0` 已保证走不到「没有 [Peer]」分支),返回错误而**不是空列表** —— + 空列表会被上层当成「解析成功但 0 节点」的静默失败。 +- **不剥行内注释**:行扫描只跳**整行** `#` / `;` 注释,而 wg-quick 的 + `parse_options` 用 `stripped="${line%%\#*}"`,即从**第一个** `#` 起全部丢弃 + (不要求 `#` 前有空白)。于是 `Endpoint = 1.2.3.4:51820 # 备用` 会把 + `# 备用` 当成值的一部分,`split_hostport` 取不到端口,同样整份作废。 + 现对齐 wg-quick 语义做行内剥离;`;` 按上游行为**不**作注释符, + 仅保留本实现原有的整行容忍。 + +### L20 多 `[Peer]` 时数组字段被所有节点共享 + +- `for k, v in pairs(common) do out[k] = v end` 是浅拷贝,而 `common.dns` + (多值时为数组)与 `common.reserved`(恒为数组)是 **table**,于是所有生成的 + 节点指向**同一个表** —— 按节点编辑 DNS 会同时改到全部节点。紧邻的 + `amnezia-wg-option` 子块本就做了副本(注释还专门说明了这个隐患),这两个漏了。 + 现统一走一层表拷贝。 + +### L16 `parse_local_link` 丢掉 query 与 userinfo + +- **无路径时 query 全丢**:authority 用 `^([^/]*)` 切分,`?` 不在排除集内, + 于是 `http://host:port?target=ClashMeta&name=Foo` 的 authority 变成 + `host:port?target=ClashMeta&name=Foo` —— `host` 被污染,`target` / `name` / + `uid` 全部丢失。现改为 `^([^/?]*)`。 +- **不剥 userinfo**:`detect_local_link` 一直会剥 `user@`,此处漏了。 + 现同样剥离(按**最后一个** `@`,与 M10 的约定一致)。 + +### L17 `is_private_host` 的 IPv6 判定可被等价写法绕过 + +- 旧实现拿字符串比前缀(`^::` / `^f[cd]` / `^fe[89ab]`)并只对 `^0*` 做一次 + 去零,于是 `[0::1]`、`[0000::1]`、`[0:0:0:0:0:0:0:1]`(同一个回环地址的 + 不同写法)**全部被判成公网**,而 `[::ffff:8.8.8.8]` 反被判成内网。 + 现先把 IPv6 字面量**展开成 8 组 16 位数值**(含 `::` 压缩、zone id、 + 嵌入式 IPv4 写法)再判定:`::` / `::1`、IPv4 映射地址按 IPv4 规则递归判定、 + ULA `fc00::/7`、链路本地 `fe80::/10`。该函数目前未被生产代码接线 + (仅 `detect_local_link` 调用,而后者只被测试引用),属**潜在** SSRF 缺口, + 非当前可利用路径。 + +### L18 纯空白 / 只有 BOM 的内容报「无法识别的订阅格式」 + +- `M.detect` 会 trim 并去 BOM 后判为 `empty`,而 `M.parse` 只挡住了完全空串 + (`content == ""`),`format == "empty"` 不匹配任何分支,于是掉到末尾报 + 「无法识别的订阅格式」。内容确实是空的,不是格式不认识 —— 提示误导。 + 现返回与空串一致的 `{ nodes = {}, format = "empty" }`。 + +### 测试 + +- `tests/wireguard_conf_test.lua` 新增 23 项断言(行内注释、坏 `[Peer]` 跳过、 + 全部对端不可用报错、多对端数组字段不共享)。 +- `tests/parser_local_link_test.lua` 新增 28 项断言(无路径 query、userinfo、 + IPv6 内网判定的 16 种写法与 4 种公网反例、空白 / BOM 内容)。 +- **反向验证**:把两个测试文件指向 `HEAD` 版 `parser.lua` 重跑, + 新增断言分别失败 16 项 / 13 项,确认它们确实覆盖了缺陷而非恒真。 +- 全量回归:40 个 Lua 测试文件(1747 项断言)+ `cron_result_test.sh`(11) 全部通过。 +- 版本号 2.6.3-r1 → 2.6.4-r1。 + +## [2.6.3-r1] - P2 批次一:JSON 编解码保真与主机名拆分 + +P2 批次一,修 `util.lua` 的三项缺陷(审计表 M13 / M14 / L13)。 +每项均先复现、后修改,并新增回归测试。 + +### M14 `json_decode` 永不返回错误串 + +- **错误被丢弃**:结尾写成 `local v = parse(); return v` —— `parse` 的第二返回值 + (错误串)被直接丢掉。后果是所有调用方的 + `local data, err = util.json_decode(...)` 里 **`err` 判断全是死代码**, + 畸形输入一律表现为「解出来是 nil」,与「内容本来就是 `null`」无法区分。 + 现在 `parse` 的错误逐层透出(对象、数组、键、值四处),顶层再判一次。 +- **内层错误被吞**:数组循环里无条件 `if val == nil then val = JSON_NULL end`, + 于是 `[1,]` 这种畸形输入被静默接受(`parse` 报错后游标不前进,下一轮读到 `]` + 就当作数组结束),解出 `{1, }`。现在内层错误直接上抛。 +- **尾部脏数据被忽略**:`parse` 只消费一个值,`"1 2"` / `"[1] junk"` / + `"1.2.3"`(数字模式只吃 `1.2`)都被当成合法值,尾部内容凭空消失。 + 现在解析后必须确认无剩余内容(尾随空白仍允许)。 + +### M13 数组 `null` 与空对象往返被改写 + +- **数组里的 `null` 变成 `[]`**:解码时用 `JSON_NULL` 占位保留位置,但 + `json_encode` **没有对应的编码分支**,占位符落进 `is_array` 判定(空表判为数组) + 被编成 `[]` —— `[null,1]` 往返变成 `[[],1]`,结构被悄悄改写。已补分支还原为 + `null`。 +- **空对象变成空数组**:`{"tls":{}}` 解出的裸 `{}` 会被 `is_array` 判成数组、 + 编码回 `[]`,于是 `{"tls":{}}` 往返变成 `{"tls":[]}`。sing-box / Xray 里要求是 + **对象**的字段(`tls` / `settings`)会因此被客户端拒绝。现在解码空对象时返回 + 既有的 `M.JSON_EMPTY_OBJECT` 占位(该常量的注释本就说明了这个用途), + 空数组仍编码为 `[]`。 + +### L13 `split_hostport` 把尾随冒号当成主机名 + +- `"example.com:"` 原样返回 `host="example.com:"`(端口匹配失败后直接返回整串)。 + 调用方拿这个带冒号的串当主机名去解析 DNS 必然失败,而失败点离此处很远、 + 很难定位。现在剥掉**结尾**的冒号;`"::1"` 这类不带方括号的裸 IPv6 不受影响, + 剥完为空则返回 `nil, nil`。 + +### 测试 + +- 新增 `tests/util_json_test.lua`(29 项断言):错误上报、尾部脏数据、 + `null` / 空对象 / 空数组往返、以及既有行为的回归(转义、unicode、数字、字符串)。 +- `tests/run_tests.lua` 的 `split_hostport` 段扩 4 项(尾随冒号、`[::1]:`、 + 裸 `::1`、裸 `:`)。 +- 全量回归:40 个 Lua 测试文件 + `cron_result_test.sh`(11) 全部通过,0 失败。 +- 版本号 2.6.2-r1 → 2.6.3-r1。 + +## [2.6.2-r1] - 更新日志补记与遗留缺陷汇总 + +**文档批次,无代码改动。** + +- **补记 P0 四次推送的更新内容**:`[2.6.0-r2]` ~ `[2.6.0-r5]` 四个条目对应提交 + `12ab1a7` / `46fe3b6` / `2b9934e` / `fb51e6e`。这四次推送当时**未提升版本号** + (`Makefile` 始终为 `2.6.0-r1`),因此本文件原先没有对应条目;现按推送顺序补记, + 使更新日志与实际推送一一对应。包版本号未回改,历史提交未被重写。 +- **新增 [`docs/LEGACY_ISSUES.md`](docs/LEGACY_ISSUES.md)**:汇总截至 2.6.1-r1 审计中 + 已确认但**尚未修复**的问题,每项给出代码级依据、影响面与候选修复方案,供决定后续 + 修复优先级。原始 70 项审计表未落盘,该文件自本次起作为「未修复项」的权威清单。 +- **修正一处遗留项的描述**:`[2.6.1-r1]` 中记录的「sing-box YAML 读取侧不读传输参数」 + 经实测确认**比原描述更严重**——两个读取侧都不把 `transport.type` 映射到 `net`, + ws / grpc / h2 节点导入后**整层传输丢失**(退化为 tcp 直连),而非仅丢 path/host。 + 详见 `docs/LEGACY_ISSUES.md` 第 3 节。 +- 版本号 2.6.1-r1 → 2.6.2-r1。 + +## [2.6.1-r1] - P1 批次缺陷修复(解析保真 / 输出合法性) + +本轮修复 2.6.0 审计表中 **P1 批次的 13 项缺陷**,另修复审计过程中实测确认的 +**4 项新缺陷**。所有修改均先做代码级审计、再用临时探针复现缺陷、修改后复测, +并补齐回归测试(新增 `tests/p1_fixes_test.lua`,97 项断言)。未做任何推测性改动。 + +### 解析保真(节点导入) + +- **trojan 密码未做 URL 解码**:`trojan://p%40ss%3Aword@host:443` 的密码被当成 + 字面量 `p%40ss%3Aword` 存下,认证必然失败。已补 `util.url_decode`。 +- **vmess classic JSON 忽略 `host` / `path`**:`vmess://base64({...})` 里的 + `host`(ws Host 头)与 `path`(ws 路径)被静默丢弃,服务端按默认路径匹配失败。 + 现已保留;未提供时不会凭空造出字段。 +- **SSR 密码 base64 回退不可达**:密码字段的 base64 解码走了不可达分支, + 明文密码会被解成乱码并写盘下发。已改用往返一致性判据的解码器。 +- **`ssr://` 外层不接受 base64url 字母表**(新发现):`util.base64_decode` 会把 + `-` / `_` 当非法字符**直接剔除**,于是外层用 base64url 编码的 `ssr://` 链接 + 少掉若干字符、整串解成乱码——server / port / 密码全错,且不报错。 + 已改用 `base64_url_decode`(对标准 base64 输入逐字节等价,严格更宽容)。 +- **userinfo 按首个 `@` 切分**:密码含未转义的 `@` 时(`hysteria2://p@ss@host:443`) + 密码被截断、host 变成 `ss@1.2.3.4`。trojan / hysteria2 / hysteria / tuic + 改为按**最后一个** `@` 切分。 +- **vless / trojan / vmess / hysteria2 / tuic / ss 缺 host·port 校验**:残缺节点会 + 被写成 `server: ` / `port: 0`,mihomo 与 sing-box 会**拒绝加载整份配置**—— + 一个节点废掉整个订阅。解析阶段即丢弃(port 必须落在 1..65535)。 +- **sing-box YAML 的嵌套 `tls:` map 被跳过**:`tls: {enabled, server_name, insecure}` + 整层丢失,导出的 trojan/vmess/vless 静默退化成明文,hysteria2/tuic 更让客户端 + 以 `C.ErrTLSRequired` 拒绝启动。现已展开为 `security` / `sni` / + `skip-cert-verify` / `alpn` / `fp`。 +- **简易 YAML 解析器不支持嵌套序列**(新发现):`tls.alpn:` 后跟 `- h2` 会让 + 映射收集器在该行立刻中断,**不只 alpn 丢失,排在它后面的 `utls.fingerprint` + 也一并消失**。已新增序列收集逻辑(声明顺序置于映射收集器之前, + 避免 Lua 局部函数 upvalue 捕获陷阱)。 +- **通用 JSON 节点忽略 `type` 字段、无协议白名单**:`type` 不再被忽略,改为按 + 权威映射表转成协议并消费掉;未知类型(`snell` / `ssh` / `shadowtls` 等) + 直接丢弃,而不是兜底成 vmess 造出字段全错的假节点。 +- **Surge 段名大小写敏感**:`[PROXY]` / `[Server_Local]` 全大写段名不被识别, + 而 `[` 开头的内容会被判成 JSON 数组,整份订阅报「JSON 解析失败」,一个节点都 + 拿不到。段名判定改为大小写不敏感。 +- **Surge / QX 未知协议无白名单**(H6 的 Surge 侧):`A = snell, …` 会把 + `proto="snell"` 透传进模型,输出端变成 sing-box 的 `type: "snell"` / + Xray 的 `protocol: "snell"` 这类非法取值。现与 Clash YAML 走同一张权威表。 +- **订阅列表文件含非法条目时崩溃**:`core.list()` 的 `pairs(meta)` 会抛 + `table expected, got string`,订阅列表页直接 500;cron 路径更糟—— + 异常让整轮同步在打印统计前中断,而 `substore-cron.sh` 据此判为**成功**。 + 现在过滤坏条目、返回可用条目并带上损坏错误,写路径据此拒绝落盘 + (避免下次保存把坏条目永久抹掉)。 + +### 输出合法性 + +- **`output_v2ray` 协议白名单**:原判定是「不等于 ssr」,于是 hysteria2 / + hysteria / tuic / wireguard 被写成 Xray 根本不认识的 `"protocol": "hysteria2"`, + 凭据还被塞进无意义的 `users` 字段——Xray 解析到未知 protocol 会拒绝整份配置。 + 改为白名单(vmess / vless / trojan / shadowsocks / ss / socks / socks5 / http)。 +- **clashmeta 从不写 `flow`**:vless 的 `xtls-rprx-vision` 丢失,mihomo 按普通 + vless 处理,服务端要求 vision 时握手失败。surge / v2ray / URI 三个输出都写 + flow,只有 clashmeta 漏了;而 Clash YAML 解析器明确会回读 flow。 +- **clashmeta 丢弃 grpc / h2 传输参数**:只写 `network: grpc`,不写 + `grpc-opts.grpc-service-name`,客户端用默认服务名去连、握手失败——与 ws 丢 + path 同类。h2 同理,现按上游文档写 `h2-opts.host`(**列表**)与 + `h2-opts.path`(标量)。服务名取自 `node.path`,与 v2ray 的 `serviceName`、 + sing-box 的 `service_name` 同源。 + +### 健壮性 + +- **改名替换串里的 `%` 未转义**:`%` 后接非数字字符会被 gsub 静默吞掉 + (`50%off` → `50off`),**结尾的 `%` 会注入一个 NUL 字节** + (`100%` → `100\0`)——节点名会写进节点文件并下发给所有客户端。 + 现已把字面 `%` 转义为 `%%`,`$1` 仍按捕获引用处理。 +- **修复自身引入的 `and/or` 三元陷阱**:`(cond) and nil or x` 在 cond 为真时得到 + `nil`,再被 `or x` 兜回 `x`,等于没生效(正是代码里已注释警告过的坑)。 + 已改为显式 `if`。 + +### 测试 + +- 新增 `tests/p1_fixes_test.lua`:97 项断言覆盖上述全部修复, + 每项都先在修改前复现缺陷、修改后断言修复行为。 +- 全量回归:39 个 Lua 测试文件 + `run_tests.lua`(47) + `cron_result_test.sh`(11) + 全部通过,0 失败。 +- 版本号 2.6.0-r1 → 2.6.1-r1。 + +### 已知未修复(待确认,本轮未改) + +> 汇总已迁移至 [`docs/LEGACY_ISSUES.md`](docs/LEGACY_ISSUES.md),含各项的代码级 +> 依据、影响面与候选修复方案。此处保留索引: + +- **H10 的丢更新(lost update)**:`load()` → `save()` 之间无任何锁,并发写会 + 互相覆盖。Lua 5.1 没有可用的原子锁原语(`os.rename` 覆盖语义无法 CAS、 + 无 `flock`、`io.open` 无 `"x"` 模式、无 `link()`),`mkdir` 方案有陈旧锁死锁 + 风险。建议作为已知限制记录,或引入 `nixio` 的 flock(本机无法验证)。 +- **M28 / M29(CSRF `post_ok()`、ACL)**:依赖 LuCI 框架运行时行为 + (`test_post_security`、ACL 解析),本机无 LuCI 运行环境,无法验证,故未改。 +- **sing-box 读取侧传输整层丢失**:`parse_singbox_json` 不读 `transport.type` / + `transport.path` / `transport.headers.Host` / `tls.utls.fingerprint`, + 简易 YAML 侧同样不处理 `transport`。实测 ws 节点导入后 `net=tcp`、`path=nil`、 + `host=nil`,即整层传输丢失(原记录为「不读 path/host」,实测更严重)。 +- **`converter.lua` / `node_converter.lua` 为死代码**:应用中无任何转换入口, + 但 README 声称「协议转换:任意协议 → 任意协议」。 + +## [2.6.0-r5] - P0 批次四:表单编辑丢 TLS、注入 XSS 与协议字段清单漂移 + +> 补记条目,对应提交 `fb51e6e`。编号说明见下方 `[2.6.0-r2]` 末尾。 + +- **H2 节点页注入 `` 即可执行任意脚本。 + 与 `node_edit.htm` / `local_form.htm` 一致改为 `json_encode` 后转义 ` 补记条目,对应提交 `2b9934e`。编号说明见下方 `[2.6.0-r2]` 末尾。 + +- **H8 `core.load` 不再把「解析失败」当成空列表**:原先 `json_decode` 失败即 + `return 0, {}`,而 `M.add` / `M.add_local` / `M.add_combo` 会在这个空表上追加 + 一条再整表写回 —— **一次损坏就抹掉用户全部订阅**,且 `_seq` 归零后重新发出 + `s00000001` 这类已用过的 ID。现在 `load` 返回第三个值 `err`,写入路径据此拒绝 + 操作;`M.list` 也把 `err` 透出给调用方。 +- **H9 `atomic_write` 检查 write / close / rename 的返回值**:磁盘写满时 `f:write` + 失败但 `os.rename` 仍会成功,等于**原子地换上一个残缺文件**而调用方以为写入成功。 + 失败路径统一清理临时文件并返回 `false, err`。 +- **H10 临时文件名唯一化**:原先固定为 `".tmp"`,两个进程同时写同一路径会 + 交错写进同一个临时文件,`rename` 上去的是两者内容的混合体,各自的原子性都失效。 + 现改为 `".tmp.<8 字节随机 hex>"`。 +- **H11 `rnd_hex` 改用内核熵源**:Lua 5.1 的 `math.random` 是 31 位 LCG,原实现每次 + 调用都重新播种,同一时钟刻度内给出相同序列:**实测 20 万次调用约 9.7% 重复**。 + 订阅下载 token 是访问控制的唯一凭据,重复即可被猜测。现优先读 `/dev/urandom`, + 仅在不可用时退回只播种一次的 PRNG。 + +新增 `tests/data_integrity_test.lua`(28 项断言)覆盖上述四项, +全套 37 个 Lua 测试文件全部通过。 + +## [2.6.0-r3] - P0 批次二:解析器静默吞掉整份订阅 + +> 补记条目,对应提交 `46fe3b6`。编号说明见下方 `[2.6.0-r2]` 末尾。 + +- **base64 里包着 YAML / JSON**(`parser.lua`):base64 分支原先一律把解码结果按 + URI 列表解析。机场把整份 Clash 配置或 sing-box 配置 base64 后直接下发是常见做法, + 这类订阅会得到 **0 个节点且不报错**,用户只看到「订阅为空」。现在解码后重新走一遍 + `detect`/`parse` 复用既有分支;`inner == "base64"` 时不再递归(避免 base64 套 + base64 无限递归),外层容器格式仍报 `base64`。 +- **流式风格与行尾注释**(`parser_clash_yaml.lua`): + - 新增 `parse_flow_map` / `split_top`:`- {name: A, type: vmess, server: 1.1.1.1}` + 是合法 YAML 但不是合法 JSON(键没加引号),`util.json_decode` 必然失败, + 原先**整项被静默丢弃**。现在 JSON 解失败后回落到 YAML 流式映射解析,并正确 + 切分 `{}` / `[]` 内部以及引号内的逗号(`alpn: [h2, http/1.1]`)。 + - 新增 `strip_comment`:YAML 规定内联注释的 `#` 前必须有空白,且引号内的 `#` + 不是注释。原先 `proxies: # 说明` 会把注释文本当成值,`proxies` 变成字符串, + **整份配置被判定为「没有 proxies」**。列表项同样处理(`- {...} # 注释`)。 + +回归测试:新增 20 项断言(base64 包 YAML / JSON / 嵌套 base64、流式映射、流式数组值、 +行尾注释、`#` 无空白与引号内 `#` 必须保留)。 + +## [2.6.0-r2] - P0 批次一:sing-box TLS 失效、未知协议泄漏与输出格式注入 + +> 补记条目,对应提交 `12ab1a7`。 + +**输出正确性:** + +- `output_singbox`:tls 块补 `enabled=true`。sing-box 的 `OutboundTLSOptions.Enabled` + 是 bool + `omitempty`(`option/tls.go`),**缺省即 false**:只写 `server_name` 而不写 + `enabled` 等于没配 TLS —— vmess/vless/trojan 退化为明文拨号,hysteria2/tuic 更会因 + `C.ErrTLSRequired` 拒绝启动。顺带移除因此变成死代码的空表分支,并更正 + `parser_json_config` 里「enabled 缺省即 true」的错误注释。 +- `parser_clash_yaml`:未知 Clash 类型(snell / shadowtls / mieru 等)改为**丢弃**。 + 原先 `TYPE_MAP[p.type] or p.type or "vmess"` 会把它透传成 sing-box 的 `type:"snell"`、 + Xray 的 `protocol:"snell"` 这类非法取值,客户端会拒绝加载整份配置;缺失 type 时兜底成 + vmess 则是凭空造出字段全错的假节点。`parser.lua` 的简易 YAML 兜底解析(复用同一张 + `TYPE_MAP`)早已如此处理,此处对齐。 +- `output_clash_meta`:`sni` 与 `servername` 同时存在时只输出一个 `servername` 键。 + Clash YAML 导入会同时填上两者,原先各写一行让 YAML 出现**重复键**。 +- `output_clash_meta`:`esc_yaml` 补 `\r` / `\t` 转义,并在转义前判断是否需要加引号 —— + 转义后的 `"\t"` 落在 plain scalar 里会被 YAML 当成两个普通字符;未加引号的换行/回车 + 则直接破坏文档结构。 +- `output_formats`:tuic 的 alpn 为数组时(sing-box JSON / Clash YAML 的 alpn 列表 + 导入后即为 table)不再 `attempt to concatenate a table value`。 + +**换行注入**(节点名等来自订阅内容,属不可信输入): + +- 新增 `util.one_line`:把值压成单行(换行/回车转空格,其余控制字符丢弃)。 +- `output_formats`:`surge_line` / 代理组行 / Quantumult X 的 `server_local` 与 `policy` + 行统一压平 —— 含换行的节点名会截断当前行并**伪造出新的代理行**。 +- `output_wireguard_conf`:`.conf` 注释行压平 —— 否则节点名里的 `"\n[Interface]"` + 会**注入出真正的配置段**。 + +回归测试:新增 34 项断言覆盖以上全部缺陷(含 sing-box TLS 往返、surge/QX/wgconf 的 +行数不变性、未知类型丢弃、控制字符转义)。 + +> **关于以上四条 `-r2` ~ `-r5` 的编号**:这四次推送(`12ab1a7` / `46fe3b6` / +> `2b9934e` / `fb51e6e`)当时**未提升版本号**,`Makefile` 始终为 `2.6.0-r1`, +> 因此本文件中原先没有对应条目。现按推送顺序补记为 `-r2` ~ `-r5`,使更新日志与实际 +> 推送一一对应。包版本号本身未回改,历史提交未被重写;从 2.6.1 起恢复 +> 「每次推送提升一个版本号」的约定。 + ## [2.6.0-r1] - 协议覆盖补全与导入/输出保真修复 本轮起因:用户报告「添加本地订阅 → 表单导入」的「类型」下拉框协议不全, diff --git a/luci-app-substore/Makefile b/luci-app-substore/Makefile index 7e32432dd..75b6c8278 100644 --- a/luci-app-substore/Makefile +++ b/luci-app-substore/Makefile @@ -9,8 +9,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-substore # 版本约定:每次提交 PKG_RELEASE +1(2.1.2-r1 ~ r9); # 达到 r10 时 PKG_VERSION 末位 +1(2.1.2 -> 2.1.3),PKG_RELEASE 重置为 1。 -PKG_VERSION:=2.6.0 -PKG_RELEASE:=12 +PKG_VERSION:=2.6.8 +PKG_RELEASE:=13 LUCI_DEPENDS:=+luci-lua-runtime +luci-compat diff --git a/luci-app-substore/README.en.md b/luci-app-substore/README.en.md index ad7435ca5..0fe272435 100644 --- a/luci-app-substore/README.en.md +++ b/luci-app-substore/README.en.md @@ -42,6 +42,16 @@ group them, then re-emit them in a format your client can consume. `listen-port` / `mtu` / `dns`) plus the `amnezia-wg-option` sub-block (Jc / Jmin / Jmax / S1–S4 / H1–H4 / I1–I5 / J1–J3 / Itime); you can paste the contents of a `.conf` file exported by an AmneziaWG client directly +- **Parsing tolerance**: `ssr://` accepts both the standard and the base64url alphabet + for its outer layer; incomplete nodes (missing `server`, or `port` outside 1–65535) + are dropped **at parse time** (otherwise they become `server:` / `port: 0`, which makes + mihomo and sing-box refuse to load the whole file — one bad node kills a subscription); + passwords containing `@` are split on the **last** `@`; sing-box YAML's nested `tls:` + block (including the `alpn` list and `utls.fingerprint`) is fully expanded; wg-quick + `.conf` supports inline `#` comments (matching wg-quick's own splitting semantics), + and a single bad `[Peer]` skips only itself instead of discarding the whole file; + whitespace-only content is treated as an empty subscription rather than reported as + an unrecognised format **Node processing** - Browse nodes, filter by group / protocol, keyword search, sort @@ -50,9 +60,14 @@ group them, then re-emit them in a format your client can consume. - Per-node edit / delete (Actions column); header checkbox selects all, then the Delete button batch-deletes the selection; the Refresh button reloads the list keeping the current filters -- Per-subscription rules applied on every update: +- Per-subscription rules applied on every update (available on all three forms: + subscription / combination / local subscription): - keyword include / exclude (comma-separated, multi-keyword) - - deduplication + - protocol filter (tick the protocols to keep; none ticked = no filtering) + - deduplication (multiple accounts on the same endpoint are not merged: the + dedup key includes each protocol's own credentials) + - rename (one rule per line: `OLD=NEW` exact, `PATTERN -> REPLACEMENT` regex, + `{server}_{port}_{proto}` placeholder template) **Network probing** (Nodes page) - Ping (ICMP latency), TCPing (connect latency), URL test (HTTP latency) @@ -66,6 +81,8 @@ group them, then re-emit them in a format your client can consume. - 15 output formats (all implemented): Plain JSON, Stash, Clash.Meta / Mihomo YAML, Clash (original), Surfboard, Surge, Surge Mac, Loon, Egern, Shadowrocket, Quantumult X, sing-box, V2Ray / Xray, V2Ray URI, WireGuard / AmneziaWG `.conf` + - **Clash.Meta / Mihomo**: emits transport parameters in full (`ws-opts` / `grpc-opts` / + `h2-opts` path, host and service name) plus vless `flow` (XTLS Vision) - **Clash (original)**: for Dreamacro Clash / ClashX / Clash for Windows; protocols the original does not support (vless / hysteria2 / hysteria / tuic / wireguard) are filtered out - **WireGuard / AmneziaWG `.conf`**: wg-quick single-interface config with `[Interface]` / @@ -78,12 +95,31 @@ group them, then re-emit them in a format your client can consume. - V2Ray/Xray: node outbounds + `freedom` (direct) / `blackhole` (block) + `observatory` + `routing.balancers` (`leastPing` auto-selection), with built-in `geoip:private` direct and a catch-all route + - Only Xray-supported protocols are emitted (vmess / vless / trojan / shadowsocks / + socks / http); hysteria2 / hysteria / tuic / wireguard / ssr have no corresponding + outbound type and are filtered out (an unknown `protocol` makes Xray refuse to load + the whole config) - Deliberately **excludes `inbounds` / `dns`**: those bind local listening ports and override your existing DNS settings — keep them in your own config and merge this output into it - ⚠️ Not compatible with 2.3.x: 2.3.x emitted an `outbounds`-only fragment meant to be pasted into an existing config; from 2.4.0 it is a complete config you can start directly as a single file +- **Output validity**: only what the target client can actually load is emitted — not + something that merely looks right + - WireGuard `allowed-ips` / `reserved` / `dns` are always emitted as the arrays the + target client requires (`[]string` / `[]uint8` for both mihomo and sing-box), + whether the source was a YAML list or a comma-separated string; a scalar makes the + client **refuse to load the whole config** + - Surge-family / Quantumult X proxy-group member lists drop node names containing a + **comma**: those formats have no quoting or escaping, so a comma in a name is read as + a member separator, yielding two members that do not exist and making the client + refuse the whole config for referencing unknown proxies; Quantumult X's `[policy]` + additionally lists only nodes that actually got a `[server_local]` line, so no + dangling references remain + - hysteria / hysteria2 share links take `insecure` from the authoritative + `skip-cert-verify` field, so nodes imported from Clash YAML / sing-box JSON / the + form no longer lose "skip certificate verification" **Subscription links** - Per-subscription random token → public download endpoint @@ -96,18 +132,18 @@ group them, then re-emit them in a format your client can consume. ## Installation > The version in the package name must match `PKG_VERSION` / `PKG_RELEASE` in the -> [Makefile](Makefile) (currently `2.6.0-r1`). +> [Makefile](Makefile) (currently `2.6.8-r1`). opkg (OpenWrt / ImmortalWrt 24.10 and earlier): ```bash -opkg install luci-app-substore-2.6.0-r1.ipk +opkg install luci-app-substore-2.6.8-r1.ipk ``` apk (OpenWrt / ImmortalWrt 25.12+): ```bash -apk add --allow-untrusted luci-app-substore-2.6.0-r1.apk +apk add --allow-untrusted luci-app-substore-2.6.8-r1.apk ``` Then open LuCI: **Services → Subscriptions**. @@ -157,6 +193,7 @@ Then open LuCI: **Services → Subscriptions**. - [docs/TESTING.md](docs/TESTING.md) — testing - [docs/UCODE_MIGRATION.md](docs/UCODE_MIGRATION.md) — `.htm` → `.ut` (ucode) migration notes - [CHANGELOG.md](CHANGELOG.md) — changelog +- [docs/LEGACY_ISSUES.md](docs/LEGACY_ISSUES.md) — known unfixed issues (pending decision) ## Building @@ -185,11 +222,14 @@ Target-device verification is required for the LuCI UI and cron behaviour — se ## Security -SSRF protection (private / reserved / link-local ranges rejected), protocol -whitelisting, response size & timeout limits, command-injection defence -(whitelisted parsing + shell quoting), token-based access control on the public -download endpoint, and no credentials in logs. See -[docs/SECURITY.md](docs/SECURITY.md). +SSRF protection (private / reserved / link-local ranges rejected; a hostname that +**fails to resolve is rejected** rather than allowed, so "unresolvable ⇒ pass" is not +a bypass), protocol whitelisting and port range validation (1–65535), response size & +timeout limits, download temp files removed on every exit path (`/tmp` is a tmpfs), +command-injection defence (whitelisted parsing + shell quoting, plus probe targets +starting with `-` rejected — busybox `getopt` would read them as options), +token-based access control on the public download endpoint, and no credentials in +logs. See [docs/SECURITY.md](docs/SECURITY.md). ## License diff --git a/luci-app-substore/README.md b/luci-app-substore/README.md index f079a78a4..8c791cbdd 100644 --- a/luci-app-substore/README.md +++ b/luci-app-substore/README.md @@ -36,15 +36,25 @@ `ip` / `ipv6` / `allowed-ips` / `reserved` / `persistent-keepalive` / `listen-port` / `mtu` / `dns`), 以及 `amnezia-wg-option` 子块(Jc / Jmin / Jmax / S1–S4 / H1–H4 / I1–I5 / J1–J3 / Itime); 可直接粘贴 AmneziaWG 客户端导出的 `.conf` 文件内容 +- **解析容错**:`ssr://` 外层同时接受标准 base64 与 base64url 字母表;缺 `server` + 或 `port` 不在 1–65535 的残缺节点在**解析阶段即丢弃**(否则会被写成 `server:` / + `port: 0`,mihomo 与 sing-box 会拒绝加载整份配置 —— 一个坏节点废掉整个订阅); + 含 `@` 的密码按**最后一个** `@` 切分;sing-box YAML 的嵌套 `tls:` 块 + (含 `alpn` 列表与 `utls.fingerprint`)完整展开;wg-quick `.conf` 支持行内 + `#` 注释(与 wg-quick 自身的切分语义一致),且单个坏 `[Peer]` 只跳过它自己、 + 不作废整份文件;纯空白内容按「空订阅」处理,而非报「无法识别的订阅格式」 **节点处理** - 浏览节点,按分组 / 协议筛选、关键词搜索、排序 - 节点分组:「分组」列单元格内直接修改单节点分组(XHR 无刷新保存),配合「分组:」下拉筛选 - 单节点编辑 / 删除(行尾「操作」列);表头复选框全选、行复选框勾选后点「删除」批量删除; 「刷新」按钮重载列表(保留当前筛选条件) -- 每次更新时生效的按订阅规则: +- 每次更新时生效的按订阅规则(订阅 / 组合 / 本地订阅三种表单均提供): - 关键词包含 / 排除(逗号分隔,支持多关键词) - - 去重 + - 协议筛选(勾选保留哪些协议,全不勾选 = 不筛选) + - 去重(同一入口的多账号不会被误合并:去重键含各协议各自的凭据) + - 重命名(每行一条:`旧名=新名` 精确匹配、`模式 -> 替换` 正则替换、 + `{server}_{port}_{proto}` 占位符模板) **网络探测**(节点页) - Ping(ICMP 延迟)、TCPing(连接延迟)、URL 测试(HTTP 延迟) @@ -58,6 +68,8 @@ - 15 种输出格式(全部实现):Plain JSON、Stash、Clash.Meta / Mihomo YAML、Clash 原版、 Surfboard、Surge、Surge Mac、Loon、Egern、Shadowrocket、Quantumult X、sing-box、 V2Ray / Xray、V2Ray URI、WireGuard / AmneziaWG `.conf` + - **Clash.Meta / Mihomo**:完整输出传输参数(`ws-opts` / `grpc-opts` / `h2-opts` + 的 path、host、服务名)与 vless 的 `flow`(XTLS Vision) - **Clash 原版**:面向 Dreamacro Clash / ClashX / Clash for Windows,自动过滤原版不支持的 协议(vless / hysteria2 / hysteria / tuic / wireguard) - **WireGuard / AmneziaWG `.conf`**:wg-quick 单接口配置,含 `[Interface]` / `[Peer]` 与 @@ -68,10 +80,23 @@ `route.final` 指向 `selector`,内置私网直连规则 - V2Ray/Xray:节点出站 + `freedom`(direct) / `blackhole`(block) + `observatory` + `routing.balancers`(`leastPing` 自动选优),内置 `geoip:private` 直连与兜底分流 + - 仅输出 Xray 支持的协议(vmess / vless / trojan / shadowsocks / socks / http); + hysteria2 / hysteria / tuic / wireguard / ssr 没有对应的 outbound 类型,会被过滤 + (写成 Xray 不认识的 `protocol` 会让它拒绝加载整份配置) - 刻意**不含 `inbounds` / `dns`**:这两项会绑定本地监听端口、覆盖你既有的 DNS 设置, 请在你自己的配置里维护;把本输出合并进已有配置即可 - ⚠️ 与 2.3.x 不兼容:2.3.x 输出的是仅含 `outbounds` 的片段,需要粘进已有配置使用; 2.4.0 起是完整配置,可直接作为单文件配置启动 +- **输出合法性**:只输出目标客户端真正能加载的内容,而不是「看起来像那么回事」 + - WireGuard 的 `allowed-ips` / `reserved` / `dns` 无论来源是 YAML 列表还是逗号分隔 + 字符串,一律输出为目标客户端要求的数组(mihomo 是 `[]string` / `[]uint8`, + sing-box 是 `[]string` / `[]uint8`);写成标量会让客户端**拒绝加载整份配置** + - Surge 家族 / Quantumult X 的策略组成员列表会剔除**含逗号**的节点名:这些格式的 + 成员列表没有引号或转义机制,名字里的逗号会被当成成员分隔符,产出两个都不存在的 + 成员,客户端因「引用不存在的代理」拒绝加载整份配置;Quantumult X 的 `[policy]` + 同时只收录真正写出了 `[server_local]` 行的节点,不再产生悬空引用 + - hysteria / hysteria2 分享链接的 `insecure` 以权威字段 `skip-cert-verify` 为准, + Clash YAML / sing-box JSON / 表单导入的节点不再丢掉「跳过证书校验」 **订阅链接** - 每个订阅独立随机 token → 公开下载端点 @@ -83,18 +108,18 @@ ## 安装 > 包名中的版本号必须与 [Makefile](Makefile) 的 `PKG_VERSION` / `PKG_RELEASE` 保持一致 -> (当前 `2.6.0-r1`)。 +> (当前 `2.6.8-r1`)。 opkg(OpenWrt / ImmortalWrt 24.10 及更早): ```bash -opkg install luci-app-substore-2.6.0-r1.ipk +opkg install luci-app-substore-2.6.8-r1.ipk ``` apk(OpenWrt / ImmortalWrt 25.12+): ```bash -apk add --allow-untrusted luci-app-substore-2.6.0-r1.apk +apk add --allow-untrusted luci-app-substore-2.6.8-r1.apk ``` 然后在 LuCI 菜单打开:**服务 → 订阅**。 @@ -141,6 +166,7 @@ apk add --allow-untrusted luci-app-substore-2.6.0-r1.apk - [docs/TESTING.md](docs/TESTING.md) — 测试 - [docs/UCODE_MIGRATION.md](docs/UCODE_MIGRATION.md) — `.htm` → `.ut`(ucode)迁移说明 - [CHANGELOG.md](CHANGELOG.md) — 更新日志 +- [docs/LEGACY_ISSUES.md](docs/LEGACY_ISSUES.md) — 遗留缺陷汇总(待决定修复方案) ## 构建 @@ -166,8 +192,11 @@ LuCI 界面与 cron 行为仍需在目标设备上验证 —— 见 [docs/TESTIN ## 安全 -SSRF 防护(拒绝内网 / 保留 / 链路本地地址)、协议白名单、响应体大小与超时限制、 -命令注入防护(白名单解析 + shell 引用)、公开下载端点基于 token 的访问控制、日志不含凭据。 +SSRF 防护(拒绝内网 / 保留 / 链路本地地址;**DNS 解析失败即拒绝**,不给 +「解析不出来就放行」留绕过口)、协议白名单与端口范围校验(1–65535)、 +响应体大小与超时限制、下载临时文件在每条退出路径上清理(`/tmp` 是 tmpfs)、 +命令注入防护(白名单解析 + shell 引用 + 探测目标拒绝以 `-` 开头的主机名)、 +公开下载端点基于 token 的访问控制、日志不含凭据。 详见 [docs/SECURITY.md](docs/SECURITY.md)。 ## 许可证 diff --git a/luci-app-substore/docs/INSTALL.md b/luci-app-substore/docs/INSTALL.md index 8f706f07b..5ef9c319c 100644 --- a/luci-app-substore/docs/INSTALL.md +++ b/luci-app-substore/docs/INSTALL.md @@ -3,18 +3,18 @@ > NOTE: stage-0 skeleton has not yet been installed/verified on a target device. > This document will be updated once installation is validated. > -> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.6.0-r1)。 +> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.6.8-r1)。 ## Install (opkg — OpenWrt / ImmortalWrt 24.10 及更早) ```bash -opkg install luci-app-substore-2.6.0-r1.ipk +opkg install luci-app-substore-2.6.8-r1.ipk ``` ## Install (apk — OpenWrt / ImmortalWrt 25.12+) ```bash -apk add --allow-untrusted luci-app-substore-2.6.0-r1.apk +apk add --allow-untrusted luci-app-substore-2.6.8-r1.apk ``` Refresh LuCI: diff --git a/luci-app-substore/docs/LEGACY_ISSUES.md b/luci-app-substore/docs/LEGACY_ISSUES.md new file mode 100644 index 000000000..1a44ae12e --- /dev/null +++ b/luci-app-substore/docs/LEGACY_ISSUES.md @@ -0,0 +1,287 @@ +# 遗留缺陷与已知限制汇总(待决定修复方案) + +**决策时点**:P0 + P1 + P2 全部修复工作完成后统一决定。本文件只记录**不在 P2 +范围内**、需要另行决策的项。 + +**当前进度**: + +| 批次 | 范围 | 状态 | +|---|---|---| +| P0 | 14 项高危(输出整份不可用 / 数据永久丢失 / 安全) | ✅ 已完成(4 次推送,见 CHANGELOG `[2.6.0-r2]`~`[2.6.0-r5]`) | +| P1 | 13 项中危 + 审计中实测确认的 4 项新缺陷 | ✅ 已完成(`[2.6.1-r1]`) | +| P2 | 其余 M / L 项 | 🔄 进行中(批次一 `[2.6.3-r1]`、批次二 `[2.6.4-r1]`、批次三 `[2.6.5-r1]`、批次四 `[2.6.6-r1]`、批次五 `[2.6.7-r1]`、批次六 `[2.6.8-r1]`) | + +**编写纪律**:只记录经**代码级审计或实测探针**确认的结论,不含推测。原始 70 项 +审计表(高危 14 / 中危 30 / 低危 26)未落盘,本文件自本次起作为「未修复项」的 +权威清单。已修复项见 [CHANGELOG.md](../CHANGELOG.md)。 + +--- + +# 一、本轮(P1 之后)确认的遗留项 + +## 1.1 H10 的「无锁」部分 —— `load()` → `save()` 丢更新 + +**已修的部分**:H10 的另一半(临时文件名固定为 `path..".tmp"` 被并发写者共用) +已在 P0 批次三修复,改为 `path..".tmp.<8 字节随机 hex>"`。 + +**未修的部分**:读-改-写全程仍无互斥。 + +**代码级依据**: + +- `core.lua` 的 `local function load()` 读出整表,`local function save(seq, items)` + 整表写回,中间无锁;`M.add` / `M.add_local` / `M.add_combo` / `M.save_meta` / + `M.save_combo` 均走这条路径。 +- `util.lua` 中**不存在任何锁原语**(无 `flock`、无 `mkdir` 锁、无 `link()`)。 +- `nixio` 只用于 `http.lua:208-210` 的 `getaddrinfo`(`util.try_require` 可选加载), + `Makefile` 的 `LUCI_DEPENDS` 未声明 `+nixio`。 + +**并发来源**:LuCI 页面保存订阅、cron 定时更新、组合订阅自动重算,三者可能同时发生。 + +**影响**:后写者整表覆盖先写者 → 订阅丢失或新增丢失。与已修复的 H8 同属「数据被 +抹除」,但触发条件是并发而非文件损坏,H8 的修复**不覆盖**本项。 + +**候选方案**: + +| 方案 | 做法 | 代价 / 风险 | +|---|---|---| +| A. 记为已知限制 | 文档说明「同一时间只应有一个写操作」 | 零成本;用户无从感知,仍会丢数据 | +| B. 引入 `nixio` flock | `Makefile` 加 `+nixio`,写路径加文件锁 | 最可靠;新增运行时依赖,本机**无法验证** | +| C. `mkdir` 锁 + 陈旧锁回收 | 目录创建做互斥,带超时与持有者 PID 回收 | 纯 Lua 无依赖;需正确处理崩溃残留锁,否则死锁 | +| D. 写路径单点串行化 | 所有写入收敛到同一入口 | 跨进程串行仍需 OS 原语,实际仍要 B 或 C | + +## 1.2 M28 / M29 —— CSRF `post_ok()` 与 ACL + +**状态**:**已决定跳过验证**(用户 2026-09-30 指示)。 + +**原因**:二者依赖 LuCI 框架运行时行为(`test_post_security`、ACL 解析),本机没有 +LuCI 运行环境,无法做代码级确认。按「不靠猜」的纪律,未做改动。 + +**审计结论(未做运行时验证,置信度有限)**: + +- M28:`substore.lua:40-44` 的 `post_ok()` 只判 `formvalue("token") ~= nil`(空串也过), + 且 `entry` 均未声明 `post` → 框架的 `test_post_security` 从未执行。缓解因素是 + Cookie `SameSite=strict`。 +- M29:`menu.d/luci-app-substore.json` 无 `depends.acl`、`entry` 无 `acl_depends`、 + 仓库内无 rpcd ACL 文件 → 任意已登录 LuCI 用户可读写全部订阅(含凭据 URL)。 + +**建议**:在目标设备上以真实 LuCI 环境复核后再决定。 + +## 1.3 sing-box 读取侧:transport 整层丢失(实测确认) + +**现象**:sing-box 配置中 ws / grpc / h2 的传输参数在导入后**完全丢失**,节点退化 +为 tcp 直连。 + +**实测探针**(`parser.parse`,vmess + `transport: {type: ws, path: /ws, headers.Host}`): + +``` +JSON -> proto=vmess net=tcp sni=a.com fp=nil path=nil host=nil +YAML -> proto=vmess net=tcp sni=a.com fp=chrome path=nil host=nil +``` + +**代码级依据**: + +- `parser_json_config.lua`(`parse_singbox_json`,314 行)全文**不含** + `transport` / `path` / `host` / `utls` 任何一处。它读的是 `outbound.network`, + 而 sing-box 表达传输方式用的是 `transport.type` —— 该字段在 sing-box 出站里 + 并不存在,因此 `net` 恒为默认值 `tcp`。 +- 简易 YAML 解析器(`parser.lua`)只展开 `tls:` 子块,同样不处理 `transport`。 + +**影响**:ws / grpc / h2 节点在机场导出的 sing-box 配置里占相当比例,导入后全部按 +tcp 直连,握手必然失败,且**不报错**。比原记录(「不读 path/host」)更严重:连传输 +类型本身都没读进来。 + +**附带不一致**:`tls.utls.fingerprint` 在 YAML 侧**已读**(`fp=chrome`,P1 修复), +JSON 侧**不读**(`fp=nil`)。 + +**候选方案**:在 `parse_singbox_json` 中补 `transport.type` → `net`、 +`transport.path` → `path`、`transport.headers.Host` → `host`、 +`tls.utls.fingerprint` → `fp`;并让简易 YAML 侧同样处理 `transport`。 +**需先核实 sing-box 各 transport 的权威字段名(ws / grpc / http),不凭记忆。** + +## 1.4 `converter.lua` / `node_converter.lua` 为死代码,README 声明与实现不符 + +(即审计表 L2) + +**代码级依据**: + +- `root/` 下**没有任何文件** `require` 这两个模块 —— 唯一的引用是 + `converter.lua:7` 引用 `node_converter.lua`,以及 `tests/` 下的单元测试。 +- LuCI controller 与前端 JS 中**不存在**「转换」相关的路由或 UI。 +- 模块实现本身也有问题:禁止 shadowsocks 转换;`hysteria2/tuic/wireguard → + "sing-box"/"clash"` 产出**非法 proto**;`node_converter.convert` 因 `network` / + `fingerprint` 字段名不匹配丢 `net`/`path`/`host`/`fp`;`core.generate_link` 不传 + opts → `options.proto` 永不被设置。 + +**README 声明**:`README.md:59`「协议转换:任意协议 → 任意协议」、 +`README.en.md:68`「Protocol conversion: any node type → any other type」; +紧接其后的 `README.md:60-62` 又说明「SSR 不能与 vmess/vless 等其它协议互转」, +与上一行自相矛盾。 + +**候选方案**:A. 接入 UI(需先定义「哪些协议可互转」的权威矩阵并审计现有映射表); +B. 删除两个模块与测试,修正 README 两处声明。 + +--- + +## 1.5 Quantumult X:节点名含逗号时 `tag=` 字段本身被写坏(**待核实**) + +**发现于**:P2 批次三修复 L21 时顺带发现。L21 只处理了**成员列表**(Surge 家族 +`[Proxy Group]` 与 QX `[policy]`),已修复;本条是同一根因在 QX **定义行**上的 +另一处表现。 + +**实测(已验证的部分)**:`output_formats.lua` 的 `to_qx` 用 +`string.format("trojan=%s, password=%s, over-tls=true, tag=%s", …)` 拼行, +`tag` 直接取自 `n.name`。节点名 `A,B` 经 `util.one_line` 后仍含逗号, +输出为: + +``` +trojan=1.2.3.4:443, password=p, over-tls=true, tag=A,B +``` + +`[server_local]` 行内是逗号分隔的 `key=value` 字段序列,名字里的逗号落在 +`tag=` 的值里,按该语法会被读成字段分隔符。 + +**未验证的部分(不猜)**:QX 对这种「最后一个字段值里多出逗号」的行究竟是 +报错、忽略多余片段,还是原样接受,**本机没有 Quantumult X 可供实测**。 +因此本条按「待核实」记录,未作修改。 + +**候选方案**(三选一,需先核实 QX 实际行为再定): + +- **A. 改名**:输出时把名字里的 `,` 替换为安全字符。节点不丢失,但导出名与 + LuCI 中显示的名字不一致;且 `A,B` 与 `A_B` 两个节点会撞名(Surge / QX 对 + 重名代理的处理需另行核实)。 +- **B. 丢弃**:把含逗号名字的节点从 QX 输出中整体剔除(连定义行一起)。 + 与「Surge 家族丢弃 wireguard / ssr、Clash 原版丢弃不支持的协议」的既有约定 + 一致,产出必定合法;代价是节点静默消失。 +- **C. 维持现状**:仅在文档中说明「QX 输出请勿使用含逗号的节点名」。 + +**注**:Surge 家族的 `[Proxy]` 定义行是 `NAME = type, host, port, …`, +名字在 `=` **左侧**,逗号大概率不影响解析(未实测),故本条**只针对 QX**。 + +--- + +# 二、P0 时代的遗留项(2.6.0 审计时记录) + +来源:CHANGELOG `[2.6.0-r1]` 的「已知限制(本轮不修,均有明确原因)」。以下逐条 +复核过当前代码,均**仍然成立**。 + +## 2.1 M11 混合格式文本导入不支持 + +**依据**:`parse_local → parse → detect` 只识别**一种**格式,其余部分被静默丢弃 +(实测:「URI + WG conf」只剩 WG 节点;「URI + JSON」只剩 URI 节点)。 + +**性质**:**功能缺失**而非小缺陷,需要重新设计 parser 的分段架构(规格 §22 明确 +警告不要直接采用未经验证的逐行算法)。 + +**候选方案**:A. 设计分段架构后实现(工作量大,属 minor 版本特性); +B. 在 README / UI 明确「一次只支持一种格式」(README 已如实说明,但 UI 未提示)。 + +## 2.2 sing-box 的 hysteria(v1) 出站还要求 `up` / `down`(带宽) + +**依据**:`output_singbox.lua:127` 有明确注释说明;本项目的节点模型不承载该字段, +凭空填默认值属于猜测,故不输出。 + +**影响**:导出到 sing-box 的 hysteria v1 节点需用户自行补 `up`/`down`。 + +**候选方案**:A. 保持现状(文档说明);B. 在节点模型中增加 `up`/`down` 字段并贯通 +解析 / 表单 / 输出(属新功能)。 + +## 2.3 hysteria(v1) 的上游 URI 规范未能核实 + +**依据**:上游文档站点持续 404,无法取得权威定义。`parse_hysteria` 只保证解析本 +项目 `output_uri` 自身生成的链接形态(回环已验证),认不出的查询参数一律忽略而不 +报错,不臆造参数语义。 + +**候选方案**:待上游文档可访问后核实并补全;或维持「回环保证」的现状。 + +## 2.4 `http` 不加入任何 UI 协议列表 + +**依据**:规格 §25 的权威协议表恰好是 10 个协议、不含 `http`。`node.lua` 的 +`M.PROTOS` 实测为 10 项(vmess / vless / trojan / shadowsocks / ssr / hysteria2 / +tuic / hysteria / wireguard / socks),确实不含 `http`。它可由 Clash YAML / JSON +配置导入并正常导出(凭据已修),但不作为表单可选项。 + +**候选方案**:A. 维持现状(`http` 属「可导入可导出但不可手录」);B. 加入 UI 列表 +(需先确认规格 §25 是否有意排除)。 + +## 2.5 `parser_yaml.lua` 为死代码 + +**依据**:`parser.lua:6` `require` 了它,但全文对 `parser_yaml.` 的调用次数实测为 +**0**。其中留有同样未归一的 `socks5` 映射。 + +**影响**:不影响运行,属清理项。 + +**候选方案**:A. 删除文件与 `require`;B. 保留(无害)。 + +## 2.6 AmneziaWG 3.0 / 3.1 新增的 9 个字段不被 `.conf` 解析器接受 + +**字段**:`HeaderProtectionKey`、`ContentPaddingAddition`、`RekeyAfterTime`、 +`RekeyTimeout`、`RejectAfterTime`、`KeepaliveTimeout`、`MaxHandshakeAttempts`、 +`RandomTrailers`、`DisableCookies`。 + +**依据**:实测这 9 个名字在 `parser.lua` 中**一处都不出现** —— 即 `.conf` 解析器 +按已知键白名单映射,这 9 个键落在白名单外,**导入即丢弃**;但它们会从 Clash / JSON / +`wireguard://` 导入路径原样透传。 + +**影响**:同一节点经 `.conf` 导入与经 JSON 导入,得到的字段集**不一致**。 + +**候选方案**:A. 核实 AmneziaWG 上游对这 9 个键的权威语义后补入白名单(不猜语义); +B. 保持白名单策略,在文档说明差异。 + +--- + +# 三、更早的遗留项(2.5.x 记录,一并归档) + +来源:CHANGELOG `[2.5.1-r1]` / `[2.5.0-r1]` 的「已知限制」。均为**已确认、未修复**。 + +## 3.1 LuCI 里 `amnezia-wg-option` 仍是单个 JSON 文本框 + +**依据**:`node.lua` 的 `PROTO_FIELDS.wireguard` 中 `amnezia-wg-option` 是**单个** +字段(实测该数组共 13 项,`amnezia-wg-option` 占其一),因此前端渲染成一个文本框, +需用户手写 JSON。数据本身能正确往返,但录入体验不佳。 + +**候选方案**:逐字段化(属新功能,按版本约定应进入 minor 版本)。 + +## 3.2 wget 后端的下载体积上限是**下载后**判断 + +**依据**:busybox wget 没有「下载前限流」的选项。响应体虽被丢弃,但请求已经发出。 + +**候选方案**:改用 curl(若有)或维持现状并在文档说明。 + +## 3.3 改名规则不支持括号内的「或」`(a|b)` + +**依据**:Lua 模式无 alternation 语义,按字面处理。 + +**候选方案**:A. 维持现状并文档说明;B. 实现一个简单的 alternation 展开。 + +## 3.4 `check_public` 在无 DNS 解析能力时放行(fail-open) + +**依据**:`http.lua` 实测代码为: + +```lua +local ips = resolve(host) +if not ips then + -- 无 DNS 解析能力:放行,交由下载工具处理(尽力而为) + return true +end +``` + +即无 `nixio` 时任意主机名放行。另存在解析与下载之间的 TOCTOU 窗口。 + +**候选方案**:A. 改为 fail-closed(可能误伤无 DNS 环境);B. 维持现状并文档说明; +C. 引入 `nixio` 作为必需依赖(与 1.1 的锁方案可一并考虑)。 + +## 3.5 wget 路径的重定向校验是**事后**的 + +**依据**:busybox wget 无 `--max-redirect`;响应体虽被丢弃但请求已经发出,且无法 +限制下载体积。 + +**候选方案**:与 3.2 一并处理(改用 curl 或自实现 HTTP 客户端)。 + +--- + +# 附:P2 修复范围(不含本文件所列项) + +P2 为审计表中**其余中危 / 低危**项中性质明确、无需另行决策的缺陷,例如 +M6 / M9 / M13 / M14 / M16 / M17 / M18 / M19 / M23 / M24 / M25 / M26 / M27 / M30 +与 L1 / L3~L6 / L8~L26 等。逐项修复并补回归测试,完成后在 CHANGELOG 中记录。 +本文件所列各项**不在** P2 范围内。 diff --git a/luci-app-substore/docs/SECURITY.md b/luci-app-substore/docs/SECURITY.md index 6eeedd9ac..2d11e13be 100644 --- a/luci-app-substore/docs/SECURITY.md +++ b/luci-app-substore/docs/SECURITY.md @@ -3,17 +3,28 @@ ## SSRF 防护 - `http.lua` 下载前解析主机名,拒绝 localhost/内网/链路本地/保留地址 - DNS 解析后二次校验,处理重定向 -- 仅允许 http/https 协议 +- 仅允许 http/https 协议;端口限定 1–65535 +- **解析失败即拒绝(fail-closed)**:主机名解析不出 IP 时不放行。 + 检查的强度不能低于被检查者 —— `127.0.0.1.nip.io` 这类「公网可解析到内网」的域名, + 若在本机解析失败的那一刻被放行,curl 自己仍会把它解析到 127.0.0.1 并连上去。 + 代价为零:本包依赖 `luci-lua-runtime`,后者硬依赖 `luci-lib-nixio`, + 所以解析失败只意味着「真的解析不了」,此时下载本来也会失败 ## 资源限制 - 订阅响应体最大 10MB,可配置 - 连接/总超时 20s - 并发限制由系统调度 +- 下载临时文件(`.tmp` / `.tmp.hdr` / `.tmp.err`)在**每一条**退出路径上清理。 + OpenWrt 的 `/tmp` 是 tmpfs,占的是内存;失败路径不清理会让 cron 定时重试 + 一轮轮往内存里堆文件,其中 `.tmp` 可能是最大到 `max-size` 的部分响应体 ## 输入校验 - 订阅名/文件名白名单校验,禁止 `..`、`/` - 解析时限制结构/大小/嵌套深度,防 YAML/JSON 资源耗尽 - URL 参数解码后二次校验 +- 探测目标主机名拒绝以 `-` 开头:`probe.lua` 的命令形如 `ping -c 1 -W 2 `, + 而 `--help` / `-c` 会被 busybox 的 getopt 当成**选项**。`util.shq` 的单引号由 + shell 剥掉,getopt 看到的仍是 `-x`,引号挡不住这一层,必须在取值时就拒绝 ## 凭据保护 - 订阅 URL 中的 token 不写入普通日志 diff --git a/luci-app-substore/po/zh-cn/substore.po b/luci-app-substore/po/zh-cn/substore.po index 6e33b1356..2e62d4bf4 100644 --- a/luci-app-substore/po/zh-cn/substore.po +++ b/luci-app-substore/po/zh-cn/substore.po @@ -248,6 +248,18 @@ msgstr "关键词排除" msgid "Dedup" msgstr "去重" +msgid "Protocol filter" +msgstr "协议筛选" + +msgid "Protocol filter: with none ticked no filtering is applied." +msgstr "协议筛选:全部不勾选 = 不筛选(保留所有协议)。" + +msgid "Rename" +msgstr "重命名" + +msgid "One rename rule per line: OLD=NEW (exact), PATTERN -> REPLACEMENT (regex), or {server}_{port}_{proto} (placeholder template)." +msgstr "重命名每行一条:旧名称=新名称(精确匹配)、模式 -> 替换(正则替换)、{server}_{port}_{proto}(占位符模板)。" + # 节点分组 / 单节点编辑 msgid "Group" msgstr "分组" diff --git a/luci-app-substore/root/usr/bin/substore-cron.sh b/luci-app-substore/root/usr/bin/substore-cron.sh index effbf0ba5..16720720a 100755 --- a/luci-app-substore/root/usr/bin/substore-cron.sh +++ b/luci-app-substore/root/usr/bin/substore-cron.sh @@ -69,6 +69,14 @@ fi # 不存在前导零,故可直接按字面比较。 FAILED=$(printf '%s\n' "$OUT" | sed -n 's/.*: [0-9][0-9]* ok, \([0-9][0-9]*\) failed.*/\1/p' | tail -1) case "$FAILED" in - ''|0) exit 0 ;; + '') + # 没有结果行 ≠「0 个失败」,而是 Lua 根本没跑到最后:模块加载失败、 + # core.list() 抛异常(它在 pcall 之外)、解释器中途死掉等等。 + # 旧实现把这种情况和成功归为一类(''|0) exit 0),于是一次彻底失败的 + # 更新在 cron / 外部监控看来与成功无异 —— 故障永远不会被发现。 + echo "substore cron: no result line, lua did not complete" >&2 + exit 1 + ;; + 0) exit 0 ;; *) exit 1 ;; esac diff --git a/luci-app-substore/root/usr/lib/lua/luci/controller/admin/substore.lua b/luci-app-substore/root/usr/lib/lua/luci/controller/admin/substore.lua index 8f93927fb..a278bb767 100644 --- a/luci-app-substore/root/usr/lib/lua/luci/controller/admin/substore.lua +++ b/luci-app-substore/root/usr/lib/lua/luci/controller/admin/substore.lua @@ -37,22 +37,50 @@ function index() entry({"substore", "download"}, call("action_download"), nil) end +-- 表单字段统一取字符串。 +-- +-- LuCI 的 formvalue **不保证返回字符串**:同名字段提交多次时返回的是 table。 +-- 依据上游 luci/http.lua 的 urldecode_message_body: +-- elseif what == parser.VALUE and name then +-- local val = msg.params[name] +-- if type(val) == "table" then val[#val+1] = ... +-- elseif val ~= nil then msg.params[name] = { val, ... } -- ← 第二次出现变成 table +-- 而 formvalue 原样返回 msg.params[name];上游 luadoc 也写着 +-- "@return HTTP input value or table of all input value"。 +-- +-- 本项目多处直接对返回值 :gsub / util.trim / urlencode,遇到 table 会抛 +-- "attempt to index a table value" → HTTP 500。攻击面不限于已登录用户: +-- /substore/download 是**无需登录**的入口(供 Passwall / OpenClash 拉取), +-- 对它 POST 一个重复的 token 字段即可触发。统一收敛后这类输入不再崩溃。 +-- +-- 取值语义:table 取最后一个(与「同名参数后者覆盖前者」的直觉一致), +-- nil 保持 nil(post_ok 依赖这一点区分「无 token」与「空 token」)。 +-- +-- 注意:必须定义在 post_ok **之前** —— Lua 的 local function 只捕获定义时 +-- 已可见的局部变量,写在后面会让 post_ok 里的 fv 落到全局(nil)。 +local function fv(http, key) + local v = http.formvalue(key) + if type(v) == "table" then v = v[#v] end + if v == nil then return nil end + return tostring(v) +end + local function post_ok() -- 拒绝缺失 CSRF token 的 POST local http = require("luci.http") - return http.formvalue("token") ~= nil + return fv(http, "token") ~= nil end -- read cron fields from form, validate cron expression, return cron_enable cron_time local function read_cron_fields() local http = require("luci.http") local core = require("substore.core") - local cron_enable = http.formvalue("cron_enable") or "0" - local m = http.formvalue("cron_min") or "0" - local h = http.formvalue("cron_hour") or "3" - local dom = http.formvalue("cron_dom") or "*" - local mon = http.formvalue("cron_mon") or "*" - local dow = http.formvalue("cron_dow") or "*" + local cron_enable = fv(http, "cron_enable") or "0" + local m = fv(http, "cron_min") or "0" + local h = fv(http, "cron_hour") or "3" + local dom = fv(http, "cron_dom") or "*" + local mon = fv(http, "cron_mon") or "*" + local dow = fv(http, "cron_dow") or "*" local cron_time = table.concat({m,h,dom,mon,dow}, " ") if not core.cron_time_valid(cron_time) then cron_enable = "0" @@ -65,19 +93,21 @@ end -- read subscription level rules from form, return rules table local function read_rules_fields() local http = require("luci.http") + local core = require("substore.core") local proto_list = {} - for _, p in ipairs({"vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","hysteria","wireguard","socks"}) do - if http.formvalue("proto_filter_"..p) then proto_list[#proto_list+1]=p end + -- 协议清单来自 core.RULE_PROTOS,与表单模板共用一份(见该常量的说明) + for _, p in ipairs(core.RULE_PROTOS) do + if fv(http, "proto_filter_"..p) then proto_list[#proto_list+1]=p end end - local rules_enable = http.formvalue("rules_enable") or "0" + local rules_enable = fv(http, "rules_enable") or "0" if rules_enable ~= "1" then rules_enable = "0" end return { rules_enable = rules_enable, proto_filter = table.concat(proto_list, ","), - keyword_include = http.formvalue("keyword_include") or "", - keyword_exclude = http.formvalue("keyword_exclude") or "", - dedup = http.formvalue("dedup") or "0", - rename_map = http.formvalue("rename_map") or "", + keyword_include = fv(http, "keyword_include") or "", + keyword_exclude = fv(http, "keyword_exclude") or "", + dedup = fv(http, "dedup") or "0", + rename_map = fv(http, "rename_map") or "", } end @@ -85,11 +115,11 @@ function action_create() local http = require("luci.http") local core = require("substore.core") if post_ok() then - local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "") - local url = (http.formvalue("url") or ""):gsub("^%s+", ""):gsub("%s+$", "") - local proxy_enable = http.formvalue("proxy_enable") or "0" + local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "") + local url = (fv(http, "url") or ""):gsub("^%s+", ""):gsub("%s+$", "") + local proxy_enable = fv(http, "proxy_enable") or "0" if proxy_enable ~= "1" then proxy_enable = "0" end - local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "") + local proxy = (fv(http, "proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "") if name == "" or url == "" then return back_to_list("名称和 URL 不能为空") end @@ -112,12 +142,12 @@ function action_save() local http = require("luci.http") local core = require("substore.core") if post_ok() then - local id = http.formvalue("id") or "" - local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "") - local url = (http.formvalue("url") or ""):gsub("^%s+", ""):gsub("%s+$", "") - local proxy_enable = http.formvalue("proxy_enable") or "0" + local id = fv(http, "id") or "" + local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "") + local url = (fv(http, "url") or ""):gsub("^%s+", ""):gsub("%s+$", "") + local proxy_enable = fv(http, "proxy_enable") or "0" if proxy_enable ~= "1" then proxy_enable = "0" end - local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "") + local proxy = (fv(http, "proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "") if id == "" then return back_to_list("缺少订阅 ID") end if name == "" or url == "" then return back_to_list("名称和 URL 不能为空") @@ -141,9 +171,9 @@ function action_local_create() local http = require("luci.http") local core = require("substore.core") if post_ok() then - local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "") - local content = http.formvalue("content") or "" - local local_mode = http.formvalue("local_mode") or "text" + local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "") + local content = fv(http, "content") or "" + local local_mode = fv(http, "local_mode") or "text" local rules = read_rules_fields() -- §19:空名称 / 空内容必须明确报错,不能无声创建无效订阅 if name == "" then return back_to_list("名称不能为空") end @@ -164,10 +194,10 @@ function action_local_save() local http = require("luci.http") local core = require("substore.core") if post_ok() then - local id = http.formvalue("id") or "" - local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "") - local content = http.formvalue("content") or "" - local local_mode = http.formvalue("local_mode") or "text" + local id = fv(http, "id") or "" + local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "") + local content = fv(http, "content") or "" + local local_mode = fv(http, "local_mode") or "text" local rules = read_rules_fields() if id == "" then return back_to_list("缺少订阅 ID") end if name == "" then return back_to_list("名称不能为空") end @@ -194,7 +224,11 @@ function action_delete() local http = require("luci.http") local core = require("substore.core") if post_ok() then - core.remove(http.formvalue("id") or "") + -- §18:删除失败必须让用户看见。core.remove 对「非法 ID / 订阅不存在」 + -- 返回 false(或 false, err),此前返回值被整个丢弃 —— 用户点了删除, + -- 页面正常跳回,订阅却还在,看起来像「删了但没生效」。 + local ok, err = core.remove(fv(http, "id") or "") + if not ok then return back_to_list(err or "删除失败:订阅不存在") end core.write_cron() end back_to_list() @@ -204,10 +238,10 @@ end -- 返回节点页。err 非空时把失败原因带到页面显示(§18:失败必须让用户看见, -- 不能「失败了却看起来像成功」)。与 back_to_list 同一套约定。 local function back_to_nodes(http, err) - local id = http.formvalue("id") or "" + local id = fv(http, "id") or "" local qs = "?id=" .. luci.util.urlencode(id) for _, k in ipairs({ "proto", "keyword", "group", "sort", "desc" }) do - local v = http.formvalue(k) + local v = fv(http, k) if v and v ~= "" then qs = qs .. "&" .. k .. "=" .. luci.util.urlencode(v) end end if err ~= nil and tostring(err) ~= "" then @@ -222,9 +256,9 @@ function action_node_save() local core = require("substore.core") local parser = require("substore.parser") if post_ok() then - local id = http.formvalue("id") or "" - local idx = tonumber(http.formvalue("idx") or "") - local content = http.formvalue("content") or "" + local id = fv(http, "id") or "" + local idx = tonumber(fv(http, "idx") or "") + local content = fv(http, "content") or "" local nodes = core.read_nodes(id) -- §18:任何一条失败路径都必须把原因带回页面。 -- 原来只在成功分支里做事,其余情况一律静默重定向, @@ -258,14 +292,18 @@ function action_node_delete() local http = require("luci.http") local core = require("substore.core") if post_ok() then - local id = http.formvalue("id") or "" - local idx_param = http.formvalue("idx") or "" - if type(idx_param) == "table" then idx_param = table.concat(idx_param, ",") end + local id = fv(http, "id") or "" + local idx_param = fv(http, "idx") or "" local nodes = core.read_nodes(id) local idxs = {} for s in tostring(idx_param):gmatch("%d+") do idxs[#idxs + 1] = tonumber(s) end + -- §18:什么都没删掉时必须说清楚,不能静默跳回列表。 + if #idxs == 0 then + back_to_nodes(http, "未指定要删除的节点") + return + end -- 倒序删除,避免 table.remove 后下标偏移 table.sort(idxs, function(a, b) return a > b end) local removed = false @@ -275,10 +313,16 @@ function action_node_delete() removed = true end end - if removed and core.write_nodes(id, nodes) then - core.save_meta(id, { node_count = #nodes }) - core.refresh_combos(id) + if not removed then + back_to_nodes(http, "节点不存在或已被删除") + return end + if not core.write_nodes(id, nodes) then + back_to_nodes(http, "写入节点数据失败") + return + end + core.save_meta(id, { node_count = #nodes }) + core.refresh_combos(id) end back_to_nodes(http) end @@ -293,9 +337,9 @@ function action_node_set_group() http.write(util.json_encode({ ok = false, err = "forbidden" })) return end - local id = http.formvalue("id") or "" - local idx = tonumber(http.formvalue("idx") or "") - local group = util.trim(http.formvalue("group") or "") + local id = fv(http, "id") or "" + local idx = tonumber(fv(http, "idx") or "") + local group = util.trim(fv(http, "group") or "") local nodes = core.read_nodes(id) if not idx or not nodes[idx] then http.write(util.json_encode({ ok = false, err = "node not found" })) @@ -315,27 +359,32 @@ function action_combo_save() local core = require("substore.core") local util = require("substore.util") if post_ok() then - local id = util.trim(http.formvalue("id") or "") - local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "") + local id = util.trim(fv(http, "id") or "") + local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "") local sources = {} for _, it in ipairs(core.list()) do - if not it.combo and http.formvalue("src_" .. it.id) then + if not it.combo and fv(http, "src_" .. it.id) then sources[#sources + 1] = it.id end end - if name ~= "" then - local rules = read_rules_fields() - local o = { - rules_enable = rules.rules_enable, proto_filter = rules.proto_filter, - keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude, - dedup = rules.dedup, - } - if id == "" then - core.add_combo(name, sources, o) - else - core.save_combo(id, name, sources, o) - end + -- §18:此前名称留空、或一个来源都没勾选时,这里整段跳过、直接跳回列表 —— + -- 用户填了表单却什么都没发生,页面上也没有任何提示。 + if name == "" then return back_to_list("名称不能为空") end + local rules = read_rules_fields() + local o = { + rules_enable = rules.rules_enable, proto_filter = rules.proto_filter, + keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude, + dedup = rules.dedup, + } + -- 返回值此前被整个丢弃:add_combo / save_combo 的失败(非法 ID、 + -- 未选来源、写入失败)一律静默。 + local nid, err + if id == "" then + nid, err = core.add_combo(name, sources, o) + else + nid, err = core.save_combo(id, name, sources, o) end + if not nid then return back_to_list(err or "保存组合订阅失败") end end back_to_list() end @@ -344,12 +393,15 @@ function action_update() local http = require("luci.http") local core = require("substore.core") if post_ok() then - local id = http.formvalue("id") or "" + local id = fv(http, "id") or "" local meta = core.get(id) if meta and meta["local"] then -- local subscription does not support auto update return end + -- §18:订阅不存在(ID 拼错 / 已被删除)时此前静默跳回列表, + -- 用户点了「更新」却看不到任何反馈。 + if not meta then return back_to_list("订阅不存在") end -- pcall 只保证「不抛异常」;core.sync 的失败是「返回 nil, err」而非抛错, -- 因此必须同时检查两层结果,否则失败永远不会写回列表状态。 local ok, res, err = pcall(core.sync, id) @@ -377,8 +429,8 @@ function action_probe() http.write("invalid token") return end - local id = util.trim(http.formvalue("id") or "") - local mode = util.trim(http.formvalue("mode") or "") + local id = util.trim(fv(http, "id") or "") + local mode = util.trim(fv(http, "mode") or "") if mode ~= "ping" and mode ~= "tcping" and mode ~= "url" then http.status(400, "Bad Request") http.prepare_content("text/plain; charset=utf-8") @@ -394,8 +446,8 @@ function action_probe() end -- 与 nodes 页一致的过滤:按当前 proto / keyword 过滤后逐个探测 local nodes = core.read_nodes(id) - local proto = util.trim(http.formvalue("proto") or "") - local keyword = util.trim(http.formvalue("keyword") or "") + local proto = util.trim(fv(http, "proto") or "") + local keyword = util.trim(fv(http, "keyword") or "") if proto ~= "" then nodes = node.filter(nodes, { proto = proto }) end if keyword ~= "" then nodes = node.filter(nodes, { keyword = keyword }) end nodes = node.sort(nodes, "name", false) @@ -421,8 +473,8 @@ function action_download() local http = require("luci.http") local core = require("substore.core") local util = require("substore.util") - local token = util.trim(http.formvalue("token") or "") - local target = util.trim(http.formvalue("target") or "ClashMeta") + local token = util.trim(fv(http, "token") or "") + local target = util.trim(fv(http, "target") or "ClashMeta") local content, ct, filename, err = core.generate_link(token, target) if not content then diff --git a/luci-app-substore/root/usr/lib/lua/luci/view/substore/combo.htm b/luci-app-substore/root/usr/lib/lua/luci/view/substore/combo.htm index 2f7c0dfb8..6faa0c0bc 100644 --- a/luci-app-substore/root/usr/lib/lua/luci/view/substore/combo.htm +++ b/luci-app-substore/root/usr/lib/lua/luci/view/substore/combo.htm @@ -9,6 +9,13 @@ local ki = (it and it.keyword_include) or "" local ke = (it and it.keyword_exclude) or "" local dedup = it and (it.dedup == "1" or it.dedup == true) + -- 协议筛选 / 重命名:控制器一直在读这两项(read_rules_fields),但此前三个表单 + -- 都没有提交它们的控件 —— 字段永远写空,且每次保存会把 UCI 里已有的值重置为 ""。 + local proto_sel = {} + for p in ((it and it.proto_filter) or ""):gmatch("[^,]+") do + proto_sel[p:match("^%s*(.-)%s*$")] = true + end + local rm = (it and it.rename_map) or "" local sel = {} if it and type(it.sources) == "table" then for _, s in ipairs(it.sources) do sel[s] = true end @@ -63,7 +70,21 @@ />
- 多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。 + + <% for _, p in ipairs(core.RULE_PROTOS) do %> + + <% end %> +
+
+ + +
+
+ 多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。
+ 协议筛选:全部不勾选 = 不筛选(保留所有协议)。
+ 重命名每行一条:旧名称=新名称(精确匹配)、模式 -> 替换(正则替换)、{server}_{port}_{proto}(占位符模板)。
<% if is_edit then %> diff --git a/luci-app-substore/root/usr/lib/lua/luci/view/substore/form.htm b/luci-app-substore/root/usr/lib/lua/luci/view/substore/form.htm index 274d11237..a531c82d8 100644 --- a/luci-app-substore/root/usr/lib/lua/luci/view/substore/form.htm +++ b/luci-app-substore/root/usr/lib/lua/luci/view/substore/form.htm @@ -13,6 +13,13 @@ local ki = (it and it.keyword_include) or "" local ke = (it and it.keyword_exclude) or "" local dedup = it and (it.dedup == "1" or it.dedup == true) + -- 协议筛选 / 重命名:控制器一直在读这两项(read_rules_fields),但此前三个表单 + -- 都没有提交它们的控件 —— 字段永远写空,且每次保存会把 UCI 里已有的值重置为 ""。 + local proto_sel = {} + for p in ((it and it.proto_filter) or ""):gmatch("[^,]+") do + proto_sel[p:match("^%s*(.-)%s*$")] = true + end + local rm = (it and it.rename_map) or "" local proxy_enabled = it and (it.proxy_enable == true or it.proxy_enable == "1") or false local util = require("substore.util") -- 剩余流量 / 剩余时长(仅编辑页且有 subscription-userinfo 数据时显示,首页不显示) @@ -89,7 +96,21 @@ />
- 多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。 + + <% for _, p in ipairs(core.RULE_PROTOS) do %> + + <% end %> +
+
+ + +
+
+ 多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。
+ 协议筛选:全部不勾选 = 不筛选(保留所有协议)。
+ 重命名每行一条:旧名称=新名称(精确匹配)、模式 -> 替换(正则替换)、{server}_{port}_{proto}(占位符模板)。
<% if is_edit and it.last_update then %> diff --git a/luci-app-substore/root/usr/lib/lua/luci/view/substore/local_form.htm b/luci-app-substore/root/usr/lib/lua/luci/view/substore/local_form.htm index 8ee112908..51c82940f 100644 --- a/luci-app-substore/root/usr/lib/lua/luci/view/substore/local_form.htm +++ b/luci-app-substore/root/usr/lib/lua/luci/view/substore/local_form.htm @@ -9,6 +9,13 @@ local ki = (it and it.keyword_include) or "" local ke = (it and it.keyword_exclude) or "" local dedup = it and (it.dedup == "1" or it.dedup == true) + -- 协议筛选 / 重命名:控制器一直在读这两项(read_rules_fields),但此前三个表单 + -- 都没有提交它们的控件 —— 字段永远写空,且每次保存会把 UCI 里已有的值重置为 ""。 + local proto_sel = {} + for p in ((it and it.proto_filter) or ""):gmatch("[^,]+") do + proto_sel[p:match("^%s*(.-)%s*$")] = true + end + local rm = (it and it.rename_map) or "" local local_mode = (it and it.local_mode) or "text" local content = (it and it.raw_content) or "" -- 注入 JS 的初始内容:JSON 字符串字面量,转义 "<%:Dedup%> /> +
+ + <% for _, p in ipairs(core.RULE_PROTOS) do %> + + <% end %> +
+
+ + +
+
+ <%:Protocol filter: with none ticked no filtering is applied.%>
+ <%:One rename rule per line: OLD=NEW (exact), PATTERN -> REPLACEMENT (regex), or {server}_{port}_{proto} (placeholder template).%>
+
<% if is_edit and it.last_update then %>
diff --git a/luci-app-substore/root/usr/share/substore/core.lua b/luci-app-substore/root/usr/share/substore/core.lua index 1b8895a0f..caba81a4e 100644 --- a/luci-app-substore/root/usr/share/substore/core.lua +++ b/luci-app-substore/root/usr/share/substore/core.lua @@ -7,7 +7,7 @@ local parser = require("substore.parser") local M = {} -M.version = "2.6.0" +M.version = "2.6.8" M.DATA_DIR = "/etc/substore" M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json" M.NODES_DIR = M.DATA_DIR .. "/nodes" @@ -16,6 +16,14 @@ M.CRON_FILE = "/etc/cron.d/substore" M.MAX_SIZE = 10 * 1024 * 1024 -- 10MB M.TIMEOUT = 20 +-- 「协议筛选」的可选协议,用的是节点模型里的**规范**协议名(node.normalize 的产物)。 +-- 控制器(read_rules_fields)与三个表单模板共用这一份:两边各写一份的话, +-- 一旦漂移,勾选框就会生成一个永远匹配不到任何节点的 proto_filter, +-- 而症状是「勾了没用」—— 不会报错,最难查。 +-- 不含 socks5:解析阶段已把它归一成 socks(见 parser.lua 的说明)。 +M.RULE_PROTOS = { "vmess", "vless", "trojan", "shadowsocks", "ssr", + "hysteria2", "tuic", "hysteria", "wireguard", "socks" } + local function id_is_valid(id) return type(id) == "string" and id ~= "" and id:match("^[A-Za-z0-9_%-]+$") ~= nil end @@ -42,6 +50,24 @@ local function load() end local seq = tonumber(data._seq) or 0 local items = type(data.items) == "table" and data.items or {} + -- items 的每个值都必须是订阅元数据表。出现别的类型说明文件不是本程序写的 + -- (被手工编辑过 / 被截断后又被补全 / 磁盘错误)。此时不能原样返回: + -- * M.list / M.get 里的 pairs(meta) 会抛 + -- "bad argument #1 to 'pairs' (table expected, got string)" —— + -- 订阅列表页直接 500;cron 路径更糟,core.list() 抛异常会让整轮同步 + -- 在打印 "N ok, M failed" 之前中断,substore-cron.sh 据此判为成功。 + -- * 静默丢弃坏条目再返回也不行:调用方会以为列表完好,下一次 save 就把 + -- 它们永久抹掉(与 H8 的整体损坏同一个陷阱)。 + -- 因此:返回能用的条目,同时带上损坏错误,让写路径(add / save_meta / + -- write_nodes …)拒绝落盘,与上面的整体损坏走同一条路。 + local bad = false + local clean = {} + for id, meta in pairs(items) do + if type(meta) == "table" then clean[id] = meta else bad = true end + end + if bad then + return seq, clean, "订阅列表文件已损坏,存在非法条目:" .. M.LIST_FILE + end return seq, items end @@ -50,7 +76,9 @@ local function save(seq, items) return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items })) end --- 返回 arr, err。err 非空表示列表文件已损坏(此时 arr 为空)。 +-- 返回 arr, err。err 非空表示列表文件已损坏。 +-- 整体无法解析时 arr 为空;只有部分条目非法时 arr 仍包含能用的条目 +-- (坏条目已被 load 过滤掉,否则下面的 pairs(meta) 会抛异常)。 -- 追加第二个返回值是向后兼容的:调用方普遍写成 ipairs(core.list()) 或 -- local items = core.list(),都只取第一个值。 function M.list() diff --git a/luci-app-substore/root/usr/share/substore/http.lua b/luci-app-substore/root/usr/share/substore/http.lua index 25c87adf7..1f0cf7438 100644 --- a/luci-app-substore/root/usr/share/substore/http.lua +++ b/luci-app-substore/root/usr/share/substore/http.lua @@ -157,7 +157,13 @@ function M.parse_url(url) end end if not host or host == "" then return nil, "无效主机名" end - return { scheme = scheme, host = host, port = port } + -- 端口范围校验:`(%d+)` 只保证是数字,`http://host:99999/` 与 `:0` 都能通过, + -- 而这两个端口连不出去 —— 下载必然失败,却会先经过一轮 DNS/SSRF 检查, + -- 报出来的是「连接失败」这种指错方向的原因。parse_proxy 早已做了同样的校验, + -- 这里对齐。 + local pn = tonumber(port) + if not pn or pn < 1 or pn > 65535 then return nil, "端口无效" end + return { scheme = scheme, host = host, port = tostring(pn) } end -- 校验并规范化代理地址:支持 http/https/socks4/socks5/socks5h,可含 user:pass@ @@ -182,7 +188,8 @@ function M.parse_proxy(p) return nil, "代理用户名/密码含非法字符" end local host, port - if hostport:sub(1, 1) == "[" then + local bracketed = hostport:sub(1, 1) == "[" + if bracketed then host = hostport:match("^%[([%w%.%-%:]+)%]") port = hostport:match("^%[.*%]%:(%d+)$") else @@ -192,10 +199,22 @@ function M.parse_proxy(p) if not host or host == "" or not host:match("^[%w%.%-%:]+$") then return nil, "代理主机无效" end + -- 未加方括号的 IPv6 字面量:RFC 3986 要求 IPv6 主机必须写成 [addr]。 + -- 没有方括号时 `::1:1080` 既可能是「地址 ::1 + 端口 1080」,也可能就是 + -- 地址 `::1:1080` —— 无法判定。与其猜一个再拼出一条 curl -x 解析不了的 + -- 代理串(静默失效),不如明确报错让用户补上方括号。 + if not bracketed and select(2, host:gsub(":", "")) > 1 then + return nil, "IPv6 代理地址必须加方括号,例如 http://[::1]:1080" + end if port then port = tonumber(port) if not port or port < 1 or port > 65535 then return nil, "代理端口无效" end end + -- IPv6 字面量必须把方括号拼回去:上面为做主机校验把 [::1] 拆成了 ::1, + -- 直接拼会得到 `http://::1:1080` —— 冒号歧义,curl -x 与 http_proxy= 都 + -- 解析不了(curl 会把 `::1:1080` 整个当成主机名),代理静默失效。 + -- 单个冒号才是 host:port 的分隔符。 + if host:find(":", 1, true) then host = "[" .. host .. "]" end local r = scheme .. "://" if userinfo ~= "" then r = r .. userinfo .. "@" end r = r .. host @@ -263,8 +282,19 @@ function M.check_public(host) local ips = resolve(host) if not ips then - -- 无 DNS 解析能力:放行,交由下载工具处理(尽力而为) - return true + -- 解析不出 IP 一律拒绝(fail-closed)。 + -- + -- 此前这里放行,理由是「无 DNS 解析能力时尽力而为」—— 但那是一个 + -- SSRF 绕过口:`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名, + -- 只要本机这一刻解析不出来(nixio 缺失、解析器临时故障、超时), + -- 就绕过上面全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1 + -- 并连上去。检查的强度不能低于被检查者。 + -- + -- 代价为零:本包依赖 luci-lua-runtime,后者硬依赖 luci-lib-nixio, + -- 所以 resolve() 返回 nil 只意味着「解析真的失败了」—— 那种情况下 + -- curl 同样解析不了,下载本来就会失败,只是错误信息会变成 + -- 「连接失败」这种指错方向的说法。 + return false, "无法解析目标主机名" end for _, ip in ipairs(ips) do if not is_private(ip) then return true end @@ -369,7 +399,16 @@ local function fetch_curl(url, parsed, opts) local tmp = "/tmp/substore_dl_" .. tag .. "_" .. redirect .. ".tmp" local hdr = tmp .. ".hdr" local errf = tmp .. ".err" - os.remove(tmp); os.remove(hdr); os.remove(errf) + -- 清理必须在**每一条**退出路径上执行。 + -- 此前只在「本轮开始」和「2xx 成功」两处 os.remove:失败路径(curl 报错、 + -- 超限、重定向无 Location / 目标不安全、HTTP 4xx-5xx、重定向次数过多) + -- 都把文件留在 /tmp。而 OpenWrt 的 /tmp 是 tmpfs —— 占的是内存; + -- 订阅更新失败(cron 定时重试)会一轮轮往内存里堆 .tmp/.hdr/.err, + -- 其中 .tmp 可能是部分下载的响应体,最大到 max_size。 + local function cleanup() + os.remove(tmp); os.remove(hdr); os.remove(errf) + end + cleanup() local cmd = string.format( "curl -sS -o %s --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %s -w \"%%{http_code}\"%s %s 2>%s", util.shq(tmp), t, math.min(t, 10), max, util.shq(hdr), proxy_arg, util.shq(cur), util.shq(errf)) @@ -378,30 +417,46 @@ local function fetch_curl(url, parsed, opts) if p then p:close() end code = util.trim(code) if code == "" or code == "000" then - return nil, util.trim(util.read_file(errf) or "下载失败") + local msg = util.trim(util.read_file(errf) or "下载失败") + cleanup() + return nil, msg end -- 只把 2xx 当成功。写成 [23] 会把 3xx 也当成功,于是重定向响应体(通常是空的 -- 或一段 HTML)被当成订阅内容存下去:已存的节点被清空、node_count 归 0, -- 而 error 仍是空字符串,列表页看不出任何异常。下面的重定向分支也因此永远不会执行。 if code:match("^2%d%d$") then local size = util.file_size(tmp) - if size > max then return nil, "响应超过大小限制 (" .. max .. " 字节)" end + if size > max then + cleanup() + return nil, "响应超过大小限制 (" .. max .. " 字节)" + end local content = util.read_file(tmp) local headers = read_headers(hdr) - os.remove(tmp); os.remove(hdr); os.remove(errf) + cleanup() if not content then return nil, "读取响应失败" end return content, headers end if code:match("^3%d%d$") then local loc = location_from_headers(hdr) - if not loc then return nil, "重定向无 Location" end + if not loc then + cleanup() + return nil, "重定向无 Location" + end local next_url = resolve_url(cur, loc) local np = M.parse_url(next_url) - if not np then return nil, "重定向目标无效" end + if not np then + cleanup() + return nil, "重定向目标无效" + end local ok, re = M.check_public(np.host) - if not ok then return nil, "重定向目标不安全: " .. (re or "") end + if not ok then + cleanup() + return nil, "重定向目标不安全: " .. (re or "") + end + cleanup() cur = next_url else + cleanup() return nil, "HTTP 错误 " .. code end end diff --git a/luci-app-substore/root/usr/share/substore/node.lua b/luci-app-substore/root/usr/share/substore/node.lua index b7dd6fbfc..9c2ebf661 100644 --- a/luci-app-substore/root/usr/share/substore/node.lua +++ b/luci-app-substore/root/usr/share/substore/node.lua @@ -167,17 +167,56 @@ local function wg_public_key(n) return n["public-key"] or n.public_key or n["peer-public-key"] or n.peer_public_key end --- 去重:按 proto+server+port 唯一。 --- WireGuard/AmneziaWG 例外:同一个 endpoint 上不同 peer 公钥是**不同**的节点, --- 只按 server+port 去重会把它们错误合并(§32/§43),因此把公钥并入去重键。 +-- 去重键的「身份」部分:同一 server:port 上的**不同账号是不同节点**。 +-- +-- 旧键只有 proto|server|port,于是「同一入口的多账号」被当成重复,静默删掉 +-- 其中一个 —— 那是数据丢失而不是去重(与下面 WireGuard 公钥的说明同理)。 +-- 这里取各协议里真正代表账号身份的字段;未知协议不猜,退回空身份。 +local function identity_key(n) + local proto = (n.proto or ""):lower() + if proto == "ss" then proto = "shadowsocks" end + if proto == "wg" then proto = "wireguard" end + if proto == "socks5" then proto = "socks" end + + -- WireGuard 的身份是 peer 公钥(同一个 endpoint 上不同公钥是不同节点) + if proto == "wireguard" then + return tostring(wg_public_key(n) or "") + end + if proto == "shadowsocks" then + return tostring(n.method or n.cipher or "") .. "\1" .. tostring(n.password or "") + end + if proto == "ssr" then + -- SSR 的加密方式、密码、协议插件、混淆方式是四段独立参数,任一不同 + -- 都是不同的节点 + return tostring(n.method or n.cipher or "") .. "\1" .. tostring(n.password or "") + .. "\1" .. tostring(n.protocol or "") .. "\1" .. tostring(n.obfs or "") + end + if proto == "vmess" or proto == "vless" then + return tostring(n.uuid or "") + end + if proto == "tuic" then + return tostring(n.uuid or "") .. "\1" .. tostring(n.password or "") + end + if proto == "socks" or proto == "http" then + return tostring(n.username or "") .. "\1" .. tostring(n.password or "") + end + if proto == "trojan" or proto == "hysteria" or proto == "hysteria2" then + return tostring(n.password or "") + end + return "" +end + +-- 去重:按 proto+server+port+身份唯一。 +-- proto 先归一:`wg` 与 `wireguard` 是同一协议,不归一的话同一条链路以两种 +-- 写法出现时不会被去重。 function M.dedup(nodes) local seen = {} local out = {} for _, n in ipairs(nodes) do - local key = (n.proto or "") .. "|" .. (n.server or "") .. "|" .. tostring(n.port or "") - if n.proto == "wireguard" or n.proto == "wg" then - key = key .. "|" .. tostring(wg_public_key(n) or "") - end + local proto = (n.proto or ""):lower() + if proto == "wg" then proto = "wireguard" end + local key = proto .. "|" .. (n.server or "") .. "|" .. tostring(n.port or "") + .. "|" .. identity_key(n) if not seen[key] then seen[key] = true out[#out + 1] = n @@ -569,6 +608,37 @@ local function split_alternatives(pat) end -- 按规则链式重命名节点(就地修改) +-- 把用户写的替换串翻译成 string.gsub 的替换串。 +-- +-- 两个坑: +-- * 捕获引用写的是 `$1`(正则风格),而 gsub 要的是 `%1`,必须转换。 +-- * 其余字符必须按**字面**处理。用户写的 `%` 若原样传进 gsub,就落进了 gsub +-- 的替换串语义:`%` 后接非数字字符时被吞掉("50%off" → "50off"), +-- 结尾的 `%` 会注入一个 NUL 字节("100%" → "100\0")。这不是显示问题—— +-- 节点名会写进节点文件并下发给所有客户端。 +-- 原先只做了 `$` → `%` 的替换,没有转义字面 `%`,上述两种损坏都会发生。 +local function build_replacement(rep) + rep = rep or "" + local out, i, n = {}, 1, #rep + while i <= n do + local c = rep:sub(i, i) + local nx = rep:sub(i + 1, i + 1) + if c == "$" and nx:match("%d") then + -- $1..$9 → %1..%9(gsub 的捕获引用) + out[#out + 1] = "%" .. nx + i = i + 2 + elseif c == "%" then + -- 字面百分号:转义成 %% + out[#out + 1] = "%%" + i = i + 1 + else + out[#out + 1] = c + i = i + 1 + end + end + return table.concat(out) +end + function M.rename_with_rules(nodes, rules) rules = rules or {} for _, n in ipairs(nodes) do @@ -581,7 +651,7 @@ function M.rename_with_rules(nodes, rules) local name = n.name or "" -- 正则风格 → Lua pattern:\d -> %d、$1 -> %1、HK-01 里的 - 转义成 %- local pat = regex_to_lua(r.pattern or "") - local repl = (r.replacement or ""):gsub("%$", "%%") + local repl = build_replacement(r.replacement) local ok, result = pcall(function() -- 顶层 | 拆成多个候选依次替换(Lua pattern 没有「或」) local s = name diff --git a/luci-app-substore/root/usr/share/substore/output_clash_meta.lua b/luci-app-substore/root/usr/share/substore/output_clash_meta.lua index d373402c2..938f6afd5 100644 --- a/luci-app-substore/root/usr/share/substore/output_clash_meta.lua +++ b/luci-app-substore/root/usr/share/substore/output_clash_meta.lua @@ -12,8 +12,12 @@ local function esc_yaml(s) -- 注意:这里必须用 Lua 模式(不能传 plain=true),否则整串被当作字面量、永不匹配 or s:find("[ :#{}%[%],&*?|>'\"%@`]") ~= nil or s:match("^[-?]*:") ~= nil - s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t") + -- 转义只在**加引号时**做:未加引号的 plain scalar 里反斜杠就是字面反斜杠, + -- 提前翻倍会让回读得到两个反斜杠(`pa\ss` → 输出 `pa\\ss` → 读回 `pa\\ss`, + -- 密码/路径直接错)。\ 本身不在上面的 need_quote 触发集里,所以含反斜杠的 + -- 值恰恰是最容易走到这条路径的一类。 if need_quote then + s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t") return '"' .. s .. '"' end return s @@ -31,14 +35,34 @@ local function yaml_list(items, level) return table.concat(out, "\n") end --- 值可能是标量也可能是数组:数组输出为 YAML 列表,标量输出为单行 -local function yaml_value(lines, key, v, level) +-- 把「数组或逗号分隔字符串」统一成数组。 +-- mihomo 的 wireguard 出站里 allowed-ips / dns 是 []string、reserved 是 []uint8, +-- 三者都**只接受列表**。节点模型里这些字段的形态取决于来源:Clash YAML 的嵌套 +-- 列表解析后是 table,表单导入 / URI 导入后是 "0.0.0.0/0, ::/0" 这样的字符串。 +-- 字符串原样输出会得到 `allowed-ips: 0.0.0.0/0` 这个标量,mihomo 反序列化 +-- 到 []string 失败,**整份配置拒绝加载**。 +local function as_list(v) + local out = {} if type(v) == "table" then - lines[#lines + 1] = indent(level) .. key .. ":" - lines[#lines + 1] = yaml_list(v, level + 1) + for _, x in ipairs(v) do + if x ~= nil and tostring(x) ~= "" then out[#out + 1] = x end + end else - lines[#lines + 1] = indent(level) .. key .. ": " .. esc_yaml(v) + for x in tostring(v or ""):gmatch("[^,]+") do + x = x:match("^%s*(.-)%s*$") + if x ~= "" then out[#out + 1] = x end + end end + return out +end + +-- 值可能是数组也可能是逗号分隔字符串:一律输出为 YAML 列表。 +-- 空列表直接不输出该键(`allowed-ips: []` 同样是非法值)。 +local function yaml_value(lines, key, v, level) + local items = as_list(v) + if #items == 0 then return end + lines[#lines + 1] = indent(level) .. key .. ":" + lines[#lines + 1] = yaml_list(items, level + 1) end local PROTOCOL_TYPE_MAP = { @@ -65,7 +89,9 @@ local function format_node(node) lines[#lines + 1] = " - name: " .. esc_yaml(node.name or "") lines[#lines + 1] = " type: " .. esc_yaml(ctype) lines[#lines + 1] = " server: " .. esc_yaml(node.server or "") - lines[#lines + 1] = " port: " .. tostring(node.port or 0) + -- 端口必须是数字:`port: abc` / `port: 443/tcp` 这类非数字值会让 mihomo 拒绝 + -- 加载整份配置。sing-box / v2ray 输出一直用 `tonumber() or 0` 兜底,此处对齐。 + lines[#lines + 1] = " port: " .. tostring(tonumber(node.port) or 0) -- 协议通用字段 if node.uuid then @@ -97,6 +123,28 @@ local function format_node(node) lines[#lines + 1] = " headers:" lines[#lines + 1] = " Host: " .. esc_yaml(node.host) end + elseif net == "grpc" then + -- 服务名写在 grpc-opts 里。只写 network: grpc 的话客户端用默认服务名 + -- 去连,握手失败——与 ws 丢 path 是同一类问题(原来的实现只写了 network)。 + -- 键名对照上游 transport 文档:grpc-service-name,带 grpc- 前缀。 + -- 服务名取自 node.path,与 output_v2ray 的 serviceName、 + -- output_singbox 的 service_name 同源。 + if node.path then + lines[#lines + 1] = " grpc-opts:" + lines[#lines + 1] = " grpc-service-name: " .. esc_yaml(node.path) + end + elseif net == "h2" then + -- 上游 transport 文档:h2-opts.host 是**列表**,h2-opts.path 是标量。 + if node.path or node.host then + lines[#lines + 1] = " h2-opts:" + if node.host then + lines[#lines + 1] = " host:" + lines[#lines + 1] = " - " .. esc_yaml(node.host) + end + if node.path then + lines[#lines + 1] = " path: " .. esc_yaml(node.path) + end + end end end @@ -161,6 +209,16 @@ local function format_node(node) end end + if ctype == "vless" then + -- flow 是 XTLS Vision(xtls-rprx-vision)的必需参数。不写的话 mihomo 按 + -- 普通 vless 处理,服务端要求 vision 时握手失败。surge / v2ray / URI 三个 + -- 输出都写 flow,只有 clashmeta 漏了;parser_clash_yaml 也回读 flow, + -- 所以「Clash YAML → 导出 clashmeta」这条路径上 flow 会凭空消失。 + if node.flow then + lines[#lines + 1] = " flow: " .. esc_yaml(node.flow) + end + end + if ctype == "hysteria2" then if node.sni then lines[#lines + 1] = " sni: " .. esc_yaml(node.sni) diff --git a/luci-app-substore/root/usr/share/substore/output_formats.lua b/luci-app-substore/root/usr/share/substore/output_formats.lua index 0cd2c4413..4784c97d9 100644 --- a/luci-app-substore/root/usr/share/substore/output_formats.lua +++ b/luci-app-substore/root/usr/share/substore/output_formats.lua @@ -83,11 +83,22 @@ function M.surge_line(n) return util.one_line(line) end --- 收集节点名列表 +-- 收集可用于「逗号分隔成员列表」的节点名(Surge 家族 [Proxy Group] / QX [policy])。 +-- +-- 两类名字必须排除,否则生成的配置整体非法: +-- * 含逗号:这些格式的成员列表就是 `NAME = select, X, Y, DIRECT`,语法里没有 +-- 引号 / 转义机制,名字里的逗号会被当成成员分隔符 —— `A,B` 被读成两个成员 +-- `A` 与 `B`,两个都不存在,Surge / QX 会因引用不存在的代理而拒绝加载整份 +-- 配置。节点定义本身仍留在 [Proxy] / [server_local] 中,只是不进成员列表。 +-- * 换行:定义行经过 util.one_line(换行→空格),成员列表若用原始名就对不上 +-- 定义行,同样成为悬空引用。这里统一先 one_line 再比对,保证两边一致。 local function names_of(nodes) local out = {} for _, n in ipairs(nodes or {}) do - if n.name then out[#out + 1] = n.name end + local nm = util.one_line(n.name) + if nm and nm ~= "" and not nm:find(",", 1, true) then + out[#out + 1] = nm + end end return out end @@ -177,6 +188,10 @@ end function M.to_qx(nodes, options) options = options or {} local out = {} + -- 真正写出了 [server_local] 行的节点。QX 只支持下面这 4 类协议,其余节点 + -- (hysteria2 / hysteria / tuic / socks / wireguard / ssr …)没有定义行; + -- [policy] 若把它们也列进去,就成了引用不存在服务器的悬空条目。 + local emitted = {} out[#out + 1] = "[server_local]" for _, n in ipairs(nodes or {}) do local proto = props(n) @@ -213,10 +228,12 @@ function M.to_qx(nodes, options) -- 节点名 / sni / path / host 全部来自订阅(不可信),含换行会截断本行 -- 并伪造出一条新的 server_local 行 for i = before + 1, #out do out[i] = util.one_line(out[i]) end + -- 上面的 if/elseif 命中时必定追加一行,未命中时一行不加 + if #out > before then emitted[#emitted + 1] = n end end out[#out + 1] = "" out[#out + 1] = "[policy]" - local names = names_of(nodes) + local names = names_of(emitted) local policy = "static=" .. (options.name or "PROXY") .. ", DIRECT" for _, nm in ipairs(names) do policy = policy .. ", " .. nm end out[#out + 1] = util.one_line(policy) diff --git a/luci-app-substore/root/usr/share/substore/output_singbox.lua b/luci-app-substore/root/usr/share/substore/output_singbox.lua index 2ed807fbb..bbab1c296 100644 --- a/luci-app-substore/root/usr/share/substore/output_singbox.lua +++ b/luci-app-substore/root/usr/share/substore/output_singbox.lua @@ -11,6 +11,38 @@ local function bool(v) return true end +-- 把「数组或逗号分隔字符串」统一成数组(空项丢弃)。 +-- 节点模型里 wireguard 的 allowed-ips / reserved / dns 形态取决于来源:Clash YAML +-- 的嵌套列表解析后是 table,表单导入 / URI 导入后是 "0.0.0.0/0, ::/0" 这样的 +-- 字符串。字符串原样写进 JSON 会得到 `"allowed_ips":"0.0.0.0/0"`,而 sing-box +-- 这几个字段是 []string / []uint8,反序列化失败 → 整份配置拒绝启动。 +local function as_list(v) + local out = {} + if type(v) == "table" then + for _, x in ipairs(v) do + if x ~= nil and tostring(x) ~= "" then out[#out + 1] = x end + end + else + for x in tostring(v or ""):gmatch("[^,]+") do + x = x:match("^%s*(.-)%s*$") + if x ~= "" then out[#out + 1] = x end + end + end + return out +end + +-- 同 as_list,但把每项转成数字(非数字项丢弃)。 +-- reserved 在 sing-box 里是 []uint8:JSON 里写成 ["1","2","3"](字符串)同样 +-- 反序列化失败,必须输出 [1,2,3]。 +local function as_num_list(v) + local out = {} + for _, x in ipairs(as_list(v)) do + local num = tonumber(x) + if num then out[#out + 1] = num end + end + return out +end + -- sing-box type 映射 local TYPE_MAP = { vmess = "vmess", @@ -148,12 +180,17 @@ function M.to_outbound(n, tag) if n.ipv6 then addr[#addr + 1] = n.ipv6 end if #addr == 1 then o.local_address = addr[1] elseif #addr > 1 then o.local_address = addr end - if n["allowed-ips"] then o.allowed_ips = n["allowed-ips"] end - if n.reserved then o.reserved = n.reserved end + -- 三个字段在 sing-box 里都是列表(allowed_ips/dns 是 []string,reserved 是 + -- []uint8),字符串形态必须归一后再写,否则整份配置起不来 + local aips = as_list(n["allowed-ips"]) + if #aips > 0 then o.allowed_ips = aips end + local rsv = as_num_list(n.reserved) + if #rsv > 0 then o.reserved = rsv end if n["persistent-keepalive"] then o.persistent_keepalive_interval = tonumber(n["persistent-keepalive"]) end if n["listen-port"] then o.listen_port = tonumber(n["listen-port"]) end if n.mtu then o.mtu = tonumber(n.mtu) end - if n.dns then o.dns = n.dns end + local dns = as_list(n.dns) + if #dns > 0 then o.dns = dns end elseif stype == "socks" or stype == "http" then -- socks 与 http 出站的认证字段相同(username / password) if n.username then o.username = n.username end diff --git a/luci-app-substore/root/usr/share/substore/output_uri.lua b/luci-app-substore/root/usr/share/substore/output_uri.lua index 4417bd830..6e873351f 100644 --- a/luci-app-substore/root/usr/share/substore/output_uri.lua +++ b/luci-app-substore/root/usr/share/substore/output_uri.lua @@ -14,6 +14,29 @@ local function url_encode(s) end)) end +-- alpn 归一成逗号分隔字符串。 +-- alpn 可能是数组:Clash YAML 的 alpn 列表与 sing-box JSON 的 tls.alpn 导入后 +-- 就是 table。直接交给 url_encode 会 tostring 成 "table: 0x...",链接里出现 +-- `alpn=table%3A%200x...`(客户端解析失败)。 +local function alpn_str(v) + if type(v) == "table" then return table.concat(v, ",") end + return v +end + +-- 是否跳过证书校验。模型里的权威字段是 skip-cert-verify(见 parser.lua 中 +-- hysteria2/hysteria URI 解析处的说明);insecure 只是 URI 参数名,且**只有** +-- URI 解析器会写它 —— Clash YAML / sing-box JSON / 表单导入的节点只有 +-- skip-cert-verify。只读 insecure 会让这些节点在导出链接时丢掉该项, +-- 客户端按严格校验握手直接失败。 +local function insecure_flag(n) + local v = n["skip-cert-verify"] + if v == nil then v = n.skip_cert_verify end + if v == nil then v = n.insecure end + if v == nil then return nil end + if v == false or v == "false" or v == 0 or v == "0" then return "0" end + return "1" +end + -- 生成单节点分享链接;无法生成时返回 nil function M.to_share_uri(n) if type(n) ~= "table" or not n.server or not n.port then return nil end @@ -59,10 +82,7 @@ function M.to_share_uri(n) q[#q + 1] = "security=" .. url_encode(n.security or "none") if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end if n.fp then q[#q + 1] = "fp=" .. url_encode(n.fp) end - if n.alpn then - local alpn = type(n.alpn) == "table" and table.concat(n.alpn, ",") or n.alpn - q[#q + 1] = "alpn=" .. url_encode(alpn) - end + if n.alpn then q[#q + 1] = "alpn=" .. url_encode(alpn_str(n.alpn)) end if n.path then q[#q + 1] = "path=" .. url_encode(n.path) end if n.host then q[#q + 1] = "host=" .. url_encode(n.host) end if n.flow then q[#q + 1] = "flow=" .. url_encode(n.flow) end @@ -74,7 +94,7 @@ function M.to_share_uri(n) local q = {} q[#q + 1] = "security=" .. url_encode(n.security or "tls") if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end - if n.alpn then q[#q + 1] = "alpn=" .. url_encode(n.alpn) end + if n.alpn then q[#q + 1] = "alpn=" .. url_encode(alpn_str(n.alpn)) end if n.fp then q[#q + 1] = "fp=" .. url_encode(n.fp) end return "trojan://" .. url_encode(n.password or "") .. "@" .. server .. ":" .. tostring(port) .. "?" .. table.concat(q, "&") .. "#" .. name @@ -83,7 +103,8 @@ function M.to_share_uri(n) if proto == "hysteria2" or proto == "hysteria" then local q = {} if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end - if n.insecure ~= nil then q[#q + 1] = "insecure=" .. tostring(n.insecure) end + local ins = insecure_flag(n) + if ins ~= nil then q[#q + 1] = "insecure=" .. ins end -- 混淆:hysteria2 是 salamander,URI 参数为 obfs / obfs-password; -- hysteria(v1) 的 obfs 只是普通字符串,**没有** obfs-password -- (写到 v1 链接上是非法参数,且会误导下游客户端) @@ -104,7 +125,7 @@ function M.to_share_uri(n) local userinfo = (n.uuid or "") .. ":" .. url_encode(n.password or "") local q = {} if n.congestion_control then q[#q + 1] = "congestion_control=" .. url_encode(n.congestion_control) end - if n.alpn then q[#q + 1] = "alpn=" .. url_encode(n.alpn) end + if n.alpn then q[#q + 1] = "alpn=" .. url_encode(alpn_str(n.alpn)) end if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or "" return "tuic://" .. userinfo .. "@" .. server .. ":" .. tostring(port) diff --git a/luci-app-substore/root/usr/share/substore/output_v2ray.lua b/luci-app-substore/root/usr/share/substore/output_v2ray.lua index 9c0bb8e70..db09267da 100644 --- a/luci-app-substore/root/usr/share/substore/output_v2ray.lua +++ b/luci-app-substore/root/usr/share/substore/output_v2ray.lua @@ -47,9 +47,25 @@ local function build_stream_settings(n) return ss end --- 该协议能否落到 V2Ray / Xray outbound(SSR 无法表达,跳过) +-- 该协议能否落到 V2Ray / Xray outbound。 +-- 白名单而不是「排除 ssr」:原来的 `proto ~= "ssr"` 会把 hysteria2 / hysteria / +-- tuic / wireguard / snell 一并放行,而 to_outbound 里没有它们的分支,全部落进 +-- 末尾的 else —— 于是生成 `"protocol": "hysteria2"` 这种 Xray 根本不认识的 +-- outbound,凭据还被塞进无意义的 users 字段。Xray 解析到未知 protocol 会拒绝 +-- 整份配置,一个节点废掉整个订阅(与 parser 侧丢弃未知协议是同一个理由)。 +local V2RAY_PROTOS = { + vmess = true, + vless = true, + trojan = true, + shadowsocks = true, + ss = true, + socks = true, + socks5 = true, + http = true, +} + local function supported(proto) - return (proto or "vmess") ~= "ssr" + return V2RAY_PROTOS[proto or "vmess"] == true end -- 单节点 → V2Ray outbound 表 diff --git a/luci-app-substore/root/usr/share/substore/parser.lua b/luci-app-substore/root/usr/share/substore/parser.lua index a5f06f6d2..5b4be2270 100644 --- a/luci-app-substore/root/usr/share/substore/parser.lua +++ b/luci-app-substore/root/usr/share/substore/parser.lua @@ -29,6 +29,21 @@ local function split_lines(content) return out end +-- 节点必须有可用的 server 与 port,否则解析阶段就丢弃。 +-- +-- 依据:Clash / sing-box / v2ray 的输出都会把缺失值写成 `server: `(空字符串) +-- 或 `port: 0`。这两个值不是「降级」,而是**非法配置**——mihomo 与 sing-box 会 +-- 拒绝加载整份文件,于是一个残缺节点废掉整个订阅的所有节点。 +-- 与 H6(未知协议)同理:模型承载不了的东西不进模型,而不是带着非法值往下传。 +-- +-- port 来自 util.split_hostport,只会是 "%d+" 匹配到的数字串或 nil, +-- 因此这里只需判空与范围(0 与 >65535 都是非法端口)。 +local function valid_hostport(host, port) + if not host or host == "" then return false end + local p = tonumber(port) + return p ~= nil and p >= 1 and p <= 65535 +end + -- 检测订阅格式:uri / base64 / json / yaml / empty / unknown function M.detect(content) content = util.trim(content or "") @@ -123,7 +138,7 @@ local function parse_ss(body) end local method, password = userinfo:match("^([^:]+):(.*)$") local host, port = util.split_hostport(hostport) - if not (method and password and host) then return nil, "bad ss" end + if not (method and password and valid_hostport(host, port)) then return nil, "bad ss" end local name = fragment ~= "" and fragment or (host .. ":" .. tostring(port or "")) local out = node.normalize({ proto = "shadowsocks", name = name, server = host, port = tonumber(port), @@ -159,7 +174,14 @@ local function parse_ssr(body) rest = rest:sub(1, hash - 1) end if rest:find("%", 1, true) then rest = util.url_decode(rest) end - local decoded = util.base64_decode(rest) + -- 用 base64_url_decode 而不是 base64_decode:前者只是先把 -/_ 还原成 +// + -- 再走标准解码,对标准 base64 输入逐字节等价(标准字母表里没有 -/_), + -- 对 base64url 输入才是正确的。 + -- 反过来的代价很大:base64_decode 会把 -/_ 当非法字符**直接剔除** + -- (util.lua 的 `s:gsub("[^%w%+/=]", "")`),于是外层用了 base64url 的 + -- ssr:// 链接会少掉若干字符、整串解成乱码——server / port / 密码全错, + -- 而且不报错,用户只看到一个连不上的节点。 + local decoded = util.base64_url_decode(rest) if decoded == "" then return nil, "bad ssr" end -- 以首个 '?' 切分主体与参数(密码 base64 可能含 '/',故不能按 '/' 切分) @@ -169,8 +191,7 @@ local function parse_ssr(body) local server, port, protocol, method, obfs, pwd_b64 = main:match("^([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):(.*)$") if not server or server == "" then return nil, "bad ssr" end - local password = util.base64_url_decode(pwd_b64 or "") - if password == "" then password = pwd_b64 or "" end + local password = b64u_decode(pwd_b64 or "") local params = {} for k, v in (query or ""):gmatch("([^&=]+)=([^&]*)") do @@ -215,6 +236,7 @@ local function parse_vless(uri, body) if not at then return nil, "bad vless" end local uuid = hp:sub(1, at - 1) local host, port = util.split_hostport(hp:sub(at + 1)) + if not valid_hostport(host, port) then return nil, "bad vless" end local out = node.normalize({ proto = "vless", name = name, server = host, port = tonumber(port), uuid = uuid, raw = uri, }) @@ -250,10 +272,16 @@ local function parse_trojan(uri, body) query[k] = util.url_decode(v) end end - local at = hp:find("@", 1, true) - if not at then return nil, "bad trojan" end - local password = hp:sub(1, at - 1) - local host, port = util.split_hostport(hp:sub(at + 1)) + -- 取**最后**一个 @:userinfo 里不允许裸 @,未编码的 @ 只可能出现在密码里。 + -- 按第一个 @ 切会把 "p@ss" 切成密码 "p" + server "ss@1.2.3.4"(静默出错)。 + -- 与 parse_socks / parse_hysteria2 / parse_tuic 保持同一策略。 + local userinfo, hostport = hp:match("^(.*)@([^@]*)$") + if not userinfo then return nil, "bad trojan" end + -- 密码必须 url_decode:output_uri 写链接时会 url_encode,不解码就是双重编码 + -- ("p@ss:w/rd" → 回读成 "p%40ss%3Aw%2Frd"),回环一次密码就错了。 + local password = util.url_decode(userinfo) + local host, port = util.split_hostport(hostport) + if not valid_hostport(host, port) then return nil, "bad trojan" end local out = node.normalize({ proto = "trojan", name = name, server = host, port = tonumber(port), password = password, raw = uri, @@ -291,6 +319,7 @@ local function parse_vmess(uri, body) local at = hp:find("@", 1, true) local uuid = hp:sub(1, at - 1) local host, port = util.split_hostport(hp:sub(at + 1)) + if not valid_hostport(host, port) then return nil, "bad vmess" end local out = node.normalize({ proto = "vmess", name = name, server = host, port = tonumber(port), uuid = uuid, raw = uri, @@ -311,6 +340,7 @@ local function parse_vmess(uri, body) if decoded == "" then return nil, "bad vmess b64" end local j = util.json_decode(decoded) if type(j) ~= "table" or not j.add then return nil, "bad vmess json" end + if not valid_hostport(j.add, j.port) then return nil, "bad vmess json" end local out = node.normalize({ proto = "vmess", name = j.ps or (j.add .. ":" .. tostring(j.port)), @@ -323,6 +353,12 @@ local function parse_vmess(uri, body) security = (j.tls == "tls" or j.tls == true) and "tls" or nil, raw = uri, }) + -- host / path 是 ws(以及 http/h2)传输的必需参数:output_uri 会把它们写成 + -- vmess://…?host=…&path=…,不回读就等于导出→导入丢字段。丢 path 的后果最严重—— + -- Clash 输出得到 network: ws 却没有 ws-opts.path,客户端请求 "/" 而非真实路径。 + -- vless 分支与 vmess 新版 URI 分支早已回读这两个字段,此处是对齐。 + if j.host and j.host ~= "" then out.host = j.host end + if j.path and j.path ~= "" then out.path = j.path end return out end @@ -343,10 +379,13 @@ local function parse_hysteria2(uri, body) query[k] = util.url_decode(v) end end - local at = hp:find("@", 1, true) - if not at then return nil, "bad hysteria2" end - local password = util.url_decode(hp:sub(1, at - 1)) - local host, port = util.split_hostport(hp:sub(at + 1)) + -- 取最后一个 @(见 parse_socks 的说明):按第一个 @ 切会把密码里的裸 @ 当成 + -- userinfo 分隔符,得到密码 "p" 和 server "ss@1.2.3.4"——静默解析出一个错节点。 + local userinfo, hostport = hp:match("^(.*)@([^@]*)$") + if not userinfo then return nil, "bad hysteria2" end + local password = util.url_decode(userinfo) + local host, port = util.split_hostport(hostport) + if not valid_hostport(host, port) then return nil, "bad hysteria2" end local out = node.normalize({ proto = "hysteria2", name = name, server = host, port = tonumber(port), password = password, raw = uri, @@ -390,11 +429,11 @@ local function parse_hysteria(uri, body) query[k] = util.url_decode(v) end end - local at = hp:find("@", 1, true) - if not at then return nil, "bad hysteria" end - local password = util.url_decode(hp:sub(1, at - 1)) - local host, port = util.split_hostport(hp:sub(at + 1)) - if not host or host == "" then return nil, "bad hysteria" end + local userinfo, hostport = hp:match("^(.*)@([^@]*)$") + if not userinfo then return nil, "bad hysteria" end + local password = util.url_decode(userinfo) + local host, port = util.split_hostport(hostport) + if not valid_hostport(host, port) then return nil, "bad hysteria" end local out = node.normalize({ proto = "hysteria", name = name, server = host, port = tonumber(port), password = password, raw = uri, @@ -468,16 +507,18 @@ local function parse_tuic(uri, body) query[k] = util.url_decode(v) end end - local at = hp:find("@", 1, true) - if not at then return nil, "bad tuic" end - local userinfo = util.url_decode(hp:sub(1, at - 1)) + -- 取最后一个 @(见 parse_socks 的说明) + local raw_userinfo, hostport = hp:match("^(.*)@([^@]*)$") + if not raw_userinfo then return nil, "bad tuic" end + local userinfo = util.url_decode(raw_userinfo) local uuid, password = "", userinfo local cpos = userinfo:find(":", 1, true) if cpos then uuid = userinfo:sub(1, cpos - 1) password = userinfo:sub(cpos + 1) end - local host, port = util.split_hostport(hp:sub(at + 1)) + local host, port = util.split_hostport(hostport) + if not valid_hostport(host, port) then return nil, "bad tuic" end local out = node.normalize({ proto = "tuic", name = name, server = host, port = tonumber(port), uuid = uuid, password = password, raw = uri, @@ -579,8 +620,15 @@ local function parse_wireguard_conf(content) local section = nil for line in content:gmatch("[^\r\n]+") do local s = util.trim(line) - if s == "" or s:sub(1, 1) == "#" or s:sub(1, 1) == ";" then - -- 空行 / 注释 + -- 行内注释:wg-quick 的 parse_options 用 `stripped="${line%%\#*}"`,即从 + -- **第一个** # 起全部丢弃(不要求 # 前有空白)。此处对齐同一语义:否则 + -- `Endpoint = 1.2.3.4:51820 # 备用` 会把 "# 备用" 当成值的一部分, + -- split_hostport 取不到端口,整份 .conf 因此作废。 + -- wg-quick 不把 ; 当注释符(; 是本实现额外容忍的写法),故 ; 只按整行 + -- 注释处理,不参与行内剥离。 + s = util.trim((s:gsub("#.*$", ""))) + if s == "" or s:sub(1, 1) == ";" then + -- 空行 / 纯注释行 elseif s:sub(1, 1) == "[" then local name = s:match("^%[%s*(.-)%s*%]") name = name and name:lower() or "" @@ -655,41 +703,55 @@ local function parse_wireguard_conf(content) end end + -- 一层的表拷贝。common 里的 dns / reserved 是数组、awg 是子表,都必须按节点 + -- 各拿一份:浅拷贝会让所有节点共享同一个表,之后任何一处改动(例如按节点编辑 + -- DNS)会同时改到全部节点。 + local function copy1(t) + local o = {} + for k, v in pairs(t) do o[k] = v end + return o + end + local nodes = {} for _, p in ipairs(peers) do local host, port = util.split_hostport(p["endpoint"] or "") - if not host or host == "" or not port then return nil, "bad wireguard conf: no endpoint" end + -- 单个 [Peer] 缺 Endpoint(或 Endpoint 解析不出端口)只跳过它自己: + -- 旧实现直接 return nil,一个坏段就让整份 .conf 作废,同文件里其它完好的 + -- 对端全部丢失 —— 与「一个坏节点不该废掉整个订阅」同一原则。 + if host and host ~= "" and port then + local out = {} + for k, v in pairs(common) do + if type(v) == "table" then out[k] = copy1(v) else out[k] = v end + end + out.server = host + out.port = tonumber(port) + out["public-key"] = p["publickey"] + out["pre-shared-key"] = p["presharedkey"] + out["persistent-keepalive"] = tonumber(p["persistentkeepalive"]) - local out = {} - for k, v in pairs(common) do out[k] = v end - out.server = host - out.port = tonumber(port) - out["public-key"] = p["publickey"] - out["pre-shared-key"] = p["presharedkey"] - out["persistent-keepalive"] = tonumber(p["persistentkeepalive"]) + -- AllowedIPs 拆分数组 + local allowed = {} + for a in (p["allowedips"] or ""):gmatch("[^,]+") do + a = util.trim(a) + if a ~= "" then allowed[#allowed + 1] = a end + end + if #allowed > 0 then out["allowed-ips"] = allowed end - -- AllowedIPs 拆分数组 - local allowed = {} - for a in (p["allowedips"] or ""):gmatch("[^,]+") do - a = util.trim(a) - if a ~= "" then allowed[#allowed + 1] = a end + -- [Peer] 段的 AmneziaWG 参数(AdvancedSecurity)不在此处解析: + -- 它属于 [Peer] 段,而本项目的 amnezia-wg-option 只对应 [Interface] 段, + -- 合并进来会在导出时被写到 [Interface] 下(错误段落)。宁可不支持, + -- 也不要产出一份键位置错误的 .conf(§110)。 + if next(awg) then out["amnezia-wg-option"] = copy1(awg) end + + nodes[#nodes + 1] = node.normalize(out) end - if #allowed > 0 then out["allowed-ips"] = allowed end - - -- [Peer] 段的 AmneziaWG 参数(AdvancedSecurity)不在此处解析: - -- 它属于 [Peer] 段,而本项目的 amnezia-wg-option 只对应 [Interface] 段, - -- 合并进来会在导出时被写到 [Interface] 下(错误段落)。宁可不支持, - -- 也不要产出一份键位置错误的 .conf(§110)。 - -- 每个节点各拿一份副本:共享同一个表会让后续修改一处影响全部节点。 - if next(awg) then - local o = {} - for k, v in pairs(awg) do o[k] = v end - out["amnezia-wg-option"] = o - end - - nodes[#nodes + 1] = node.normalize(out) end + -- 一个可用 [Peer] 都没有时报错。此处 #peers > 0 已由上面保证,走到这里说明 + -- 每个 [Peer] 都缺 Endpoint。不能返回空列表:空列表会被上层当成「解析成功但 + -- 0 节点」,用户看到更新成功却一个节点都没有(H4/H5 的静默失败形态)。 + if #nodes == 0 then return nil, "bad wireguard conf: no usable [Peer] endpoint" end + return nodes end @@ -746,15 +808,39 @@ local function parse_json_content(content) if data[1] then list = data else list = { data } end local nodes = {} for _, o in ipairs(list) do - if type(o) == "table" and o.server and o.port then - nodes[#nodes + 1] = node.normalize({ - proto = o.proto or "vmess", - name = o.name or (o.server .. ":" .. o.port), - server = o.server, port = tonumber(o.port), - uuid = o.uuid, password = o.password, method = o.method, - net = o.net, security = o.security, sni = o.sni, - raw = o.raw, - }) + -- 协议名可能写在 proto 或 type 里:表单导入落盘的 JSON 用的就是 type + -- (见 M.parse_local 的 form 分支),同一份 JSON 走文本导入也必须解析出 + -- 同一个协议,否则 `{"type":"vless",…}` 会被当成 vmess。 + -- 两者都没有时保留旧的 vmess 兜底;type 存在但不在 TYPE_MAP 里,说明这是 + -- 统一模型承载不了的协议,丢弃而不是兜底成 vmess(理由见 H6 的说明: + -- 凭空造一个字段全错的假节点比丢弃更糟)。 + local proto, type_is_proto = o.proto, false + if proto == nil or proto == "" then + if o.type == nil or o.type == "" then + proto = "vmess" + else + proto = parser_clash_yaml.TYPE_MAP[o.type] + type_is_proto = true + end + end + if proto and type(o) == "table" and valid_hostport(o.server, o.port) then + -- 与 M.parse_local 的 form 分支同样「透传全部字段」:这里原来是硬编码 + -- 白名单,只搬 proto/name/server/port/uuid/password/method/net/security/sni, + -- 于是 path / host / cipher / alterId / flow / fp / alpn / tls / obfs 等 + -- 全被静默丢掉——ws 节点丢掉 path 就连不上。字段清单散落成多份必然漂移, + -- 统一由 node.normalize 收口。 + local n = {} + for k, v in pairs(o) do n[k] = v end + n.proto = proto + -- 只有真的把 type 当作协议名消费掉时才清空它,避免误删 vmess 的 + -- header type(output_uri 会读 n.type or n.headerType) + if type_is_proto then n.type = nil end + n.port = tonumber(n.port) or n.port + if n.alterId ~= nil then n.alterId = tonumber(n.alterId) or n.alterId end + if n.name == nil or n.name == "" then + n.name = (n.server or "") .. ":" .. tostring(n.port or "") + end + nodes[#nodes + 1] = node.normalize(n) end end return nodes, nil @@ -769,14 +855,95 @@ local function parse_yaml_content(content) local in_proxies = false local proxies_indent = 0 - for i, line in ipairs(lines) do + -- 读取从 start 行开始、缩进大于 min_indent 的 `k: v` 映射,递归处理嵌套 map + -- (sing-box 的 tls: {enabled, server_name, utls: {fingerprint}} 就有两层)。 + -- 返回 (map, 下一个未消费的行号)。 + -- + -- 单层收集是不够的:内层键会被摊平进外层,`utls.enabled` 会覆盖 `tls.enabled`, + -- `utls.fingerprint` 会变成 `tls.fingerprint`——取值看似还在,语义已经错了。 + -- + -- nlines 必须在这里就声明:read_map 是局部函数,闭包捕获的是此刻可见的 + -- 变量;若 nlines 声明在后面,函数体里的 nlines 会被解析成全局变量。 + local nlines = #lines + -- 读取从 start 行开始、缩进大于 min_indent 的 `- item` 序列。 + -- 必须先于 read_map 声明:Lua 的局部函数只看得见「声明在它之前」的局部变量, + -- 顺序反了 read_seq 会解析成全局变量。 + -- + -- 序列必须先于嵌套 map 尝试:read_map 碰到 `- ` 开头的行会立刻 break, + -- 于是 `alpn:` 这种序列键会让整个子块从该行起被截断——不只 alpn 丢失, + -- 排在它后面的键(sing-box 的 utls.fingerprint 等)也一并消失。 + local function read_seq(start, min_indent) + local seq, j = {}, start + while j <= nlines do + local l = lines[j] + local t = util.trim(l) + if t == "" then + j = j + 1 + else + local ind = #(l:match("^%s*") or "") + if ind <= min_indent then break end + local item = t:match("^%-%s*(.*)$") + if not item then break end + seq[#seq + 1] = item:gsub('^["\'](.*)["\']$', '%1') + j = j + 1 + end + end + return seq, j + end + + local function read_map(start, min_indent) + local map, j = {}, start + while j <= nlines do + local l = lines[j] + local t = util.trim(l) + if t == "" then + j = j + 1 + else + local ind = #(l:match("^%s*") or "") + if ind <= min_indent or t:match("^%-") then break end + local k, v = t:match("^([^:]+):%s*(.*)$") + if not k then break end + k, v = util.trim(k), util.trim(v) + if v ~= "" then + -- 去掉引号 + map[k] = v:gsub('^["\'](.*)["\']$', '%1') + j = j + 1 + else + -- 先试序列,再试嵌套 map(见 read_seq 的注释:顺序不能反) + local seq, sj = read_seq(j + 1, ind) + if next(seq) then + map[k] = seq + j = sj + else + local sub, nj = read_map(j + 1, ind) + if next(sub) then + map[k] = sub + j = nj + else + -- 空值且不是嵌套 map / 序列:保持旧行为(跳过该键) + j = j + 1 + end + end + end + end + end + return map, j + end + + -- 用 while 而不是 for:值为空的键要向后收集嵌套 map(sing-box 的 + -- `tls:` 块),必须能一次吃掉多行。Lua 的数值 for 每轮都会重新赋值控制 + -- 变量,循环体内改 i 不生效;Lua 5.1 也没有 goto。 + local i = 1 + while i <= nlines do + local line = lines[i] local trimmed = util.trim(line) if trimmed == "" or trimmed:sub(1, 1) == "#" then - -- 跳过空行和注释 + i = i + 1 elseif trimmed:match("^proxies:%s*$") or trimmed:match("^outbounds:%s*$") then in_proxies = true - proxies_indent = line:match("^%s*") and #line:match("^%s*") or 0 + proxies_indent = #(line:match("^%s*") or "") current_node = nil + i = i + 1 elseif in_proxies then -- 检测列表项开始:- name: xxx 或单独的 - local list_match = line:match("^%s*%-%s*(.*)$") @@ -787,21 +954,44 @@ local function parse_yaml_content(content) end current_node = {} -- 检查列表项同一行是否有字段 - if list_match:match("^([^:]+):%s*(.+)$") then - local k, v = list_match:match("^([^:]+):%s*(.+)$") - current_node[k] = util.trim(v) - end + local k, v = list_match:match("^([^:]+):%s*(.+)$") + if k then current_node[util.trim(k)] = util.trim(v) end + i = i + 1 else -- 解析缩进的字段 - local indent = line:match("^%s*") and #line:match("^%s*") or 0 + local indent = #(line:match("^%s*") or "") if current_node and indent > proxies_indent then - local k, v = trimmed:match("^([^:]+):%s*(.+)$") - if k then + local k, v = trimmed:match("^([^:]+):%s*(.*)$") + if not k then + i = i + 1 + else k = util.trim(k) v = util.trim(v) - -- 去掉引号 - v = v:gsub('^["\'](.*)["\']$', '%1') - current_node[k] = v + if v ~= "" then + -- 去掉引号 + current_node[k] = v:gsub('^["\'](.*)["\']$', '%1') + i = i + 1 + else + -- 值为空:可能是嵌套 map(sing-box 的 tls: {enabled: true, …})。 + -- 原先这类行直接被跳过,于是 security 永远读不到,TLS 整层丢失: + -- 导出的 sing-box 配置里 trojan/vmess/vless 静默退化成明文, + -- hysteria2/tuic 更让客户端以 C.ErrTLSRequired 拒绝启动。 + -- 先试序列(alpn: 后面跟 - h2),再试嵌套 map(tls: {...}) + local seq, sj = read_seq(i + 1, indent) + if next(seq) then + current_node[k] = seq + i = sj + else + local sub, nj = read_map(i + 1, indent) + if next(sub) then + current_node[k] = sub + i = nj + else + -- 空值且不是嵌套 map / 序列:保持旧行为(跳过该键) + i = i + 1 + end + end + end end else -- 遇到缩进减少,结束当前节点 @@ -810,8 +1000,11 @@ local function parse_yaml_content(content) current_node = nil end in_proxies = false + i = i + 1 end end + else + i = i + 1 end end @@ -860,10 +1053,49 @@ local function parse_yaml_content(content) local skip_cert_verify = nil if scv == true or scv == "true" or scv == "1" then skip_cert_verify = true elseif scv == false or scv == "false" or scv == "0" then skip_cert_verify = false end + -- sing-box 的 tls 是一个嵌套 map(tls: {enabled, server_name, insecure, alpn}), + -- 上面收集成子表后在这里展开。字段名对照 parser_json_config.parse_singbox_json + -- (本项目读取 sing-box 出站的权威实现),不另立一套。 + local tls_map = type(n.tls) == "table" and n.tls or nil + if tls_map then + -- enabled 是显式开关:sing-box 的 OutboundTLSOptions.Enabled 默认 false, + -- 只有 tls 块存在并不代表启用 + local enabled = tls_map.enabled + if enabled == true or enabled == "true" or enabled == "1" then + if tls_map.reality and tls_map.reality ~= "" and tls_map.reality ~= false then + n.security = "reality" + else + n.security = "tls" + end + elseif tls_map.reality and tls_map.reality ~= "" and tls_map.reality ~= false then + n.security = "reality" + end + -- server_name / insecure 只在节点顶层没有对应字段时补 + if (n.sni == nil or n.sni == "") and tls_map.server_name then n.sni = tls_map.server_name end + if scv == nil and tls_map.insecure ~= nil then + local ins = tls_map.insecure + if ins == true or ins == "true" or ins == "1" then skip_cert_verify = true + elseif ins == false or ins == "false" or ins == "0" then skip_cert_verify = false end + end + if n.alpn == nil and tls_map.alpn ~= nil then n.alpn = tls_map.alpn end + -- sing-box 的 utls 也是嵌套 map:{enabled, fingerprint} + local utls = tls_map.utls + if n.fp == nil and type(utls) == "table" then + local ue = utls.enabled + if (ue == true or ue == "true" or ue == "1") and utls.fingerprint ~= nil then + n.fp = utls.fingerprint + end + end + end -- 注意不能用 `(cond) and nil or x` 写法:Lua 的 and/or 在 cond 为真时 -- 结果是 nil,会被后面的 or 继续兜底,等于没生效 local tls_security = n.security if vmess_cipher ~= nil then tls_security = nil end + -- 同上:嵌套 map 已展开,标量 tls 才继续往下传。 + -- 这里同样不能写 `(type(x)=="table") and nil or x` —— cond 为真时得到 + -- `true and nil` = nil,再被 `or x` 兜回 x,等于没生效。 + local tls_scalar = n.tls + if type(tls_scalar) == "table" then tls_scalar = nil end local node_data = { proto = proto, name = n.name or n.Name or n.tag or (n.server .. ":" .. tostring(port)), @@ -876,11 +1108,21 @@ local function parse_yaml_content(content) -- Clash 的 cipher 是 vmess 加密方式;tls 才是 TLS 层, -- 交由 node.normalize 归一到 security cipher = vmess_cipher, - tls = n.tls, + -- 嵌套 map 已在上面展开成 security/sni/… ,这里只透传标量写法。 + -- 传 table 下去会被下游的 `if n.tls then` 当成「已启用 TLS」 + -- (Lua 里 table 恒为真值),tls.enabled=false 也会被判成启用。 + tls = tls_scalar, -- sing-box 的 vmess.security 已被上面消费为 cipher,不再当 TLS 层 security = tls_security, -- Clash 写 sni / servername,sing-box 的 tls 子块写 server_name sni = n.sni or n.servername or n.server_name, + -- 下面三项在 tls 子块里展开后挂在 n 上,必须显式带进 node_data, + -- 否则赋值后立刻被丢掉(n 只是个中间收集表,不参与最终结果)。 + -- 字段名对照 parser_json_config.parse_singbox_json(本项目读取 + -- sing-box 出站的权威实现):那边同样读 flow 与 tls.alpn。 + flow = n.flow, + alpn = n.alpn, + fp = n.fp, -- sing-box 的 tls.insecure 对应 skip-cert-verify。 -- 行解析器读出来的是字符串,而 "false" / "0" 在 Lua 里也是真值, -- 直接透传会让 skip-cert-verify=false 变成「跳过证书校验」,故显式判假。 @@ -904,20 +1146,89 @@ end -- ---------- 局域网订阅链接 ---------- +-- 把 IPv6 字面量展开成 8 组 16 位数值(仅用于内网判定);无法解析时返回 nil。 +-- 必须展开而不是比字符串前缀:`::1` / `0::1` / `0000::1` / `0:0:0:0:0:0:0:1` +-- 是同一个地址的不同写法,前缀比较会全部漏判(旧实现即如此,`[0::1]` 被判成公网)。 +local function expand_v6(s) + s = s:match("^([^%%]*)") or s -- 丢弃 zone id(fe80::1%eth0) + local tail4 + local head, a, b, c, d = s:match("^(.*):(%d+)%.(%d+)%.(%d+)%.(%d+)$") + if head then + a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d) + if a > 255 or b > 255 or c > 255 or d > 255 then return nil end + tail4 = { a * 256 + b, c * 256 + d } + s = head + end + + local function collect(part) + local out = {} + for g in part:gmatch("[^:]+") do + if #g > 4 or not g:match("^%x+$") then return nil end + out[#out + 1] = tonumber(g, 16) + end + return out + end + + local groups = {} + local left, right = s:match("^(.*)::(.*)$") + if left then + if left:find("::", 1, true) or right:find("::", 1, true) then return nil end + local lg, rg = collect(left), collect(right) + if not lg or not rg then return nil end + local zeros = 8 - #lg - #rg - (tail4 and 2 or 0) + if zeros < 1 then return nil end + for i = 1, #lg do groups[#groups + 1] = lg[i] end + for _ = 1, zeros do groups[#groups + 1] = 0 end + for i = 1, #rg do groups[#groups + 1] = rg[i] end + else + local flat = collect(s) + if not flat then return nil end + for i = 1, #flat do groups[#groups + 1] = flat[i] end + end + if tail4 then + groups[#groups + 1] = tail4[1] + groups[#groups + 1] = tail4[2] + end + if #groups ~= 8 then return nil end + return groups +end + -- 判断主机是否为内网 / 回环 / 链路本地地址(SSRF 防护) local function is_private_host(host) if not host then return false end host = host:lower() - if host == "localhost" or host == "::" then return true end + if host == "localhost" then return true end -- IPv6:去掉方括号 local v6 = host:match("^%[([^%]]+)%]$") if v6 then host = v6 end if host:find(":", 1, true) then - local h = host:gsub("^0*", "") - if h == "" or h == "1" then return true end -- :: 或 ::1 - return host:match("^::") ~= nil - or host:match("^f[cd]") ~= nil - or host:match("^fe[89ab]") ~= nil + local g = expand_v6(host) + if not g then return false end + -- 未指定地址 :: 与回环 ::1(含 0::1 / 0000::1 / 0:0:0:0:0:0:0:1 等写法) + local all_zero = true + for i = 1, 7 do + if g[i] ~= 0 then + all_zero = false + break + end + end + if all_zero and g[8] <= 1 then return true end + -- IPv4 映射 / 兼容地址(::ffff:a.b.c.d、::a.b.c.d)按 IPv4 规则判定 + local mapped = true + for i = 1, 5 do + if g[i] ~= 0 then + mapped = false + break + end + end + if mapped and (g[6] == 0 or g[6] == 0xffff) then + return is_private_host(string.format("%d.%d.%d.%d", + math.floor(g[7] / 256), g[7] % 256, math.floor(g[8] / 256), g[8] % 256)) + end + -- ULA fc00::/7 与链路本地 fe80::/10 + if g[1] >= 0xfc00 and g[1] <= 0xfdff then return true end + if g[1] >= 0xfe80 and g[1] <= 0xfebf then return true end + return false end local a, b, c, d = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") if not a then return false end @@ -960,9 +1271,17 @@ function M.parse_local_link(url) if not scheme then return nil end scheme = scheme:lower() - local authority, remainder = rest:match("^([^/]*)(.*)$") + -- authority 止于 / 或 ?:`http://host?target=x` 这种「没有路径、直接跟 query」 + -- 的写法,旧模式 `[^/]*` 会把 "host?target=x" 整串当成 authority, + -- 于是 host 变成 "host?target=x"、query 全丢(target/name/uid 都没了)。 + local authority, remainder = rest:match("^([^/?]*)(.*)$") if authority == nil then authority, remainder = rest, "" end + -- 去掉 userinfo@(detect_local_link 一直这么做,此处此前漏了) + if authority:find("@", 1, true) then + authority = authority:match("^.*@(.*)$") or "" + end + -- 提取 host 与 port local host, port if authority:sub(1, 1) == "[" then @@ -1006,7 +1325,13 @@ end function M.parse(content) if not content or content == "" then return { nodes = {}, format = "empty" } end local format = M.detect(content) - if format == "uri" then + if format == "empty" then + -- 纯空白 / 只有 BOM 的内容:detect 会 trim 并去 BOM 后判为 empty,而上面的 + -- `content == ""` 只挡住了完全空串。没有这个分支时,一份「全是空白」的订阅 + -- 会一路掉到末尾报「无法识别的订阅格式」—— 对用户是误导,内容确实是空的, + -- 不是格式不认识。与空串保持同一返回形态(0 节点、格式 empty)。 + return { nodes = {}, format = "empty" } + elseif format == "uri" then return { nodes = parse_lines(split_lines(content)), format = "uri" } elseif format == "base64" then -- 兼容标准 base64 与 base64url(- _ 无 padding):base64_url_decode 两者皆可 diff --git a/luci-app-substore/root/usr/share/substore/parser_surge.lua b/luci-app-substore/root/usr/share/substore/parser_surge.lua index 57d702032..f0deee811 100644 --- a/luci-app-substore/root/usr/share/substore/parser_surge.lua +++ b/luci-app-substore/root/usr/share/substore/parser_surge.lua @@ -3,9 +3,23 @@ local util = require("substore.util") local node = require("substore.node") +-- 协议白名单复用 Clash YAML 那张表:两边都是「外部格式的类型名 → 统一模型 proto」, +-- 各维护一份必然漂移。TYPE_MAP 的键就是这里要判定的原始类型名。 +local TYPE_MAP = require("substore.parser_clash_yaml").TYPE_MAP local M = {} +-- 把 Surge / QX 行里的类型名映射到统一模型 proto,未知类型返回 nil。 +-- 与 parser_clash_yaml 的未知协议处理保持一致(见那边的长注释):snell / ssh / +-- shadowtls / mieru 这类在统一模型里没有对应字段,原样透传会在输出端变成 +-- sing-box 的 type: "snell"、Xray 的 protocol: "snell" 这类非法取值, +-- 客户端会拒绝加载整份配置;兜底成 vmess 更糟(凭空造出字段全错的假节点)。 +-- 两者都比丢弃差,所以丢弃。 +local function map_proto(raw) + if type(raw) ~= "string" then return nil end + return TYPE_MAP[raw:lower()] +end + -- 解析 Surge 风格行:Name = proto, server, port, k=v, ... local function parse_surge_line(name, rest) local parts = {} @@ -14,8 +28,8 @@ local function parse_surge_line(name, rest) end if #parts < 3 then return nil end - local proto = parts[1]:lower() - if proto == "ss" then proto = "shadowsocks" end + local proto = map_proto(parts[1]) + if not proto then return nil end local server = parts[2] local port = tonumber(parts[3]) @@ -74,8 +88,8 @@ local function parse_qx_line(content) end local name = kv.tag or (host .. ":" .. tostring(port or "")) - proto = proto:lower() - if proto == "ss" then proto = "shadowsocks" end + local proto = map_proto(proto) + if not proto then return nil end local data = { proto = proto, name = name, server = host, port = tonumber(port) } if proto == "shadowsocks" then @@ -120,12 +134,17 @@ function M.parse(content) return nodes end --- 判断内容是否为客户端配置文件 +-- 判断内容是否为客户端配置文件。 +-- 段名大小写不敏感:Surge 官方配置写 `[Proxy]`,但 Loon / 第三方转换器常写 +-- `[PROXY]` / `[General]` 这类全大写段名。原来的 find("%[Proxy%]") 只认大小写 +-- 完全一致的写法,于是 detect 不认为这是 Surge 配置,继续往下走—— +-- 而 `[` 开头的内容会被判成 JSON 数组,整份订阅报「JSON 解析失败」, +-- 一个节点都拿不到(M.parse 本身是按小写段名匹配的,本来就能解析)。 function M.is_config(content) content = content or "" - return content:find("%[Proxy%]") ~= nil - or content:find("%[proxy%]") ~= nil - or content:find("%[server_local%]") ~= nil + local lower = content:lower() + return lower:find("[proxy]", 1, true) ~= nil + or lower:find("[server_local]", 1, true) ~= nil end return M \ No newline at end of file diff --git a/luci-app-substore/root/usr/share/substore/probe.lua b/luci-app-substore/root/usr/share/substore/probe.lua index 589f85f5b..63c5ff864 100644 --- a/luci-app-substore/root/usr/share/substore/probe.lua +++ b/luci-app-substore/root/usr/share/substore/probe.lua @@ -15,7 +15,14 @@ local function q(s) return util.shq(s) end -- 校验目标主机名/IP:仅允许 [A-Za-z0-9._-] 与冒号(IPv6),杜绝命令注入 local function safe_host(host) - return type(host) == "string" and host ~= "" and host:match("^[%w%.%-%:]+$") ~= nil + if type(host) ~= "string" or host == "" then return false end + if not host:match("^[%w%.%-%:]+$") then return false end + -- 以 "-" 开头会被 busybox 的 getopt 当成**选项**而不是参数: + -- 命令是 `ping -c 1 -W 2 `,host="--help" / "-c" 会改变 ping 的行为。 + -- 引号(util.shq)挡不住这个 —— 引号由 shell 剥掉,getopt 看到的仍是 -x。 + -- 合法主机名(RFC 1123 要求首字符为字母或数字)与 IP 都不会以 "-" 开头。 + if host:sub(1, 1) == "-" then return false end + return true end -- 构造 http URL:IPv6 字面量必须写成 [addr]:port,否则 "2001:db8::1:443" 不是合法 URL, diff --git a/luci-app-substore/root/usr/share/substore/util.lua b/luci-app-substore/root/usr/share/substore/util.lua index acd5b34be..b6e94ce49 100644 --- a/luci-app-substore/root/usr/share/substore/util.lua +++ b/luci-app-substore/root/usr/share/substore/util.lua @@ -157,6 +157,12 @@ function M.split_hostport(s) else local host, port = s:match("^([^:]+):(%d+)$") if host then return host, port end + -- 没有可识别的端口,整串就是主机名 —— 但必须先剥掉**尾随**冒号: + -- `"example.com:"` 此前原样返回 host="example.com:",调用方拿这个带 + -- 冒号的串当主机名,DNS 解析必然失败,而失败点离这里很远、很难定位。 + -- 只剥结尾的冒号,`"::1"` 这类不带方括号的裸 IPv6 不受影响。 + s = s:gsub(":$", "") + if s == "" then return nil, nil end return s, nil end end @@ -218,6 +224,12 @@ function M.json_encode(v) elseif t == "table" then if v == M.JSON_EMPTY_OBJECT then return "{}" + elseif v == JSON_NULL then + -- 数组里的 null 解码时被换成 JSON_NULL 占位(见 json_decode), + -- 编码时必须还原成 null。此前没有这个分支,占位符落进下面的 + -- is_array 判定(空表判为数组)被编成 [],于是 `[null,1]` 往返 + -- 变成 `[[],1]` —— 结构被悄悄改写。 + return "null" elseif is_array(v) then local a = {} for i = 1, #v do a[#a + 1] = enc(v[i]) end @@ -256,15 +268,21 @@ function M.json_decode(s) i = i + 1 local obj = {} skip_ws() - if s:sub(i, i) == "}" then i = i + 1 return obj end + -- 空对象必须返回 JSON_EMPTY_OBJECT 而不是裸 {}:Lua 的空表无法区分 + -- {} 与 [],裸 {} 会被 is_array 判成数组、编码回 [],于是 + -- `{"tls":{}}` 往返变成 `{"tls":[]}` —— sing-box / Xray 里 + -- 要求是对象的字段(tls / settings)会因此被客户端拒绝。 + if s:sub(i, i) == "}" then i = i + 1 return M.JSON_EMPTY_OBJECT end while true do skip_ws() local k, ke = parse() + if ke then return nil, ke end if not k or type(k) ~= "string" then return nil, "expected string key" end skip_ws() if s:sub(i, i) ~= ":" then return nil, "expected ':'" end i = i + 1 - local val = parse() + local val, verr = parse() + if verr then return nil, verr end obj[k] = val skip_ws() local cc = s:sub(i, i) @@ -284,7 +302,11 @@ function M.json_decode(s) skip_ws() if s:sub(i, i) == "]" then i = i + 1 return arr end while true do - local val = parse() + local val, verr = parse() + -- 必须把内层错误透出去:此前无条件把 nil 当成 null 占位, + -- 于是 `[1,]` 这种畸形输入被静默接受(parse 报错后 i 未前进, + -- 下一轮读到 `]` 就当成数组结束),解出 `{1, JSON_NULL}`。 + if verr then return nil, verr end if val == nil then val = JSON_NULL end arr[#arr + 1] = val skip_ws() @@ -358,7 +380,17 @@ function M.json_decode(s) end end - local v = parse() + local v, perr = parse() + -- 此前写成 `local v = parse(); return v` —— parse 的第二返回值(错误串) + -- 被直接丢弃,于是 json_decode **永远不返回错误**:所有调用方的 + -- `local data, err = util.json_decode(...)` 里 err 判断都是死代码, + -- 畸形输入一律表现为「解出来是 nil」,与「内容就是 null」无法区分。 + if perr then return nil, perr end + -- 解析出一个值还不够,必须确认**没有剩余内容**:parse 只消费一个值, + -- 于是 "1 2" / "[1] junk" / "1.2.3"(数字模式只吃 1.2)这类输入会被 + -- 当成合法值静默接受,尾部的脏数据凭空消失。 + skip_ws() + if i <= len then return nil, "trailing content at position " .. i end return v end diff --git a/luci-app-substore/tests/controller_local_test.lua b/luci-app-substore/tests/controller_local_test.lua index 2bd0d833f..46ff249e8 100644 --- a/luci-app-substore/tests/controller_local_test.lua +++ b/luci-app-substore/tests/controller_local_test.lua @@ -59,6 +59,10 @@ package.loaded["substore.core"] = { sync = function() SYNC_CALLS = SYNC_CALLS + 1; return CORE_RESULT.sync[1], CORE_RESULT.sync[2] end, write_cron = function() end, cron_time_valid = function() return true end, + -- read_rules_fields 会遍历它收集 proto_filter_*。这里只是 stub:真实清单的 + -- 内容/规范性由 tests/rules_fields_test.lua 对着 core.lua 校验,本文件只保证 + -- 控制器在「清单存在」的前提下能正常跑完(缺了它 read_rules_fields 会崩)。 + RULE_PROTOS = { "vmess", "trojan" }, } -- 载入控制器(module() 会在 package.loaded 里建表,不需要真实 luci 模块) diff --git a/luci-app-substore/tests/controller_robustness_test.lua b/luci-app-substore/tests/controller_robustness_test.lua new file mode 100644 index 000000000..b2cbe9b82 --- /dev/null +++ b/luci-app-substore/tests/controller_robustness_test.lua @@ -0,0 +1,245 @@ +-- controller_robustness_test.lua — 控制器健壮性回归测试 +-- 用法:lua5.1 tests/controller_robustness_test.lua +-- +-- 覆盖两项: +-- L3 重复表单字段(POST)会让 formvalue 返回 **table** 而非 string, +-- 控制器直接对它 :gsub / util.trim 会抛错 → HTTP 500。 +-- 依据上游 luci/http.lua 的 urldecode_message_body: +-- elseif what == parser.VALUE and name then +-- local val = msg.params[name] +-- if type(val) == "table" then val[#val+1] = ... +-- elseif val ~= nil then msg.params[name] = { val, ... } -- ← 变 table +-- 注意 GET 查询串走的是 urldecode_params,它只做覆盖、不建表, +-- 所以触发条件是 POST 重复字段。 +-- L4 combo_save / delete / update / node_delete 的失败被静默吞掉, +-- 违反项目 §18(失败必须让用户看见)。 +-- +-- 不需要真实 LuCI:用最小 stub 提供 luci.http / luci.dispatcher / luci.util, +-- substore.core 换成可控假实现,然后直接调用控制器的 action_* 函数。 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local passed, failed = 0, 0 +local function check(name, cond) + if cond then passed = passed + 1; print("PASS " .. name) + else failed = failed + 1; print("FAIL " .. name) end +end + +-- ---------- luci stub ---------- +local FORM = {} +local LAST_REDIRECT = nil +local LAST_WRITE = nil + +_G.luci = { + http = { + formvalue = function(k) return FORM[k] end, + redirect = function(url) LAST_REDIRECT = url end, + write = function(s) LAST_WRITE = s end, + status = function() end, + header = function() end, + prepare_content = function() end, + }, + dispatcher = { + build_url = function(...) + local t = { ... } + return "/" .. table.concat(t, "/") + end, + }, + util = { + urlencode = function(s) + return (tostring(s or ""):gsub("[^%w%-%._~]", function(c) + return string.format("%%%02X", string.byte(c)) + end)) + end, + pcdata = function(s) return tostring(s or "") end, + }, + i18n = { translate = function(s) return s end }, +} +_G.luci.controller = { admin = {} } + +package.loaded["luci.http"] = _G.luci.http +package.loaded["luci.dispatcher"] = _G.luci.dispatcher +package.loaded["luci.util"] = _G.luci.util +package.loaded["luci.i18n"] = _G.luci.i18n + +-- ---------- substore 依赖 stub ---------- +local CORE = {} +package.loaded["substore.core"] = CORE +package.loaded["substore.node"] = { + filter = function(ns) return ns end, + sort = function(ns) return ns end, +} +package.loaded["substore.probe"] = { + probe = function() return {} end, +} + +local function core_reset() + CORE.add = function() return "s0000000a" end + CORE.add_local = function() return "s0000000a" end + CORE.remove = function() return true end + CORE.add_combo = function() return "s0000000a" end + CORE.save_combo = function() return "s0000000a" end + CORE.get = function() return { id = "s00000001", name = "x" } end + CORE.list = function() return {} end + CORE.read_nodes = function() return {} end + CORE.write_nodes = function() return true end + CORE.save_meta = function() return true end + CORE.refresh_combos = function() end + CORE.sync = function() return true end + CORE.write_cron = function() end + CORE.cron_time_valid = function() return true end + CORE.generate_link = function() return "body", "text/plain", "f.txt" end + CORE.RULE_PROTOS = { "vmess", "trojan" } +end +core_reset() + +local CTL_PATH = os.getenv("SUBSTORE_CONTROLLER") + or "root/usr/lib/lua/luci/controller/admin/substore.lua" +local ok, err = pcall(dofile, CTL_PATH) +check("controller loads under stub", ok) +if not ok then print(" load error: " .. tostring(err)) end +local ctl = package.loaded["luci.controller.admin.substore"] or {} + +local function reset() + FORM = {} + LAST_REDIRECT = nil + LAST_WRITE = nil + core_reset() +end + +local function has_err() + return type(LAST_REDIRECT) == "string" and LAST_REDIRECT:find("err=", 1, true) ~= nil +end + +-- 所有 action 的入口清单(带一组能走到底的合法表单) +local ACTIONS = { + { "action_create", { token = "t", name = "n", url = "http://e.com/s" } }, + { "action_save", { token = "t", id = "s00000001", name = "n", url = "http://e.com/s" } }, + { "action_local_create", { token = "t", name = "n", content = "vmess://x" } }, + { "action_local_save", { token = "t", id = "s00000001", name = "n", content = "vmess://x" } }, + { "action_delete", { token = "t", id = "s00000001" } }, + { "action_node_save", { token = "t", id = "s00000001", idx = "1", content = "[]" } }, + { "action_node_delete", { token = "t", id = "s00000001", idx = "1" } }, + { "action_node_set_group", { token = "t", id = "s00000001", idx = "1", group = "g" } }, + { "action_combo_save", { token = "t", name = "n" } }, + { "action_update", { token = "t", id = "s00000001" } }, + { "action_probe", { token = "t", id = "s00000001", mode = "ping" } }, + { "action_download", { token = "t", target = "ClashMeta" } }, +} + +-- ---------- L3:每个字段都重复提交一次,action 不得抛错 ---------- +-- 真实场景:POST body 里同名字段出现两次(表单里出现同名 input, +-- 或攻击者手工构造)。此时 formvalue 返回 {v1, v2}。 +for _, a in ipairs(ACTIONS) do + local action, form = a[1], a[2] + reset() + local dup = {} + for k, v in pairs(form) do dup[k] = { v, v .. "2" } end + FORM = dup + local ok2, e = pcall(ctl[action]) + check(action .. " survives duplicated form fields" .. (ok2 and "" or (" (" .. tostring(e) .. ")")), ok2) +end + +-- 同一个 action 里全部字段都重复(最坏情况) +reset() +FORM = {} +for k, v in pairs(ACTIONS[1][2]) do FORM[k] = { v, v } end +FORM.id = { "s00000001", "s00000002" } +local ok3, e3 = pcall(ctl.action_save) +check("action_save survives all-duplicated fields" .. (ok3 and "" or (" (" .. tostring(e3) .. ")")), ok3) + +-- 重复字段取值语义:取最后一个(后者覆盖前者),而不是报错或取第一个 +reset() +FORM = { token = "t", id = "s00000001", name = { "first", "second" }, url = "http://e.com/s" } +local seen_name = nil +CORE.save_meta = function(_, patch) seen_name = patch.name; return true end +pcall(ctl.action_save) +check("duplicated field keeps last value", seen_name == "second") + +-- ---------- L4:失败必须回显 ---------- +-- action_delete:core.remove 返回 false +reset() +CORE.remove = function() return false, "订阅不存在" end +FORM = { token = "t", id = "s00000001" } +ctl.action_delete() +check("delete failure reports error", has_err()) + +-- action_delete:成功路径不带 err +reset() +FORM = { token = "t", id = "s00000001" } +ctl.action_delete() +check("delete success has no err", not has_err()) + +-- action_combo_save:名称为空 +reset() +FORM = { token = "t", name = "" } +ctl.action_combo_save() +check("combo_save empty name reports error", has_err()) + +-- action_combo_save:add_combo 失败 +reset() +CORE.add_combo = function() return nil, "请选择至少一个订阅" end +FORM = { token = "t", name = "n" } +ctl.action_combo_save() +check("combo_save create failure reports error", has_err()) + +-- action_combo_save:save_combo 失败 +reset() +CORE.save_combo = function() return nil, "非法 ID" end +FORM = { token = "t", id = "s00000001", name = "n" } +ctl.action_combo_save() +check("combo_save update failure reports error", has_err()) + +-- action_combo_save:成功路径不带 err +reset() +FORM = { token = "t", name = "n" } +ctl.action_combo_save() +check("combo_save success has no err", not has_err()) + +-- action_update:订阅不存在 +reset() +CORE.get = function() return nil end +FORM = { token = "t", id = "s00000001" } +ctl.action_update() +check("update missing subscription reports error", has_err()) + +-- action_update:sync 失败仍然写回 error(原有行为,加守卫) +reset() +local written = nil +CORE.sync = function() return nil, "无法识别的订阅格式" end +CORE.save_meta = function(_, patch) written = patch; return true end +FORM = { token = "t", id = "s00000001" } +ctl.action_update() +check("update sync failure writes error to meta", + written ~= nil and written.error == "无法识别的订阅格式") + +-- action_node_delete:没有可解析的下标 +reset() +FORM = { token = "t", id = "s00000001", idx = "abc" } +ctl.action_node_delete() +check("node_delete no valid idx reports error", has_err()) + +-- action_node_delete:下标越界(节点不存在) +reset() +CORE.read_nodes = function() return { { name = "only" } } end +FORM = { token = "t", id = "s00000001", idx = "9" } +ctl.action_node_delete() +check("node_delete out-of-range idx reports error", has_err()) + +-- action_node_delete:写盘失败 +reset() +CORE.read_nodes = function() return { { name = "a" }, { name = "b" } } end +CORE.write_nodes = function() return false end +FORM = { token = "t", id = "s00000001", idx = "1" } +ctl.action_node_delete() +check("node_delete write failure reports error", has_err()) + +-- action_node_delete:成功路径不带 err +reset() +CORE.read_nodes = function() return { { name = "a" }, { name = "b" } } end +FORM = { token = "t", id = "s00000001", idx = "1" } +ctl.action_node_delete() +check("node_delete success has no err", not has_err()) + +print(string.format("\n%d passed, %d failed", passed, failed)) +os.exit(failed == 0 and 0 or 1) diff --git a/luci-app-substore/tests/cron_result_test.sh b/luci-app-substore/tests/cron_result_test.sh index 7f13a4a66..5beca74ba 100755 --- a/luci-app-substore/tests/cron_result_test.sh +++ b/luci-app-substore/tests/cron_result_test.sh @@ -100,6 +100,37 @@ OUT=$(SUBSTORE_DATA_DIR="$TMP/nodata" LUA_PATH="$TMP/stub/?.lua" sh "$SCRIPT" 2> RC=$? check "no subscriptions file yields zero exit" "$RC" "0" +# (f) Lua 层致命失败 → 必须非 0 +# core.list() 在 pcall **之外**调用,抛异常会让整个 Lua 进程带着 traceback +# 退出,结果行根本没被打印。此时 FAILED 变量为空 —— 旧实现的 case 分支 +# `''|0) exit 0` 把它和成功混为一谈,一次彻底失败的更新在 cron 看来与成功 +# 无异。这里必须与 (c) 的「0 failed」严格区分开。 +cat >"$TMP/stub/substore/core.lua" <<'EOF' +local M = {} +M.list = function() error("list exploded") end +M.sync = function(id) return 1 end +return M +EOF +# 这里刻意把 stderr 一并收进 OUT:诊断信息就写在 stderr 上 +OUT=$(SUBSTORE_DATA_DIR="$TMP/data" LUA_PATH="$TMP/stub/?.lua" sh "$SCRIPT" 2>&1) +RC=$? +check "lua fatal error yields non-zero exit" "$RC" "1" +if contains "no result line reported" "$OUT" "no result line"; then + pass=$((pass + 1)) + echo "PASS no result line reported" +else + fail=$((fail + 1)) + echo "FAIL no result line reported" +fi + +# (f2) 模块加载失败(require 不到)同样是致命失败 +cat >"$TMP/stub/substore/core.lua" <<'EOF' +this is not valid lua (( +EOF +OUT=$(SUBSTORE_DATA_DIR="$TMP/data" LUA_PATH="$TMP/stub/?.lua" sh "$SCRIPT" 2>/dev/null) +RC=$? +check "module load failure yields non-zero exit" "$RC" "1" + echo "" echo "$pass passed, $fail failed" [ "$fail" -eq 0 ] || exit 1 diff --git a/luci-app-substore/tests/http_proxy_test.lua b/luci-app-substore/tests/http_proxy_test.lua index 39aa2dbd0..57f5a7a3d 100644 --- a/luci-app-substore/tests/http_proxy_test.lua +++ b/luci-app-substore/tests/http_proxy_test.lua @@ -75,9 +75,13 @@ local function chain(...) return table.concat(lines, "\n") end -local BASE = "http://sub.example.com/api/sub" +-- 主机一律用**公网 IP 字面量**,不用域名。 +-- check_public 在 DNS 解析失败时是 fail-closed 的(解析不出来即拒绝), +-- 而 example.com 这类域名在离线/沙箱环境解析不出来 —— 用它会让「公网目标应放行」 +-- 变成「取决于本机有没有 DNS」,测试不可复现。IP 字面量不经过 DNS,结论确定。 +local BASE = "http://1.1.1.1/api/sub" check("chain public -> public allowed", - http.validate_redirect_chain(BASE, chain("http://cdn.example.com/a")) == true) + http.validate_redirect_chain(BASE, chain("http://8.8.8.8/a")) == true) check("chain no redirect allowed", http.validate_redirect_chain(BASE, " HTTP/1.1 200 OK") == true) check("chain relative location allowed", @@ -105,14 +109,14 @@ check("chain to ipv6 link-local rejected", check("chain protocol-relative to private rejected", http.validate_redirect_chain(BASE, chain("//127.0.0.1/evil")) == false) check("chain protocol-relative to public allowed", - http.validate_redirect_chain(BASE, chain("//cdn.example.com/a")) == true) + http.validate_redirect_chain(BASE, chain("//8.8.8.8/a")) == true) -- 多跳:只要有一跳不安全就整体拒绝 check("chain multi-hop second hop private rejected", http.validate_redirect_chain(BASE, - chain("http://cdn.example.com/a", "http://192.168.1.1/b")) == false) + chain("http://8.8.8.8/a", "http://192.168.1.1/b")) == false) check("chain multi-hop all public allowed", http.validate_redirect_chain(BASE, - chain("http://cdn.example.com/a", "http://cdn2.example.com/b")) == true) + chain("http://8.8.8.8/a", "http://9.9.9.9/b")) == true) -- 非法 Location check("chain invalid location rejected", http.validate_redirect_chain(BASE, chain("ftp://example.com/x")) == false) diff --git a/luci-app-substore/tests/network_security_test.lua b/luci-app-substore/tests/network_security_test.lua new file mode 100644 index 000000000..0f53c8c31 --- /dev/null +++ b/luci-app-substore/tests/network_security_test.lua @@ -0,0 +1,193 @@ +-- network_security_test.lua — 网络安全与健壮性修复(L10 / L11 / L12 / L14)回归测试 +-- 用法:lua5.1 tests/network_security_test.lua +-- +-- 覆盖本轮修复: +-- L10 http.check_public 在 DNS 解析失败时改为 fail-closed +-- L11 http.parse_url 校验端口范围(1-65535) +-- L12 probe.safe_host 拒绝以 "-" 开头的主机名(busybox getopt 选项注入) +-- L14 http.fetch_curl 在**每一条**退出路径上清理 /tmp 临时文件 +-- +-- 全部用例不触网:需要子进程的地方一律用 io.popen / os.execute 替身。 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local util = require("substore.util") +local http = require("substore.http") +local probe = require("substore.probe") + +local passed, failed = 0, 0 +local function check(name, cond) + if cond then passed = passed + 1; print("PASS " .. name) + else failed = failed + 1; print("FAIL " .. name) end +end + +-- ---------- 子进程替身 ---------- +-- probe / http 都在**调用时**查全局 io.popen、os.execute,替换即可生效。 +local real_popen, real_execute = io.popen, os.execute + +-- 记录被启动的命令行,返回一个「输出固定」的假文件句柄 +local spawned +local function spy_popen(output) + spawned = {} + local out = output or "" + io.popen = function(cmd) + spawned[#spawned + 1] = cmd + return { read = function() return out end, close = function() end } + end +end +local function restore_popen() io.popen = real_popen end + +-- ---------- L10:DNS 解析失败必须 fail-closed ---------- +-- .invalid 是 RFC 2606 保留 TLD,保证解析不出 IP。 +-- 修复前这里是 fail-open(放行),于是 127.0.0.1.nip.io 这类「公网可解析到内网」 +-- 的域名只要本机这一刻解析失败(nixio 缺失 / 解析器故障 / 超时)就能绕过全部 +-- SSRF 检查,而 curl 自己仍会把它解析到 127.0.0.1 并连上去。 +local ok10, reason10 = http.check_public("no-such-host.invalid") +check("L10 unresolvable host rejected", ok10 == false) +check("L10 unresolvable host reason", reason10 == "无法解析目标主机名") + +-- 数值型内网地址仍然被拦(与 DNS 无关的分支,确保没有把检查整体关掉) +check("L10 loopback still rejected", http.check_public("127.0.0.1") == false) +check("L10 private still rejected", http.check_public("10.0.0.1") == false) +check("L10 ipv6 loopback still rejected", http.check_public("::1") == false) +-- 合法公网字面量仍然放行(不依赖 DNS) +check("L10 public literal still allowed", http.check_public("1.1.1.1") == true) + +-- ---------- L11:parse_url 端口范围 ---------- +-- `(%d+)` 只保证是数字,`:0` 与 `:99999` 都能通过;这两个端口连不出去, +-- 下载必然失败,却会先经过一轮 DNS/SSRF 检查,报出「连接失败」这种指错方向的原因。 +check("L11 port 0 rejected", http.parse_url("http://example.com:0/") == nil) +check("L11 port 65536 rejected", http.parse_url("http://example.com:65536/") == nil) +check("L11 port 99999 rejected", http.parse_url("http://example.com:99999/") == nil) +check("L11 port 1 accepted", (http.parse_url("http://example.com:1/") or {}).port == "1") +check("L11 port 65535 accepted", (http.parse_url("http://example.com:65535/") or {}).port == "65535") +check("L11 default port 80 accepted", (http.parse_url("http://example.com/") or {}).port == "80") +check("L11 https default port 443 accepted", (http.parse_url("https://example.com/") or {}).port == "443") +-- IPv6 字面量分支走的是另一条赋值路径,同样要校验 +check("L11 ipv6 bad port rejected", http.parse_url("http://[::1]:0/") == nil) +check("L11 ipv6 good port accepted", (http.parse_url("http://[::1]:8080/") or {}).port == "8080") + +-- ---------- L12:以 "-" 开头的主机名 ---------- +-- 命令形如 `ping -c 1 -W 2 `;host="--help" 会被 busybox 的 getopt 当成**选项**。 +-- util.shq 的引号由 shell 剥掉,getopt 看到的仍是 -x,挡不住。 +-- 合法主机名(RFC 1123 首字符为字母或数字)与 IP 都不会以 "-" 开头。 +-- +-- 断言方式:替身记录被启动的命令行 —— 修复前会真的启动子进程(spawned 非空), +-- 修复后 safe_host 提前返回 nil,一个进程都不起。 +spy_popen("") +check("L12 ping dash host rejected", probe.ping("-c") == nil) +check("L12 ping dash host spawns nothing", #spawned == 0) +restore_popen() + +spy_popen("") +check("L12 tcping dash host rejected", probe.tcping("--help", 80) == nil) +check("L12 tcping dash host spawns nothing", #spawned == 0) +restore_popen() + +spy_popen("") +check("L12 url_test dash host rejected", probe.url_test("-x", 80) == nil) +check("L12 url_test dash host spawns nothing", #spawned == 0) +restore_popen() + +-- 批量探测走的是 build_job,同样要拦住 +spy_popen("") +local r12 = probe.probe({ { name = "a", server = "-c", port = 80 } }, "ping") +check("L12 probe dash host latency nil", r12[1] and r12[1].latency == nil) +check("L12 probe dash host spawns nothing", #spawned == 0) +restore_popen() + +-- 替身本身有效:合法主机名必须真的启动子进程(否则上面的断言是假阳性) +spy_popen("") +probe.ping("127.0.0.1") +check("L12 valid host still spawns", #spawned == 1) +restore_popen() + +-- 单测一下校验边界:只有开头是 "-" 才拒绝,中间的 "-" 是合法主机名字符 +spy_popen("") +probe.ping("my-host.example.com") +check("L12 interior dash still allowed", #spawned == 1) +restore_popen() + +-- ---------- L14:fetch_curl 的临时文件清理 ---------- +-- 固定随机标记,让测试能预先造出与实现完全一致的文件名。 +local real_rnd = util.rnd_hex +util.rnd_hex = function() return "TESTTAG" end +os.execute = function() return 0 end -- have("curl") 恒真 → detect_tool() 选 curl + +-- 注意文件名:hdr / errf 是在 tmp 之上再拼后缀,而 tmp 本身以 ".tmp" 结尾, +-- 所以实际是 "...._0.tmp.hdr" / "...._0.tmp.err"(与实现逐字一致)。 +local TMP = "/tmp/substore_dl_TESTTAG_0" +local HDR = TMP .. ".tmp.hdr" +local ERRF = TMP .. ".tmp.err" +local function touch(path, data) + local f = assert(io.open(path, "wb")) + f:write(data or "") + f:close() +end +local function exists(path) + local f = io.open(path, "rb") + if f then f:close(); return true end + return false +end +local function clear_tmp() + os.remove(TMP .. ".tmp"); os.remove(HDR); os.remove(ERRF) +end + +-- 模拟 curl 真实写盘:从命令行里解析出 -o / -D / 2> 的目标路径并把文件建出来。 +-- 这一步是必需的 —— 若替身只返回一个输出字符串而不落盘,那么「失败后 /tmp 无残留」 +-- 在**修复前后都成立**(文件从没被创建过),断言恒真,等于没测。 +local function spy_curl(code, errtext, hdrdata) + spawned = {} + io.popen = function(cmd) + spawned[#spawned + 1] = cmd + local o = cmd:match("%-o '([^']+)'") + local d = cmd:match("%-D '([^']+)'") + local e = cmd:match("2>'([^']+)'") + if o then touch(o, "partial body") end + if d then touch(d, hdrdata or "") end + if e then touch(e, errtext or "") end + return { read = function() return code end, close = function() end } + end +end + +-- 失败路径 1:curl 报错(http_code=000)。修复前直接 return,三个文件都留在 tmpfs 上。 +clear_tmp() +spy_curl("000", "curl: (7) Failed to connect") +local body14, _, err14 = http.download("http://1.1.1.1/sub") +restore_popen() +check("L14 failure reported", body14 == nil and err14 == "curl: (7) Failed to connect") +check("L14 000 path removes .tmp", not exists(TMP .. ".tmp")) +check("L14 000 path removes .hdr", not exists(HDR)) +check("L14 000 path removes .err", not exists(ERRF)) + +-- 失败路径 2:3xx 但响应头里没有 Location。修复前同样把文件留在 /tmp。 +clear_tmp() +spy_curl("302", "", "HTTP/1.1 302 Found\r\n") +local body14b, _, err14b = http.download("http://1.1.1.1/sub") +restore_popen() +check("L14 no-Location reported", body14b == nil and err14b == "重定向无 Location") +check("L14 no-Location removes .tmp", not exists(TMP .. ".tmp")) +check("L14 no-Location removes .hdr", not exists(HDR)) +check("L14 no-Location removes .err", not exists(ERRF)) + +-- 失败路径 3:HTTP 4xx/5xx。修复前把文件留在 /tmp。 +clear_tmp() +spy_curl("500", "", "HTTP/1.1 500 Error\r\n") +local body14c, _, err14c = http.download("http://1.1.1.1/sub") +restore_popen() +check("L14 http-error reported", body14c == nil and err14c == "HTTP 错误 500") +check("L14 http-error removes .tmp", not exists(TMP .. ".tmp")) +check("L14 http-error removes .hdr", not exists(HDR)) +check("L14 http-error removes .err", not exists(ERRF)) + +-- 替身本身有效:证明上面的「无残留」不是空断言 —— 若清理逻辑被移除,文件必须还在。 +-- 直接调用一次「不清理」的对照:手工建出文件后不跑 download,断言它们确实可见。 +touch(TMP .. ".tmp", "x") +check("L14 control: files are observable", exists(TMP .. ".tmp")) +clear_tmp() +util.rnd_hex = real_rnd +os.execute = real_execute + +print("") +print(passed .. " passed, " .. failed .. " failed") +os.exit(failed == 0 and 0 or 1) diff --git a/luci-app-substore/tests/node_extended_test.lua b/luci-app-substore/tests/node_extended_test.lua index e83a9859c..28b721001 100644 --- a/luci-app-substore/tests/node_extended_test.lua +++ b/luci-app-substore/tests/node_extended_test.lua @@ -85,14 +85,38 @@ check("add_tags existing", nodes5[1].tags and #nodes5[1].tags == 2 and nodes5[1] check("add_tags new", nodes5[2].tags and #nodes5[2].tags == 1 and nodes5[2].tags[1] == "vip") -- ---------- dedup ---------- --- 非 WireGuard:行为不变,仍按 proto+server+port +-- M17:去重键必须含凭据。此前只按 proto+server+port,同一入口上的多账号 +-- (不同 uuid / 密码)会被当成重复而删掉一个 —— 静默丢节点。 +-- 同一台服务器上的两个 vmess,uuid 不同 → 是两个节点,都必须留下。 local d0 = node.dedup({ { proto = "vmess", server = "1.1.1.1", port = 443, uuid = "a" }, { proto = "vmess", server = "1.1.1.1", port = 443, uuid = "b" }, { proto = "vless", server = "1.1.1.1", port = 443, uuid = "c" }, }) -check("dedup keeps first of identical vmess", #d0 == 2) -check("dedup treats different proto as distinct", d0[2].proto == "vless") +-- 下标先判空:修复前这里只有 2 个元素,直接取 d0[3] 会抛错中断整套测试 +check("dedup keeps vmess with different uuid", #d0 == 3) +check("dedup keeps second vmess uuid", d0[2] ~= nil and d0[2].uuid == "b") +check("dedup treats different proto as distinct", d0[3] ~= nil and d0[3].proto == "vless") + +-- 完全相同(含凭据)的两个节点仍然要去重 +local d0b = node.dedup({ + { proto = "vmess", server = "1.1.1.1", port = 443, uuid = "a" }, + { proto = "vmess", server = "1.1.1.1", port = 443, uuid = "a" }, +}) +check("dedup removes fully identical vmess", #d0b == 1) + +-- 各协议的凭据都要参与去重键 +local d0c = node.dedup({ + { proto = "shadowsocks", server = "1.1.1.1", port = 443, method = "aes-128-gcm", password = "p1" }, + { proto = "shadowsocks", server = "1.1.1.1", port = 443, method = "aes-128-gcm", password = "p2" }, + { proto = "trojan", server = "1.1.1.1", port = 443, password = "p1" }, + { proto = "trojan", server = "1.1.1.1", port = 443, password = "p2" }, + { proto = "socks", server = "1.1.1.1", port = 443, username = "u1", password = "p" }, + { proto = "socks", server = "1.1.1.1", port = 443, username = "u2", password = "p" }, +}) +check("dedup honours shadowsocks password", #d0c == 6) +check("dedup honours trojan password", d0c[4] ~= nil and d0c[4].password == "p2") +check("dedup honours socks username", d0c[6] ~= nil and d0c[6].username == "u2") -- WireGuard(§32/§43):同 server+port 但 peer 公钥不同 → 不能合并 local d1 = node.dedup({ diff --git a/luci-app-substore/tests/output_legal_test.lua b/luci-app-substore/tests/output_legal_test.lua new file mode 100644 index 000000000..215e53455 --- /dev/null +++ b/luci-app-substore/tests/output_legal_test.lua @@ -0,0 +1,234 @@ +-- output_legal_test.lua — 输出层「生成的配置必须能被目标客户端加载」回归测试 +-- 用法:lua5.1 tests/output_legal_test.lua +-- +-- 覆盖 P2 批次三: +-- M19 QX [policy] 引用未定义的服务器(悬空引用) +-- M23 wireguard 的 allowed-ips / reserved / dns 以字符串形态原样透传 +-- M24 hysteria2/hysteria 分享链接只读 insecure,丢掉 skip-cert-verify +-- M25 trojan/tuic 分享链接 alpn 为数组时输出 "table: 0x..." +-- M27 未加引号的 YAML 标量里反斜杠被翻倍 +-- L21 成员列表里含逗号的名字被当成两个成员 +-- L22 非数字 port 原样输出 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local node = require("substore.node") +local clash_meta = require("substore.output_clash_meta") +local singbox = require("substore.output_singbox") +local uri = require("substore.output_uri") +local formats = require("substore.output_formats") + +local passed, failed = 0, 0 + +local function check(name, cond) + if cond then + passed = passed + 1 + print("PASS " .. name) + else + failed = failed + 1 + print("FAIL " .. name) + end +end + +-- 取出第一行匹配 pattern 的整行 +local function line_with(text, pattern) + for l in text:gmatch("[^\n]+") do + if l:find(pattern) then return l end + end + return nil +end + +-- ---------- M27:未加引号的标量里反斜杠不得翻倍 ---------- +-- need_quote 的触发集里没有反斜杠,所以 `pa\ss` 会走「不加引号」这条路; +-- 转义若在判定之前无条件执行,输出就是 `pa\\ss`,YAML 回读得到两个反斜杠。 +local bs = node.normalize({ proto = "trojan", server = "1.2.3.4", port = 443, + password = "pa\\ss", name = "BS" }) +local bs_line = line_with(clash_meta.generate({ bs }), "password") +check("M27 plain scalar keeps single backslash", + bs_line == " password: pa\\ss") +check("M27 plain scalar not doubled", not bs_line:find("pa\\\\ss", 1, true)) + +-- 含 `:` 时必须加引号,加引号时反斜杠才需要转义 +local bq = node.normalize({ proto = "trojan", server = "1.2.3.4", port = 443, + password = "pa\\ss: x", name = "BQ" }) +local bq_line = line_with(clash_meta.generate({ bq }), "password") +check("M27 quoted scalar escapes backslash", + bq_line == ' password: "pa\\\\ss: x"') + +-- ---------- M23:wireguard 数组字段在字符串形态下也要输出成列表 ---------- +-- mihomo 的 allowed-ips / dns 是 []string、reserved 是 []uint8,只接受列表; +-- 写成标量会让它拒绝加载整份配置。字符串来源:表单导入、URI 导入、.conf 导入。 +local wg_str = node.normalize({ + proto = "wireguard", server = "1.2.3.4", port = 51820, name = "WS", + ["private-key"] = "k", ["public-key"] = "p", + ["allowed-ips"] = "0.0.0.0/0, ::/0", reserved = "1,2,3", dns = "1.1.1.1, 8.8.8.8", +}) +local cm_str = clash_meta.generate({ wg_str }) +check("M23 clash allowed-ips is a list", + cm_str:find("\n %- 0%.0%.0%.0/0\n") ~= nil + and cm_str:find("\n %- \"::/0\"\n") ~= nil) +check("M23 clash reserved is a list", + cm_str:find("\n %- 1\n") ~= nil and cm_str:find("\n %- 3\n") ~= nil) +check("M23 clash dns is a list", + cm_str:find("\n %- 1%.1%.1%.1\n") ~= nil + and cm_str:find("\n %- 8%.8%.8%.8\n") ~= nil) +check("M23 clash no scalar allowed-ips", + line_with(cm_str, "allowed%-ips:") == " allowed-ips:") +check("M23 clash no scalar reserved", + line_with(cm_str, "reserved:") == " reserved:") + +-- 数组形态(Clash YAML 嵌套列表导入)不能被破坏 +local wg_tbl = node.normalize({ + proto = "wireguard", server = "1.2.3.4", port = 51820, name = "WT", + ["private-key"] = "k", ["public-key"] = "p", + ["allowed-ips"] = { "0.0.0.0/0", "::/0" }, reserved = { 1, 2, 3 }, dns = { "1.1.1.1" }, +}) +local cm_tbl = clash_meta.generate({ wg_tbl }) +check("M23 clash table form still a list", + cm_tbl:find("\n %- 0%.0%.0%.0/0\n") ~= nil + and cm_tbl:find("\n %- 1%.1%.1%.1\n") ~= nil) + +-- sing-box:allowed_ips / dns 是 []string,reserved 是 []uint8(必须是数字) +local sb_str = singbox.generate({ wg_str }) +check("M23 singbox allowed_ips array", + sb_str:find('"allowed_ips":%["0%.0%.0%.0/0","::/0"%]') ~= nil) +check("M23 singbox allowed_ips not string", + sb_str:find('"allowed_ips":"', 1, true) == nil) +check("M23 singbox reserved numeric array", + sb_str:find('"reserved":%[1,2,3%]') ~= nil) +check("M23 singbox reserved not string", + sb_str:find('"reserved":"', 1, true) == nil) +check("M23 singbox dns array", + sb_str:find('"dns":%["1%.1%.1%.1","8%.8%.8%.8"%]') ~= nil) + +-- 空值不得产出空列表(`allowed-ips: []` 同样是非法值) +local wg_empty = node.normalize({ + proto = "wireguard", server = "1.2.3.4", port = 51820, name = "WE", + ["private-key"] = "k", ["public-key"] = "p", ["allowed-ips"] = "", +}) +check("M23 empty allowed-ips omitted (clash)", + line_with(clash_meta.generate({ wg_empty }), "allowed%-ips") == nil) +check("M23 empty allowed-ips omitted (singbox)", + singbox.generate({ wg_empty }):find("allowed_ips", 1, true) == nil) + +-- ---------- L22:非数字 port ---------- +-- sing-box / v2ray 一直用 tonumber() or 0 兜底,clash 输出此前原样透传 +local badport = { proto = "trojan", server = "1.2.3.4", port = "abc", + password = "p", name = "BP" } +check("L22 non-numeric port falls back to 0", + line_with(clash_meta.generate({ badport }), "port:") == " port: 0") +local okport = { proto = "trojan", server = "1.2.3.4", port = "8443", + password = "p", name = "OP" } +check("L22 numeric string port preserved", + line_with(clash_meta.generate({ okport }), "port:") == " port: 8443") + +-- ---------- M24:hysteria2/hysteria 的 insecure 以 skip-cert-verify 为准 ---------- +-- parser.lua 的注释写明了:insecure 是 URI 参数名,模型里的权威字段是 +-- skip-cert-verify;而只有 URI 解析器会写 insecure,Clash / sing-box JSON / +-- 表单导入的节点只有 skip-cert-verify。 +local function hy2(extra) + local t = { proto = "hysteria2", server = "1.2.3.4", port = 443, + password = "p", name = "H" } + for k, v in pairs(extra or {}) do t[k] = v end + return node.normalize(t) +end +local u_true = uri.to_share_uri(hy2({ ["skip-cert-verify"] = true })) +check("M24 skip-cert-verify=true emits insecure=1", + u_true:find("insecure=1", 1, true) ~= nil) +local u_false = uri.to_share_uri(hy2({ ["skip-cert-verify"] = false })) +check("M24 skip-cert-verify=false emits insecure=0", + u_false:find("insecure=0", 1, true) ~= nil) +local u_str = uri.to_share_uri(hy2({ skip_cert_verify = "false" })) +check("M24 skip_cert_verify alias honoured", + u_str:find("insecure=0", 1, true) ~= nil) +local u_none = uri.to_share_uri(hy2(nil)) +check("M24 absent emits nothing", + u_none:find("insecure", 1, true) == nil) +local u_legacy = uri.to_share_uri(hy2({ insecure = "true" })) +check("M24 legacy insecure field still honoured", + u_legacy:find("insecure=1", 1, true) ~= nil) +-- hysteria v1 走同一分支 +local u_h1 = uri.to_share_uri(node.normalize({ proto = "hysteria", server = "1.2.3.4", + port = 443, password = "p", name = "H1", ["skip-cert-verify"] = true })) +check("M24 hysteria v1 also emits insecure=1", + u_h1:find("insecure=1", 1, true) ~= nil) + +-- ---------- M25:alpn 数组归一 ---------- +-- Clash YAML 的 alpn 列表与 sing-box JSON 的 tls.alpn 导入后就是 table, +-- 直接 url_encode 会 tostring 成 "table: 0x..." +local function alpn_node(proto, extra) + local t = { proto = proto, server = "1.2.3.4", port = 443, name = "A", + alpn = { "h3", "h2" } } + for k, v in pairs(extra or {}) do t[k] = v end + return node.normalize(t) +end +local u_trojan = uri.to_share_uri(alpn_node("trojan", { password = "p" })) +check("M25 trojan alpn table joined", + u_trojan:find("alpn=h3%2Ch2", 1, true) ~= nil) +check("M25 trojan no table address", + u_trojan:find("table", 1, true) == nil) +local u_tuic = uri.to_share_uri(alpn_node("tuic", { uuid = "u", password = "p" })) +check("M25 tuic alpn table joined", + u_tuic:find("alpn=h3%2Ch2", 1, true) ~= nil) +check("M25 tuic no table address", u_tuic:find("table", 1, true) == nil) +local u_vless = uri.to_share_uri(alpn_node("vless", { uuid = "u" })) +check("M25 vless alpn table joined", + u_vless:find("alpn=h3%2Ch2", 1, true) ~= nil) +-- 字符串形态不受影响 +local u_alpn_str = uri.to_share_uri(node.normalize({ proto = "trojan", + server = "1.2.3.4", port = 443, password = "p", name = "AS", alpn = "h3,h2" })) +check("M25 alpn string unchanged", + u_alpn_str:find("alpn=h3%2Ch2", 1, true) ~= nil) + +-- ---------- L21:含逗号的名字不得进入成员列表 ---------- +-- Surge 家族 / QX 的成员列表是 `NAME = select, X, Y, DIRECT`,没有引号或转义 +-- 机制;名字里的逗号会被当成成员分隔符,产出两个都不存在的成员。 +local n_comma = node.normalize({ proto = "trojan", server = "1.2.3.4", port = 443, + password = "p", name = "A,B" }) +local n_good = node.normalize({ proto = "trojan", server = "5.6.7.8", port = 443, + password = "p", name = "Good" }) + +local surge = formats.to_surge({ n_comma, n_good }) +local surge_sel = line_with(surge, "= select") +check("L21 surge select keeps good node", + surge_sel == "PROXY = select, Good, DIRECT") +-- 节点定义本身仍保留在 [Proxy] 里(只是不可通过策略组选中) +check("L21 surge proxy line kept", surge:find("A,B = trojan", 1, true) ~= nil) + +local qx = formats.to_qx({ n_good, n_comma }) +local qx_pol = line_with(qx, "static=") +check("L21 qx policy keeps good node", + qx_pol == "static=PROXY, DIRECT, Good") + +-- 含换行的名字:定义行经 util.one_line 变成空格,成员列表必须与之一致, +-- 否则同样是悬空引用 +local n_nl = node.normalize({ proto = "trojan", server = "7.7.7.7", port = 443, + password = "p", name = "X\nY" }) +local surge_nl = formats.to_surge({ n_nl }) +check("L21 newline name normalised in def", + surge_nl:find("X Y = trojan", 1, true) ~= nil) +check("L21 newline name matches in select", + line_with(surge_nl, "= select") == "PROXY = select, X Y, DIRECT") + +-- ---------- M19:QX [policy] 不得引用未定义的服务器 ---------- +-- QX 的 [server_local] 只输出 shadowsocks / vmess / vless / trojan 四类; +-- 其余节点(hysteria2 / tuic / socks / wireguard …)没有定义行, +-- 列进 [policy] 就是悬空引用。 +local hy = node.normalize({ proto = "hysteria2", server = "9.9.9.9", port = 443, + password = "p", name = "HY" }) +local qx2 = formats.to_qx({ n_good, hy }) +check("M19 qx policy drops undefined node", + line_with(qx2, "static=") == "static=PROXY, DIRECT, Good") +check("M19 qx server_local has no hysteria2", + qx2:find("hysteria2", 1, true) == nil) +check("M19 qx server_local keeps trojan", + qx2:find("trojan=5.6.7.8:443", 1, true) ~= nil) + +-- 全部节点都不受支持时,策略组只剩组名与 DIRECT,仍然是合法行 +local qx3 = formats.to_qx({ hy }) +check("M19 qx empty policy still valid", + line_with(qx3, "static=") == "static=PROXY, DIRECT") + +-- ---------- 结果 ---------- +print(string.format("\n%d passed, %d failed", passed, failed)) +os.exit(failed == 0 and 0 or 1) diff --git a/luci-app-substore/tests/p1_fixes_test.lua b/luci-app-substore/tests/p1_fixes_test.lua new file mode 100644 index 000000000..433d19858 --- /dev/null +++ b/luci-app-substore/tests/p1_fixes_test.lua @@ -0,0 +1,410 @@ +-- p1_fixes_test.lua — P1 批次缺陷修复的回归测试 +-- 用法:lua5.1 tests/p1_fixes_test.lua +-- +-- 覆盖本轮修复(每项都先在修改前用探针复现过,再断言修复后的行为): +-- M1 trojan 密码未做 url_decode +-- M2 vmess classic JSON 忽略 host / path +-- M3 SSR 密码 base64 回退不可达 +-- M4 vless/trojan/vmess/hysteria2/tuic/ss 缺 host/port 校验 +-- M5 sing-box YAML 嵌套 tls: map 被跳过(含嵌套序列 alpn / utls.fingerprint) +-- M7 通用 JSON 节点路径忽略 type 字段、无协议白名单 +-- M8 Surge 段名大小写敏感([PROXY] 不识别) +-- M10 userinfo 按首个 @ 切分(密码含 @ 时错位) +-- M12 core.load 遇到非 table 条目直接崩溃 +-- M15 改名替换串里的 % 未转义(吞字符 / 注入 NUL 字节) +-- M20 output_v2ray.supported() 只排除 ssr +-- M21 clashmeta 从不写 flow +-- M22 clashmeta 丢弃 grpc / h2 传输参数 +-- 另:Surge/QX 未知协议白名单(H6 的 Surge 侧)、简易 YAML 嵌套序列 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local util = require("substore.util") +local parser = require("substore.parser") +local surge = require("substore.parser_surge") +local node = require("substore.node") +local core = require("substore.core") +local output = require("substore.output") +local output_clash_meta = require("substore.output_clash_meta") + +local passed, failed = 0, 0 +local function check(name, cond) + if cond then passed = passed + 1; print("PASS " .. name) + else failed = failed + 1; print("FAIL " .. name) end +end + +-- 解析单条 URI,返回节点(或 nil) +local function one(uri) + local r = parser.parse(uri) + return r and r.nodes and r.nodes[1] or nil +end + +-- ---------- M1:trojan 密码必须 url_decode ---------- +-- 未解码时密码会是字面量 "p%40ss%3Aword",认证必然失败。 +local t1 = one("trojan://p%40ss%3Aword@1.2.3.4:443?sni=a.com#T") +check("M1 trojan parsed", t1 ~= nil and t1.proto == "trojan") +check("M1 trojan password url_decoded", t1 and t1.password == "p@ss:word") +check("M1 trojan server/port", t1 and t1.server == "1.2.3.4" and t1.port == 443) + +-- ---------- M2:vmess classic JSON 的 host / path ---------- +-- 未修复时 ws 的 Host 头与 path 全丢,服务端按默认路径匹配失败。 +local vm_json = util.json_encode({ + v = "2", ps = "V", add = "1.2.3.4", port = "443", id = "u", aid = "0", + net = "ws", type = "none", host = "ws.example.com", path = "/vmpath", + tls = "tls", sni = "a.com", +}) +local t2 = one("vmess://" .. util.base64_encode(vm_json)) +check("M2 vmess classic parsed", t2 ~= nil and t2.proto == "vmess") +check("M2 vmess keeps host", t2 and t2.host == "ws.example.com") +check("M2 vmess keeps path", t2 and t2.path == "/vmpath") +check("M2 vmess keeps net", t2 and t2.net == "ws") + +-- 未提供 host/path 时不应凭空造出字段 +local t2b = one("vmess://" .. util.base64_encode(util.json_encode({ + v = "2", ps = "V2", add = "1.2.3.4", port = "443", id = "u", aid = "0", net = "tcp", +}))) +check("M2 absent host stays nil", t2b and t2b.host == nil) +check("M2 absent path stays nil", t2b and t2b.path == nil) + +-- ---------- M3:SSR 密码 base64 回退 ---------- +-- 密码为纯 base64(非 base64url)时也必须解出明文,而不是乱码。 +local ssr_main = "1.2.3.4:8080:origin:aes-128-cfb:plain:" + .. util.base64_url_encode("plainpass") + .. "/?remarks=" .. util.base64_url_encode("SSR") +local t3 = one("ssr://" .. util.base64_url_encode(ssr_main)) +check("M3 ssr parsed", t3 ~= nil and t3.proto == "ssr") +check("M3 ssr password decoded", t3 and t3.password == "plainpass") +check("M3 ssr remarks decoded", t3 and t3.name == "SSR") +check("M3 ssr method/protocol/obfs", t3 and t3.method == "aes-128-cfb" + and t3.protocol == "origin" and t3.obfs == "plain") + +-- 非 ASCII 备注必须原样还原(base64url → UTF-8),不能被解成乱码 +local ssr_plain = "1.2.3.4:8080:origin:aes-128-cfb:plain:" + .. util.base64_url_encode("p") .. "/?remarks=" .. util.base64_url_encode("香港节点") +local t3b = one("ssr://" .. util.base64_url_encode(ssr_plain)) +check("M3 ssr utf8 remarks preserved", t3b and t3b.name == "香港节点") + +-- 外层用 base64url 字母表(含 - / _)的 ssr:// 链接同样必须正确解析。 +-- 标准 base64 解码会把 -/_ 当非法字符**剔除**(util.lua 的 `[^%w%+/=]`), +-- 整串少掉若干字符、解成乱码,且不报错——用户只看到一个连不上的节点。 +-- 这里的 server 取 1.2.3.100 是因为该 body 的标准 base64 恰好含 "/", +-- base64url 编码后变成 "_"(已实测确认)。 +local ssr_b64u = "1.2.3.100:8080:origin:aes-128-cfb:plain:" + .. util.base64_url_encode("pw") .. "/?remarks=" .. util.base64_url_encode("U") +local outer = util.base64_url_encode(ssr_b64u) +check("M3 outer really uses base64url alphabet", + outer:find("-", 1, true) ~= nil or outer:find("_", 1, true) ~= nil) +-- 前提校验:用标准 base64 解码这份 base64url 串确实会出错(复现原缺陷) +local t3c_std = one("ssr://" .. outer) +check("M3 base64url outer decodes", t3c_std ~= nil and t3c_std.proto == "ssr") +check("M3 base64url outer server", t3c_std and t3c_std.server == "1.2.3.100") +check("M3 base64url outer password", t3c_std and t3c_std.password == "pw") +check("M3 base64url outer name", t3c_std and t3c_std.name == "U") +-- 标准 base64 外层仍然照旧 +local outer_std = util.base64_encode(ssr_b64u) +local t3d = one("ssr://" .. outer_std) +check("M3 standard outer still works", t3d ~= nil and t3d.proto == "ssr" + and t3d.server == "1.2.3.100" and t3d.password == "pw") + +-- ---------- M4:host / port 校验 ---------- +-- 残缺节点会被写成 `server: ` / `port: 0`,mihomo 与 sing-box 会拒绝加载 +-- 整份配置——一个节点废掉整个订阅。 +local bad_uris = { + ["vless port 0"] = "vless://uuid@1.2.3.4:0#A", + ["vless port missing"] = "vless://uuid@1.2.3.4#B", + ["vless port > 65535"] = "vless://uuid@1.2.3.4:99999#C", + ["trojan port 0"] = "trojan://p@1.2.3.4:0#D", + ["ss port 0"] = "ss://YWVzLTI1Ni1nY206cA==@1.2.3.4:0#E", + ["hysteria2 port missing"] = "hysteria2://p@1.2.3.4#F", + ["tuic port 0"] = "tuic://u:p@1.2.3.4:0#G", +} +for label, uri in pairs(bad_uris) do + local r = parser.parse(uri) + check("M4 dropped: " .. label, r ~= nil and #r.nodes == 0) +end + +-- 合法边界值必须保留 +local ok_port = one("vless://uuid@1.2.3.4:65535#OK") +check("M4 keeps port 65535", ok_port ~= nil and ok_port.port == 65535) +local ok_port1 = one("vless://uuid@1.2.3.4:1#OK1") +check("M4 keeps port 1", ok_port1 ~= nil and ok_port1.port == 1) + +-- ---------- M5:sing-box YAML 嵌套 tls: map ---------- +-- tls 是嵌套 map,且内部还含嵌套序列(alpn)与嵌套 map(utls)。 +-- 未修复时整层 TLS 丢失:trojan/vmess/vless 静默退化成明文, +-- hysteria2/tuic 让客户端以 C.ErrTLSRequired 拒绝启动。 +local sb_yaml = [[ +outbounds: + - type: vless + tag: sb + server: 1.2.3.4 + server_port: 443 + uuid: u-1 + flow: xtls-rprx-vision + tls: + enabled: true + server_name: sni.example.com + insecure: true + alpn: + - h2 + - http/1.1 + utls: + enabled: true + fingerprint: chrome +]] +local t5 = one(sb_yaml) +check("M5 sing-box yaml parsed", t5 ~= nil and t5.proto == "vless") +check("M5 tls enabled -> security", t5 and t5.security == "tls") +check("M5 server_name -> sni", t5 and t5.sni == "sni.example.com") +check("M5 insecure -> skip-cert-verify", t5 and t5["skip-cert-verify"] == true) +check("M5 flow preserved", t5 and t5.flow == "xtls-rprx-vision") +check("M5 alpn nested sequence", t5 and type(t5.alpn) == "table" + and t5.alpn[1] == "h2" and t5.alpn[2] == "http/1.1") +-- utls 排在 alpn 之后:若嵌套序列不被支持,read_map 会在 alpn 处截断, +-- 这个键会连带消失(这正是 read_seq 要解决的问题) +check("M5 utls fingerprint -> fp", t5 and t5.fp == "chrome") +-- 嵌套 map 不得以 table 形式漏到 tls 字段(table 恒为真值, +-- 会被下游的 `if n.tls then` 当成「已启用 TLS」) +check("M5 tls table not leaked", t5 and type(t5.tls) ~= "table") + +-- tls.enabled 为假时不得开启 TLS。 +-- 用 vless 而非 trojan:trojan 是 TLS-only,node.DEFAULTS 会强制补 security="tls", +-- 用它测不出「tls 块存在但未启用」这条分支(vless 的默认值是 "none")。 +local sb_off = [[ +outbounds: + - type: vless + tag: off + server: 1.2.3.4 + server_port: 443 + uuid: u-off + tls: + enabled: false + server_name: s.example.com +]] +local t5b = one(sb_off) +check("M5 tls disabled stays off", t5b and (t5b.security == nil or t5b.security == "none")) + +-- ---------- M7:通用 JSON 节点用 type 映射协议 + 白名单 ---------- +local t7 = one(util.json_encode({ + { type = "trojan", server = "1.2.3.4", port = 443, password = "p", name = "J" }, +})) +check("M7 json type -> proto", t7 ~= nil and t7.proto == "trojan") +check("M7 json password kept", t7 and t7.password == "p") +-- type 已被消费为协议,不能原样留在节点上(vmess 的 type 是 header 类型,语义不同) +check("M7 type consumed", t7 and t7.type == nil) + +-- 未知类型必须丢弃,而不是兜底成 vmess 造出字段全错的假节点 +local t7b = parser.parse(util.json_encode({ + { type = "snell", server = "1.2.3.4", port = 443, password = "p" }, +})) +check("M7 unknown type dropped", t7b ~= nil and #t7b.nodes == 0) + +-- 无 server/port 的条目丢弃 +local t7c = parser.parse(util.json_encode({ + { type = "vmess", server = "", port = 443, uuid = "u" }, +})) +check("M7 empty server dropped", t7c ~= nil and #t7c.nodes == 0) + +-- ---------- M8:Surge 段名大小写不敏感 ---------- +check("M8 is_config [Proxy]", surge.is_config("[Proxy]\nA = vmess, 1.2.3.4, 443\n")) +check("M8 is_config [PROXY]", surge.is_config("[PROXY]\nA = vmess, 1.2.3.4, 443\n")) +check("M8 is_config [server_local]", surge.is_config("[server_local]\nvmess=1.2.3.4:443\n")) +check("M8 is_config negative", not surge.is_config("proxies:\n - name: x\n")) +-- 全大写段名必须能整份解析(未修复时被判成 JSON 数组 → 整份报错) +local t8 = parser.parse("[PROXY]\nA = vmess, 1.2.3.4, 443, username=u\n") +check("M8 uppercase section parses", t8 ~= nil and #t8.nodes == 1) +check("M8 uppercase node proto", t8 and t8.nodes[1] and t8.nodes[1].proto == "vmess") + +-- Surge / QX 未知协议白名单(H6 的 Surge 侧) +check("M8/H6 surge unknown proto dropped", + #surge.parse("[Proxy]\nA = snell, 1.2.3.4, 443, psk=x\n") == 0) +check("M8/H6 qx unknown proto dropped", + #surge.parse("[server_local]\nssh=1.2.3.4:22, password=p\n") == 0) +-- 已知协议仍正常 +check("M8/H6 surge known proto kept", + #surge.parse("[Proxy]\nA = trojan, 1.2.3.4, 443, password=p\n") == 1) + +-- ---------- M10:userinfo 按最后一个 @ 切分 ---------- +-- 密码里含未转义的 @ 时,按首个 @ 切分会把密码截断、host 变成 "ss@1.2.3.4"。 +local t10 = one("hysteria2://p@ss@1.2.3.4:443?sni=a.com#H") +check("M10 hysteria2 parsed", t10 ~= nil and t10.proto == "hysteria2") +check("M10 password keeps @", t10 and t10.password == "p@ss") +check("M10 server correct", t10 and t10.server == "1.2.3.4") +check("M10 port correct", t10 and t10.port == 443) + +-- 多个 @ 也按最后一个切 +local t10b = one("trojan://a@b@c@1.2.3.4:443#T") +check("M10 multiple @ in password", t10b and t10b.password == "a@b@c" + and t10b.server == "1.2.3.4") + +-- ---------- M12:core.load 遇坏条目不崩溃 ---------- +local tmp = os.tmpname() .. "_p1fix" +os.remove(tmp) +core.DATA_DIR = tmp +core.LIST_FILE = tmp .. "/subscriptions.json" +core.NODES_DIR = tmp .. "/nodes" +core.ensure_dirs() +os.execute("mkdir -p " .. util.shq(tmp)) +local fh = io.open(core.LIST_FILE, "w") +fh:write('{"seq":3,"items":{"good":{"name":"G","url":"http://e.com/s"},"bad":"not-a-table"}}') +fh:close() + +-- 未修复时 M.list 里的 pairs(meta) 会抛 +-- "bad argument #1 to 'pairs' (table expected, got string)",订阅列表页直接 500。 +-- 注意 core.load 是模块内局部函数,对外只能通过 core.list() 观察。 +local arr, lerr = core.list() +check("M12 list does not crash", type(arr) == "table") +check("M12 good entry kept", #arr == 1 and arr[1].id == "good" and arr[1].name == "G") +check("M12 corruption reported", type(lerr) == "string" and lerr:find("损坏") ~= nil) + +-- 坏条目不能因为「静默过滤」而被当成正常列表:写路径必须拒绝落盘, +-- 否则下一次 save 会把坏条目永久抹掉 +local wrote = core.save_meta("good", { upload = 1 }) +check("M12 write refused on corruption", wrote == nil or wrote == false) + +-- 全部条目合法时不报错 +local fh2 = io.open(core.LIST_FILE, "w") +fh2:write('{"_seq":4,"items":{"a":{"name":"A"}}}') +fh2:close() +local arr2, lerr2 = core.list() +check("M12 clean file no error", lerr2 == nil and #arr2 == 1 and arr2[1].id == "a") +os.execute("rm -rf " .. util.shq(tmp)) + +-- ---------- M15:改名替换串的 % 与 $ ---------- +local function rename_one(name, rules) + local ns = node.rename_with_rules( + { { name = name, proto = "vmess", server = "1.2.3.4", port = 443 } }, rules) + return ns[1].name +end + +-- 结尾的 % 未转义会注入 NUL 字节("100%" → "100\0"), +-- 节点名会写进节点文件并下发给所有客户端 +local m15a = rename_one("x", { { type = "regex", pattern = "x", replacement = "100%" } }) +check("M15 trailing percent preserved", m15a == "100%") +check("M15 no NUL injected", m15a:find("%z") == nil) +check("M15 percent length exact", #m15a == 4) + +-- % 后接非数字字符未转义会被静默吞掉("50%off" → "50off") +local m15b = rename_one("x", { { type = "regex", pattern = "x", replacement = "50%off" } }) +check("M15 percent before letter preserved", m15b == "50%off") +check("M15 percent before letter length", #m15b == 6) + +-- 捕获引用仍可用 +local m15c = rename_one("ab", { { type = "regex", pattern = "(a)(b)", replacement = "$2$1" } }) +check("M15 backreference still works", m15c == "ba") + +-- %% 按字面 % 处理(用户写了两个就得到两个) +local m15d = rename_one("x", { { type = "regex", pattern = "x", replacement = "a%%b" } }) +check("M15 double percent literal", m15d == "a%%b") + +-- ---------- M20:output_v2ray 协议白名单 ---------- +-- Xray 只认 vmess/vless/trojan/shadowsocks/socks/http;其余协议没有对应 +-- outbound 类型,写成 `"protocol": "hysteria2"` 会让 Xray 拒绝整份配置。 +local m20_nodes = { + { proto = "vmess", name = "V", server = "1.1.1.1", port = 443, uuid = "u" }, + { proto = "vless", name = "L", server = "2.2.2.2", port = 443, uuid = "u2" }, + { proto = "trojan", name = "T", server = "3.3.3.3", port = 443, password = "p" }, + { proto = "ss", name = "S", server = "4.4.4.4", port = 8388, method = "aes-256-gcm", password = "p" }, + { proto = "socks", name = "SK", server = "5.5.5.5", port = 1080, username = "u", password = "p" }, + { proto = "hysteria2", name = "H2", server = "6.6.6.6", port = 443, password = "p" }, + { proto = "hysteria", name = "H1", server = "7.7.7.7", port = 443, password = "p" }, + { proto = "tuic", name = "TU", server = "8.8.8.8", port = 443, uuid = "u3", password = "p" }, + { proto = "wireguard", name = "WG", server = "9.9.9.9", port = 51820, ["private-key"] = "k" }, + { proto = "ssr", name = "SSR", server = "10.10.10.10", port = 8080, password = "p" }, +} +local m20_out = output.generate(m20_nodes, "v2ray") +local m20_cfg = util.json_decode(m20_out) +local m20_protos = {} +for _, o in ipairs(m20_cfg and m20_cfg.outbounds or {}) do + m20_protos[o.protocol] = true +end +for _, bad in ipairs({ "hysteria2", "hysteria", "tuic", "wireguard", "ssr" }) do + check("M20 v2ray drops " .. bad, not m20_protos[bad]) +end +check("M20 v2ray keeps vmess", m20_protos.vmess) +check("M20 v2ray keeps vless", m20_protos.vless) +check("M20 v2ray keeps trojan", m20_protos.trojan) +check("M20 v2ray maps ss ->shadowsocks", m20_protos.shadowsocks) +check("M20 v2ray keeps socks", m20_protos.socks) +check("M20 v2ray keeps freedom/blackhole", m20_protos.freedom and m20_protos.blackhole) +-- 被放行的 socks 凭据必须真的写进 users(否则等于放行了个空壳) +local m20_socks +for _, o in ipairs(m20_cfg.outbounds) do + if o.protocol == "socks" then m20_socks = o end +end +check("M20 socks credentials kept", m20_socks ~= nil + and m20_socks.settings.servers[1].users[1].user == "u") +local m20_ss +for _, o in ipairs(m20_cfg.outbounds) do + if o.protocol == "shadowsocks" then m20_ss = o end +end +check("M20 ss method kept", m20_ss ~= nil + and m20_ss.settings.servers[1].method == "aes-256-gcm") +-- 被丢弃的节点不能残留出站(tag 会进 balancer selector) +check("M20 dropped nodes produce no outbound", + #m20_cfg.outbounds == 7) -- vmess/vless/trojan/ss/socks + freedom/blackhole + +-- ---------- M21:clashmeta 写 flow ---------- +-- flow 是 XTLS Vision(xtls-rprx-vision)的必需参数;不写的话 mihomo 按普通 +-- vless 处理,服务端要求 vision 时握手失败。surge / v2ray / URI 三个输出都写 +-- flow,只有 clashmeta 漏了。 +local m21 = output_clash_meta.generate({ + { proto = "vless", name = "L", server = "1.1.1.1", port = 443, + uuid = "u", flow = "xtls-rprx-vision", security = "tls", sni = "e.com" }, +}) +check("M21 vless flow written", m21:find("flow: xtls%-rprx%-vision") ~= nil) +-- vmess 没有 flow 概念,不得凭空写出该键 +local m21b = output_clash_meta.generate({ + { proto = "vmess", name = "V", server = "1.1.1.1", port = 443, uuid = "u" }, +}) +check("M21 vmess has no flow", m21b:find("flow:", 1, true) == nil) +-- 无 flow 的 vless 也不写空键 +local m21c = output_clash_meta.generate({ + { proto = "vless", name = "L2", server = "1.1.1.1", port = 443, uuid = "u" }, +}) +check("M21 vless without flow omits key", m21c:find("flow:", 1, true) == nil) + +-- ---------- M22:clashmeta 的 grpc / h2 传输参数 ---------- +-- 只写 network: grpc 时客户端用默认服务名去连,握手失败——与 ws 丢 path 同类。 +-- 键名对照上游 transport 文档:grpc-service-name(带 grpc- 前缀); +-- 服务名存在 node.path(与 output_v2ray 的 serviceName、sing-box 的 service_name 同源)。 +local m22g = output_clash_meta.generate({ + { proto = "vless", name = "G", server = "1.1.1.1", port = 443, + uuid = "u", net = "grpc", path = "my-grpc-svc", security = "tls" }, +}) +check("M22 grpc network written", m22g:find("network: grpc") ~= nil) +check("M22 grpc-opts block", m22g:find("grpc%-opts:") ~= nil) +check("M22 grpc-service-name", m22g:find("grpc%-service%-name: my%-grpc%-svc") ~= nil) + +-- h2-opts.host 是**列表**,h2-opts.path 是标量(上游 transport 文档) +local m22h = output_clash_meta.generate({ + { proto = "trojan", name = "H", server = "2.2.2.2", port = 443, password = "p", + net = "h2", path = "/h2path", host = "h2.example.com" }, +}) +check("M22 h2 network written", m22h:find("network: h2") ~= nil) +check("M22 h2-opts block", m22h:find("h2%-opts:") ~= nil) +check("M22 h2 host is a list", m22h:find("host:\n %- h2%.example%.com") ~= nil) +check("M22 h2 path scalar", m22h:find("path: /h2path") ~= nil) + +-- 无 path 的 grpc 节点不写出空 grpc-opts 块 +local m22e = output_clash_meta.generate({ + { proto = "vless", name = "E", server = "4.4.4.4", port = 443, uuid = "u", net = "grpc" }, +}) +check("M22 grpc without path omits opts", m22e:find("grpc%-opts:", 1, true) == nil) + +-- grpc 节点不得写出 h2-opts,反之亦然 +check("M22 grpc has no h2-opts", m22g:find("h2%-opts:", 1, true) == nil) +check("M22 h2 has no grpc-opts", m22h:find("grpc%-opts:", 1, true) == nil) + +-- ws 行为不变(回归保护) +local m22w = output_clash_meta.generate({ + { proto = "vmess", name = "W", server = "3.3.3.3", port = 443, uuid = "u", + net = "ws", path = "/ws", host = "ws.example.com" }, +}) +check("M22 ws-opts unchanged", m22w:find("ws%-opts:") ~= nil + and m22w:find("path: /ws") ~= nil + and m22w:find("Host: ws%.example%.com") ~= nil) + +-- ---------- 结果 ---------- +print(string.format("\n%d passed, %d failed", passed, failed)) +os.exit(failed == 0 and 0 or 1) diff --git a/luci-app-substore/tests/parser_local_link_test.lua b/luci-app-substore/tests/parser_local_link_test.lua index 9c7bf4f4e..45b15a189 100644 --- a/luci-app-substore/tests/parser_local_link_test.lua +++ b/luci-app-substore/tests/parser_local_link_test.lua @@ -49,6 +49,56 @@ check("parse_local_link target SingBox", result3 and result3.target == "SingBox" check("parse_local_link name param", result3 and result3.name == "TestSub") check("parse_local_link user param", result3 and result3.user == "user123") +-- ---------- L16:无路径、query 直接跟在 authority 后面 ---------- +-- 旧模式 `^([^/]*)` 会把 "host?target=…" 整串当成 authority: +-- host 变成 "host?target=…",query 里的 target/name/uid 全部丢失。 +local q_url = "http://192.168.5.15:31013?target=ClashMeta&name=Foo" +local q = parser.parse_local_link(q_url) +check("no-path query host", q and q.host == "192.168.5.15") +check("no-path query port", q and q.port == "31013") +check("no-path query target kept", q and q.target == "ClashMeta") +check("no-path query name kept", q and q.name == "Foo") +check("no-path query path empty", q and q.path == "") + +-- 带 userinfo 的 authority:host 不能被 "user:pw@" 污染(detect_local_link 一直会剥) +local u_url = "http://user:pw@192.168.5.15:31013/arthur/download/X?target=ClashMeta" +local u = parser.parse_local_link(u_url) +check("userinfo stripped host", u and u.host == "192.168.5.15") +check("userinfo stripped port", u and u.port == "31013") +check("userinfo stripped path", u and u.path == "/arthur/download/X") +check("userinfo stripped target", u and u.target == "ClashMeta") + +-- ---------- L17:IPv6 内网判定必须展开后比较 ---------- +-- `::1` / `0::1` / `0000::1` / `0:0:0:0:0:0:0:1` 是同一个地址的不同写法。 +-- 旧实现比字符串前缀,`[0::1]` 被判成公网(SSRF 防护失效)。 +local function is_local(u) return parser.detect_local_link(u) ~= nil end +check("v6 loopback ::1", is_local("http://[::1]:8080/")) +check("v6 loopback 0::1", is_local("http://[0::1]:8080/")) +check("v6 loopback 0000::1", is_local("http://[0000::1]:8080/")) +check("v6 loopback expanded", is_local("http://[0:0:0:0:0:0:0:1]/")) +check("v6 unspecified ::", is_local("http://[::]/")) +check("v6 unspecified 0::", is_local("http://[0::]/")) +check("v6 ula fc00::1", is_local("http://[fc00::1]/")) +check("v6 ula fd12:3456::1", is_local("http://[fd12:3456::1]/")) +check("v6 link-local fe80::1", is_local("http://[fe80::1]/")) +check("v6 link-local with zone", is_local("http://[fe80::1%25eth0]/")) +check("v6 mapped loopback", is_local("http://[::ffff:127.0.0.1]/")) +check("v6 mapped private", is_local("http://[::ffff:10.0.0.1]/")) +-- 公网地址不能被误判为内网(否则正常订阅链接会被 SSRF 防护拦下) +check("v6 public 2001:db8::1", not is_local("http://[2001:db8::1]/")) +check("v6 public 2606:4700::1111", not is_local("http://[2606:4700::1111]/")) +check("v6 mapped public", not is_local("http://[::ffff:8.8.8.8]/")) +check("v6 public fd-prefix neighbour", not is_local("http://[fe00::1]/")) + +-- ---------- L18:纯空白 / 只有 BOM 的内容 ---------- +-- detect 会 trim 并去 BOM 后判为 empty,而 parse 只挡了完全空串, +-- 于是「全是空白」的订阅会掉到末尾报「无法识别的订阅格式」(误导)。 +for _, blank in ipairs({ "", " ", "\n\t ", "\239\187\191", "\239\187\191 \n" }) do + local bres, berr = parser.parse(blank) + check("blank content is empty format", bres ~= nil and bres.format == "empty" + and #bres.nodes == 0 and berr == nil) +end + -- ---------- 结果 ---------- print(string.format("\n%d passed, %d failed", passed, failed)) os.exit(failed == 0 and 0 or 1) diff --git a/luci-app-substore/tests/rules_fields_test.lua b/luci-app-substore/tests/rules_fields_test.lua new file mode 100644 index 000000000..72acb0abc --- /dev/null +++ b/luci-app-substore/tests/rules_fields_test.lua @@ -0,0 +1,142 @@ +-- rules_fields_test.lua — 订阅规则的「协议筛选 / 重命名」字段必须端到端可达 +-- 用法:lua5.1 tests/rules_fields_test.lua +-- +-- 覆盖 M30:`proto_filter_*` 与 `rename_map` 被控制器读取并落盘,但三个表单 +-- (form.htm / local_form.htm / combo.htm)都没有提交它们的控件 —— +-- 1. UI 不可达:用户永远无法设置这两项; +-- 2. **静默数据丢失**:控制器每次都把 formvalue 的 nil 落成 "", +-- 于是通过 UCI 手工设过的值在下一次保存时被抹掉。 +-- +-- .htm 需要 LuCI 运行时才能渲染,lua5.1 跑不了模板,因此这里分两层: +-- * 静态检查:模板确实提交了这两个字段,且协议清单与控制器同源; +-- * 行为检查:用同一份 RULE_PROTOS 复刻控制器的收集逻辑, +-- 验证它生成的 proto_filter 真的能被 node.apply_rules 用上。 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local util = require("substore.util") +local core = require("substore.core") +local node = require("substore.node") + +local passed, failed = 0, 0 + +local function check(name, cond) + if cond then + passed = passed + 1 + print("PASS " .. name) + else + failed = failed + 1 + print("FAIL " .. name) + end +end + +local TEMPLATES = { + "root/usr/lib/lua/luci/view/substore/form.htm", + "root/usr/lib/lua/luci/view/substore/local_form.htm", + "root/usr/lib/lua/luci/view/substore/combo.htm", +} +local CONTROLLER = "root/usr/lib/lua/luci/controller/admin/substore.lua" + +-- ---------- RULE_PROTOS 本身 ---------- +check("RULE_PROTOS is a table", type(core.RULE_PROTOS) == "table") +check("RULE_PROTOS non-empty", #core.RULE_PROTOS > 0) + +local seen, dup = {}, nil +for _, p in ipairs(core.RULE_PROTOS) do + if seen[p] then dup = p end + seen[p] = true +end +check("RULE_PROTOS has no duplicate" .. (dup and (" (" .. dup .. ")") or ""), dup == nil) + +-- 名字必须是 node.normalize 产出的**规范**协议名,否则 proto_filter 匹配不到任何 +-- 节点(node.apply_rules 用 set[n.proto] 精确比对),症状是「勾了没用」。 +local bad = {} +for _, p in ipairs(core.RULE_PROTOS) do + local n = node.normalize({ proto = p, server = "1.2.3.4", port = 443 }) + if n.proto ~= p then bad[#bad + 1] = p .. "->" .. tostring(n.proto) end +end +check("RULE_PROTOS are canonical proto names" .. (#bad > 0 and (" (" .. table.concat(bad, ",") .. ")") or ""), + #bad == 0) + +-- socks5 是解析阶段的归一目标(parser 把它写成 socks),勾选框里不该出现 socks5 +check("RULE_PROTOS has no socks5 alias", seen["socks5"] == nil) +check("RULE_PROTOS has socks", seen["socks"] == true) + +-- ---------- 静态:三个模板都必须提交这两个字段 ---------- +for _, path in ipairs(TEMPLATES) do + local src = util.read_file(path) + local name = path:match("[^/]+$") + check(name .. " readable", src ~= nil) + src = src or "" + check(name .. " submits rename_map", src:find('name="rename_map"', 1, true) ~= nil) + check(name .. " submits proto_filter_*", src:find('name="proto_filter_', 1, true) ~= nil) + -- 协议清单必须来自 core.RULE_PROTOS,模板里不得再抄一份 + check(name .. " iterates core.RULE_PROTOS", + src:find("ipairs(core.RULE_PROTOS)", 1, true) ~= nil) + -- 已有值必须回填,否则打开编辑页看不到当前设置,一保存就被覆盖 + check(name .. " preloads proto_filter", src:find("proto_sel", 1, true) ~= nil) + check(name .. " preloads rename_map", src:find("local rm =", 1, true) ~= nil) +end + +-- ---------- 静态:控制器不再自己抄一份协议清单 ---------- +local ctl = util.read_file(CONTROLLER) or "" +check("controller uses core.RULE_PROTOS", ctl:find("RULE_PROTOS", 1, true) ~= nil) +-- 旧的硬编码写法:ipairs({"vmess","vless",...}) +check("controller has no hardcoded proto list", + ctl:find('ipairs({"vmess"', 1, true) == nil) + +-- ---------- 行为:复刻控制器的收集逻辑,验证它真的能过滤 ---------- +-- 与 controller 的 read_rules_fields 保持一致:按 RULE_PROTOS 顺序遍历, +-- 表单里出现即为勾选(未勾选的 checkbox 浏览器不会提交)。 +local function collect_proto_filter(form) + local list = {} + for _, p in ipairs(core.RULE_PROTOS) do + if form["proto_filter_" .. p] then list[#list + 1] = p end + end + return table.concat(list, ",") +end + +check("no checkbox ticked -> empty filter", collect_proto_filter({}) == "") +check("ticked protocols -> csv in RULE_PROTOS order", + collect_proto_filter({ proto_filter_vmess = "1", proto_filter_trojan = "1" }) == "vmess,trojan") + +-- 空串 = 不筛选(node.apply_rules 只在非空时过滤) +local all_nodes = { + node.normalize({ proto = "vmess", server = "1.1.1.1", port = 443, uuid = "a" }), + node.normalize({ proto = "trojan", server = "2.2.2.2", port = 443, password = "p" }), + node.normalize({ proto = "hysteria2", server = "3.3.3.3", port = 443, password = "p" }), +} +local kept_all = node.apply_rules(all_nodes, { proto_filter = collect_proto_filter({}) }) +check("empty filter keeps everything", #kept_all == 3) + +local kept = node.apply_rules(all_nodes, { + proto_filter = collect_proto_filter({ proto_filter_trojan = "1" }), +}) +check("filter keeps only ticked protocol", #kept == 1 and kept[1].proto == "trojan") + +-- 每一个可选协议都必须真的能选中它自己(清单写错时这里会失败) +for _, p in ipairs(core.RULE_PROTOS) do + local n = node.normalize({ proto = p, server = "9.9.9.9", port = 443, + uuid = "u", password = "p", ["public-key"] = "k" }) + local f = collect_proto_filter({ ["proto_filter_" .. p] = "1" }) + local r = node.apply_rules({ n }, { proto_filter = f }) + check("filter selects " .. p, #r == 1) +end + +-- ---------- 行为:rename_map 经控制器落盘后确实生效 ---------- +local renamed = node.apply_rules( + { node.normalize({ proto = "trojan", server = "1.2.3.4", port = 443, + password = "p", name = "OldName" }) }, + { rename_map = "OldName=NewName" }) +check("rename_map applies", renamed[1].name == "NewName") + +-- 未设置 rename_map(控制器此前会把它写成 "")时不得改动名字 +local untouched = node.apply_rules( + { node.normalize({ proto = "trojan", server = "1.2.3.4", port = 443, + password = "p", name = "OldName" }) }, + { rename_map = "" }) +check("empty rename_map leaves names alone", untouched[1].name == "OldName") + +-- ---------- 结果 ---------- +print(string.format("\n%d passed, %d failed", passed, failed)) +os.exit(failed == 0 and 0 or 1) diff --git a/luci-app-substore/tests/run_tests.lua b/luci-app-substore/tests/run_tests.lua index e39ad9ed9..e82dfbf4f 100644 --- a/luci-app-substore/tests/run_tests.lua +++ b/luci-app-substore/tests/run_tests.lua @@ -51,6 +51,16 @@ local h1, p1 = util.split_hostport("1.2.3.4:443") check("split_hostport v4", h1 == "1.2.3.4" and p1 == "443") local h2, p2 = util.split_hostport("[::1]:80") check("split_hostport v6", h2 == "::1" and p2 == "80") +-- L13:尾随冒号此前被当成主机名的一部分返回(host="example.com:"), +-- 调用方拿它去解析 DNS 必然失败,且失败点离这里很远。 +local h3, p3 = util.split_hostport("example.com:") +check("split_hostport strips trailing colon", h3 == "example.com" and p3 == nil) +local h4, p4 = util.split_hostport("[::1]:") +check("split_hostport v6 without port", h4 == "::1" and p4 == nil) +local h5, p5 = util.split_hostport("::1") +check("split_hostport bare ipv6 untouched", h5 == "::1" and p5 == nil) +local h6, p6 = util.split_hostport(":") +check("split_hostport bare colon rejected", h6 == nil and p6 == nil) check("url_extract", util.url_extract("?type=tcp&security=none", "security") == "none") check("url_decode", util.url_decode("%E6%9C%BA%E5%9C%BA") == "机场") diff --git a/luci-app-substore/tests/util_json_test.lua b/luci-app-substore/tests/util_json_test.lua new file mode 100644 index 000000000..8e8ae1314 --- /dev/null +++ b/luci-app-substore/tests/util_json_test.lua @@ -0,0 +1,140 @@ +-- util_json_test.lua — JSON 编解码保真与错误上报单元测试 +-- 用法:lua5.1 tests/util_json_test.lua +-- +-- 覆盖审计表 M13(null / 空对象往返被改写)与 M14(json_decode 永不返回错误串)。 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local util = require("substore.util") + +local passed, failed = 0, 0 +local function check(name, cond) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("FAIL: " .. name) + end +end + +local function rt(s) -- 解码再编码,返回编码结果与错误 + local v, err = util.json_decode(s) + if err then return nil, err end + return util.json_encode(v) +end + +-- ---------- M14:错误必须上报 ---------- +do + local _, err = util.json_decode("{") + check("M14 unterminated object reports error", type(err) == "string") + + local _, err2 = util.json_decode("[1,2") + check("M14 unterminated array reports error", type(err2) == "string") + + local _, err3 = util.json_decode('{"a":}') + check("M14 missing value reports error", type(err3) == "string") + + local _, err4 = util.json_decode("[1,]") + check("M14 trailing comma in array rejected", type(err4) == "string") + + local _, err5 = util.json_decode('{"a":1,}') + check("M14 trailing comma in object rejected", type(err5) == "string") + + -- 合法输入不得被误判 + local v, e = util.json_decode('{"a":1}') + check("M14 valid object still parses", type(v) == "table" and v.a == 1 and e == nil) + + local arr, e2 = util.json_decode("[1,2,3]") + check("M14 valid array still parses", type(arr) == "table" and #arr == 3 and e2 == nil) + + -- 顶层 null 是合法 JSON,必须与「解析失败」区分开 + local n, e3 = util.json_decode("null") + check("M14 top-level null is valid, not an error", n == nil and e3 == nil) +end + +-- ---------- M14:尾部脏数据必须拒绝 ---------- +do + local _, err = util.json_decode("1 2") + check("M14 trailing content after number rejected", type(err) == "string") + + local _, err2 = util.json_decode("[1] junk") + check("M14 trailing content after array rejected", type(err2) == "string") + + local _, err3 = util.json_decode('{"a":1}{"b":2}') + check("M14 two concatenated objects rejected", type(err3) == "string") + + local _, err4 = util.json_decode("1.2.3") + check("M14 malformed number rejected", type(err4) == "string") + + -- 尾随空白是合法的 + local v, e = util.json_decode("[1] \n\t ") + check("M14 trailing whitespace allowed", type(v) == "table" and v[1] == 1 and e == nil) +end + +-- ---------- M13:数组中的 null 往返保持 null ---------- +do + local out = rt("[null,1]") + check("M13 null in array round-trips to null", out == "[null,1]") + + local out2 = rt("[null,null]") + check("M13 multiple nulls round-trip", out2 == "[null,null]") + + local out3 = rt('{"a":[1,null,2]}') + check("M13 nested null in object array round-trips", out3 == '{"a":[1,null,2]}') + + -- 编码侧直接喂 null 占位符 + local decoded = util.json_decode("[null]") + check("M13 decoded null slot is a table placeholder", type(decoded) == "table" and type(decoded[1]) == "table") +end + +-- ---------- M13:空对象往返保持对象 ---------- +do + local out = rt('{"tls":{}}') + check("M13 empty object round-trips as object", out == '{"tls":{}}') + + local out2 = rt('{"settings":{},"x":1}') + check("M13 empty object among other keys round-trips", out2 == '{"settings":{},"x":1}') + + -- 空数组仍须保持数组 + local out3 = rt("[]") + check("M13 empty array still round-trips as array", out3 == "[]") + + local out4 = rt('{"a":[]}') + check("M13 empty array value round-trips", out4 == '{"a":[]}') + + -- 显式常量仍然有效 + check("M13 JSON_EMPTY_OBJECT still encodes to {}", util.json_encode(util.JSON_EMPTY_OBJECT) == "{}") +end + +-- ---------- 回归:既有行为不得被破坏 ---------- +do + local out = rt('{"a":1,"b":"x","c":true,"d":false}') + check("regression object scalars", out ~= nil and out:find('"a":1', 1, true) ~= nil + and out:find('"b":"x"', 1, true) ~= nil and out:find('"c":true', 1, true) ~= nil) + + local out2 = rt('[1,2,3]') + check("regression plain array", out2 == "[1,2,3]") + + local out3 = rt('"hello"') + check("regression plain string", out3 == '"hello"') + + local out4 = rt("42") + check("regression plain number", out4 == "42") + + -- 转义序列 + local s = util.json_decode('"a\\nb\\tc\\"d"') + check("regression escape sequences", s == 'a\nb\tc"d') + + -- unicode 转义 + local u = util.json_decode('"\\u4e2d"') + check("regression unicode escape", u == "中") + + -- 空对象解码后仍是 table 且可被 pairs 遍历(调用方普遍这么用) + local o = util.json_decode("{}") + local cnt = 0 + for _ in pairs(o) do cnt = cnt + 1 end + check("regression decoded empty object iterable", type(o) == "table" and cnt == 0) +end + +print(string.format("\n%d passed, %d failed", passed, failed)) +os.exit(failed == 0 and 0 or 1) diff --git a/luci-app-substore/tests/wireguard_conf_test.lua b/luci-app-substore/tests/wireguard_conf_test.lua index 39a934618..f05a14449 100644 --- a/luci-app-substore/tests/wireguard_conf_test.lua +++ b/luci-app-substore/tests/wireguard_conf_test.lua @@ -481,6 +481,115 @@ do check("form: empty awg json cleared", empty["amnezia-wg-option"] == nil) end +-- ---------- M9:行内注释 ---------- +-- wg-quick 的 parse_options 用 `stripped="${line%%\#*}"`,即从**第一个** # 起 +-- 全部丢弃(不要求 # 前有空白),所以 .conf 是支持行内注释的。旧实现只跳整行 +-- 注释,`Endpoint = 1.2.3.4:51820 # 备用` 会把 "# 备用" 当成值的一部分, +-- split_hostport 取不到端口 → 整份 .conf 报错。 +local INLINE_CONF = [[ +[Interface] +PrivateKey = PRIVI # 主密钥 +Address = 10.11.0.2/32 # 隧道地址 +DNS = 1.1.1.1, 8.8.8.8 # 双 DNS + +[Peer] +PublicKey = PUBI # 对端公钥 +AllowedIPs = 0.0.0.0/0, ::/0 # 全量路由 +Endpoint = 198.51.100.7:51820 # 备用入口 +PersistentKeepalive = 25 # 保活 +]] +local ires = parser.parse(INLINE_CONF, "wireguard-conf") +check("inline comment conf parses", ires ~= nil and ires.nodes and #ires.nodes == 1) +local inode = ires and ires.nodes[1] or {} +check("inline comment endpoint host", inode.server == "198.51.100.7") +check("inline comment endpoint port", inode.port == 51820) +check("inline comment private-key", inode["private-key"] == "PRIVI") +check("inline comment public-key", inode["public-key"] == "PUBI") +check("inline comment allowed-ips", type(inode["allowed-ips"]) == "table" + and #inode["allowed-ips"] == 2 and inode["allowed-ips"][2] == "::/0") +check("inline comment keepalive", inode["persistent-keepalive"] == 25) +check("inline comment dns array", type(inode.dns) == "table" and #inode.dns == 2) + +-- ---------- M9:一个坏 [Peer] 不得废掉整份文件 ---------- +-- 旧实现在第一个失败的对端上直接 return nil,同文件里其它完好的对端全部丢失。 +local PARTIAL_CONF = [[ +[Interface] +PrivateKey = PRIVP +Address = 10.12.0.2/32 + +[Peer] +PublicKey = NOENDPOINT +AllowedIPs = 10.0.0.0/8 + +[Peer] +PublicKey = GOODPEER +AllowedIPs = 0.0.0.0/0 +Endpoint = 203.0.113.77:51820 +]] +local pres = parser.parse(PARTIAL_CONF, "wireguard-conf") +check("partial conf still parses", pres ~= nil and pres.nodes ~= nil) +check("partial conf keeps the good peer", pres and pres.nodes and #pres.nodes == 1) +check("partial conf good peer server", + pres and pres.nodes[1] and pres.nodes[1].server == "203.0.113.77") +check("partial conf good peer key", + pres and pres.nodes[1] and pres.nodes[1]["public-key"] == "GOODPEER") + +-- 全部 [Peer] 都缺 Endpoint:必须报错,不能返回空列表 —— +-- 空列表会被上层当成「解析成功但 0 节点」(静默失败) +local ALLBAD_CONF = [[ +[Interface] +PrivateKey = PRIVB + +[Peer] +PublicKey = B1 + +[Peer] +PublicKey = B2 +Endpoint = :51820 +]] +local abres, aberr = parser.parse(ALLBAD_CONF, "wireguard-conf") +check("all-bad peers returns nil", abres == nil) +check("all-bad peers returns error", type(aberr) == "string" and #aberr > 0) + +-- ---------- L20:多 [Peer] 时数组字段不得共享同一个表 ---------- +-- common 里的 dns / reserved 是数组。浅拷贝让所有节点指向同一个表, +-- 按节点编辑 DNS 会同时改到全部节点。 +local SHARE_CONF = [[ +[Interface] +PrivateKey = PRIVS +Address = 10.13.0.2/32 +DNS = 1.1.1.1, 8.8.8.8 +Reserved = 1, 2, 3 + +[Peer] +PublicKey = S1 +AllowedIPs = 0.0.0.0/0 +Endpoint = 198.51.100.1:51820 + +[Peer] +PublicKey = S2 +AllowedIPs = 10.0.0.0/8 +Endpoint = 198.51.100.2:51820 +]] +local sres = parser.parse(SHARE_CONF, "wireguard-conf") +check("shared-table conf two nodes", sres ~= nil and sres.nodes and #sres.nodes == 2) +if sres and sres.nodes and #sres.nodes == 2 then + local s1, s2 = sres.nodes[1], sres.nodes[2] + check("dns tables distinct", + type(s1.dns) == "table" and type(s2.dns) == "table" and s1.dns ~= s2.dns) + check("reserved tables distinct", + type(s1.reserved) == "table" and type(s2.reserved) == "table" + and s1.reserved ~= s2.reserved) + check("allowed-ips tables distinct", s1["allowed-ips"] ~= s2["allowed-ips"]) + check("shared awg tables distinct", + s1["amnezia-wg-option"] == nil and s2["amnezia-wg-option"] == nil) + -- 改一个节点不能影响另一个 + s1.dns[1] = "MUTATED" + s1.reserved[1] = 99 + check("dns mutation isolated", s2.dns[1] == "1.1.1.1") + check("reserved mutation isolated", s2.reserved[1] == 1) +end + -- ---------- 结果 ---------- -- H7:.conf 的注释行同样要压成单行。节点名来自订阅(不可信输入),含换行时 -- 会从注释里注入出真正的配置行,伪造出一段用户没写过的隧道配置。 diff --git a/luci-app-trafficctl/Makefile b/luci-app-trafficctl/Makefile index f8cb92955..0f26a69a2 100644 --- a/luci-app-trafficctl/Makefile +++ b/luci-app-trafficctl/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-trafficctl -PKG_VERSION:=1.19.1 -PKG_RELEASE:=6 +PKG_VERSION:=1.21.0 +PKG_RELEASE:=7 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Denis Iusupov diff --git a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.css b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.css index df1de3395..a379735ff 100644 --- a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.css +++ b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.css @@ -252,6 +252,21 @@ overflow: hidden; border: 1px solid var(--tc-border); } +/* Unit picker inside .tc-custom-unit-btns. The download row's pair still + carries these as inline styles; new controls use the class so the active + state is not another inline override to hunt down. */ +.tc-unit-btn { + padding: 4px 8px; + font-size: 11px; + cursor: pointer; + background: var(--tc-bg); + color: currentColor; + user-select: none; +} +.tc-unit-btn--on { + background: var(--tc-speed); + color: var(--tc-on-accent); +} /* ── Subnet target: the each/shared explanation and its warning ──── */ .tc-scope-row { diff --git a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js index af4ba3b33..c2668eb51 100644 --- a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js +++ b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js @@ -114,7 +114,10 @@ var callMacfilterRemove = rpc.declare({ var callRatelimit = rpc.declare({ object: 'luci.trafficctl', method: 'ratelimit', - params: ['ip', 'rate_kbit', 'label', 'mode'] + // rate_kbit_up is omitted for a symmetric limit rather than sent equal to + // rate_kbit: the backend reads absence as "same as download", and sending + // it always would make every record asymmetric-shaped on disk. + params: ['ip', 'rate_kbit', 'label', 'mode', 'rate_kbit_up'] }); var callRatelimitStats = rpc.declare({ @@ -132,7 +135,7 @@ var callSubnets = rpc.declare({ var callShapeAdd = rpc.declare({ object: 'luci.trafficctl', method: 'shape_add', - params: ['ip', 'rate_kbit', 'label'] + params: ['ip', 'rate_kbit', 'label', 'rate_kbit_up'] }); var callShapeRemove = rpc.declare({ @@ -2334,6 +2337,47 @@ return view.extend({ customInput, customUnitBtns, customApplyBtn ]); + // ── Separate upload ceiling ──────────────────────────────────────── + // + // Disclosed rather than always shown. Most limits are symmetric, and + // the backend treats an absent upload rate as "same as download", so + // the default state of this control matches the default meaning: one + // number, one click. Opening it is the opt-in into two. + // + // The rate chips above stay the DOWNLOAD ceiling in either state, so + // the common path does not change shape when this is revealed. + var upInput = E('input', { 'type':'number', 'min':'1', 'step':'1', + 'placeholder': _('value'), 'class': 'tc-custom-input' }); + var _upUnit = 'mbit'; + var upMbitBtn = E('span', {'class':'tc-unit-btn'}, _('Mbit/s')); + var upKbitBtn = E('span', {'class':'tc-unit-btn'}, _('kbit/s')); + function updateUpUnitBtns() { + upMbitBtn.classList.toggle('tc-unit-btn--on', _upUnit === 'mbit'); + upKbitBtn.classList.toggle('tc-unit-btn--on', _upUnit === 'kbit'); + } + upMbitBtn.addEventListener('click', function() { _upUnit = 'mbit'; updateUpUnitBtns(); updateScopeExplain(); }); + upKbitBtn.addEventListener('click', function() { _upUnit = 'kbit'; updateUpUnitBtns(); updateScopeExplain(); }); + updateUpUnitBtns(); + upInput.addEventListener('input', function() { updateScopeExplain(); }); + + var upRow = E('div', {'class':'tc-custom-row tc-hidden'}, [ + E('span', {'class':'tc-c-muted','style':'font-size:11px'}, _('↑ Upload')), + upInput, + E('span', {'class':'tc-custom-unit-btns'}, [upMbitBtn, upKbitBtn]) + ]); + + var upToggleBtn = E('span', {'class': 'tc-chip', + 'data-tip': _('Give upload its own ceiling instead of matching download') + }, '⇅ ' + _('Split up/down')); + upToggleBtn.addEventListener('click', function() { + upRow.classList.toggle('tc-hidden'); + var on = !upRow.classList.contains('tc-hidden'); + upToggleBtn.classList.toggle('tc-chip--active', on); + if (on) { upInput.focus(); } else { upInput.value = ''; } + updateScopeExplain(); + }); + rateChipsRow.appendChild(upToggleBtn); + // Mode: segmented toggle (Shaper default) var modeToggle = E('div', {'class':'tc-mode-toggle'}); var shaperBtn = E('span', { @@ -2446,15 +2490,29 @@ return view.extend({ var t = (scopeInput.value || 'all').trim(); var shown = (t === 'all' || t === 'any') ? _('every device') : t; var kbit = parseInt(getRateKbit(), 10); + // The sentence has to quote the rate that will actually be applied. + // Naming only the download figure while a split ceiling is set would + // describe a different rule than the one being installed — and these + // sentences exist precisely so the each/shared distinction cannot be + // misread. + var kbitUp = parseInt(getRateKbitUp(), 10); + var rateWords = (kbitUp && kbitUp !== kbit) + ? _('%s down and %s up').format(fmtRate(kbit), fmtRate(kbitUp)) + : fmtRate(kbit); + // A split ceiling is two buckets, one per direction, so the aggregate + // wording changes with it. + var sharedTail = (kbitUp && kbitUp !== kbit) + ? _('between them — one bucket per direction for the whole subnet.') + : _('between them — one bucket for the whole subnet.'); if (!kbit || kbit <= 0) { scopeExplain.textContent = _('Pick a rate to apply it to this target, or Off to remove its limit.'); } else if (_scopeSelected === 'shared') { scopeExplain.textContent = _('All of') + ' ' + shown + ' ' + _('share') + ' ' + - fmtRate(kbit) + ' ' + _('between them — one bucket for the whole subnet.'); + rateWords + ' ' + sharedTail; } else { scopeExplain.textContent = _('Every device in') + ' ' + shown + ' ' + - _('may use') + ' ' + fmtRate(kbit) + ' ' + _('of its own.'); + _('may use') + ' ' + rateWords + ' ' + _('of its own.'); } var bad = !targetIsMonitored(t); @@ -2588,6 +2646,22 @@ return view.extend({ return String(Math.round(n)); } + // Empty string means symmetric — never 0, which the backend would have + // to read as a ceiling and which is a total upload block. + function getRateKbitUp() { + if (upRow.classList.contains('tc-hidden')) return ''; + var n = parseFloat(upInput.value); + if (!n || n <= 0) return ''; + return String(_upUnit === 'mbit' ? Math.round(n * 1000) : Math.round(n)); + } + + // How the pending action is described. Naming one figure when the + // operator has entered two would hide the thing they just asked for. + function rateLabel(kbit, up) { + if (!up || up === kbit) return fmtRate(parseInt(kbit)); + return _('%s down / %s up').format(fmtRate(parseInt(kbit)), fmtRate(parseInt(up))); + } + function applyRate() { var all = isAllMode(); // In all-devices mode the target is the typed scope ("all" or a @@ -2595,6 +2669,7 @@ return view.extend({ var ip = all ? (scopeInput.value || 'all').trim() : searchSelect.getValue(); var name = ''; var kbit = getRateKbit(); + var kbitUp = getRateKbitUp(); // A block target has no single tc classid, so the shaper can't // express it — force the limiter rather than silently doing nothing. var mode = all ? 'limiter' : _modeSelected; @@ -2602,8 +2677,8 @@ return view.extend({ if (all && kbit !== '0') { setStatus(statusDiv, 'loading', - _('Limiting') + ' ' + ip + ' → ' + fmtRate(parseInt(kbit)) + ' (' + scope + ')…'); - callRatelimit(ip, parseInt(kbit), name, scope).then(function(res) { + _('Limiting') + ' ' + ip + ' → ' + rateLabel(kbit, kbitUp) + ' (' + scope + ')…'); + callRatelimit(ip, parseInt(kbit), name, scope, kbitUp ? parseInt(kbitUp) : null).then(function(res) { setStatus(statusDiv, (res && res.ok) ? 'action' : 'error', (res && res.msg) || '?'); runQuery(); }).catch(function(e) { setStatus(statusDiv, 'error', '✗ '+e.message); }); @@ -2629,18 +2704,18 @@ return view.extend({ runQuery(); }).catch(function(e) { setStatus(statusDiv, 'error', '✗ '+e.message); }); } else if (mode === 'shaper') { - setStatus(statusDiv, 'loading', _('Shaping') + ' → ' + fmtRate(parseInt(kbit)) + '…'); + setStatus(statusDiv, 'loading', _('Shaping') + ' → ' + rateLabel(kbit, kbitUp) + '…'); callRatelimit(ip, 0, name) - .then(function() { return callShapeAdd(ip, parseInt(kbit), name); }) + .then(function() { return callShapeAdd(ip, parseInt(kbit), name, kbitUp ? parseInt(kbitUp) : null); }) .then(function(res) { setStatus(statusDiv, (res && res.ok) ? 'action' : 'error', (res && res.msg) || '?'); runQuery(); }) .catch(function(e) { setStatus(statusDiv, 'error', '✗ '+e.message); }); } else { - setStatus(statusDiv, 'loading', _('Limiting') + ' → ' + fmtRate(parseInt(kbit)) + '…'); + setStatus(statusDiv, 'loading', _('Limiting') + ' → ' + rateLabel(kbit, kbitUp) + '…'); callShapeRemove(ip, name) - .then(function() { return callRatelimit(ip, parseInt(kbit), name); }) + .then(function() { return callRatelimit(ip, parseInt(kbit), name, '', kbitUp ? parseInt(kbitUp) : null); }) .then(function(res) { setStatus(statusDiv, (res && res.ok) ? 'action' : 'error', (res && res.msg) || '?'); runQuery(); diff --git a/luci-app-trafficctl/root/etc/hotplug.d/iface/99-trafficctl-shapes b/luci-app-trafficctl/root/etc/hotplug.d/iface/99-trafficctl-shapes index d41b5e17d..f7a601f58 100644 --- a/luci-app-trafficctl/root/etc/hotplug.d/iface/99-trafficctl-shapes +++ b/luci-app-trafficctl/root/etc/hotplug.d/iface/99-trafficctl-shapes @@ -27,8 +27,13 @@ done if [ -f "$SHAPES_FILE" ]; then jsonfilter -i "$SHAPES_FILE" -e '@[*].ip' 2>/dev/null | while read -r ip; do rate=$(jsonfilter -i "$SHAPES_FILE" -e "@[@.ip='$ip'].rate_kbit" 2>/dev/null) + # Absent on symmetric records, which is all of them before asymmetric + # shaping existed; trafficctl-shape.sh reads empty as "same as + # download". The label has to be passed explicitly because the upload + # rate is the fifth positional. + rate_up=$(jsonfilter -i "$SHAPES_FILE" -e "@[@.ip='$ip'].rate_kbit_up" 2>/dev/null) if [ -n "$rate" ] && [ "$rate" != "0" ]; then - /usr/local/bin/trafficctl-shape.sh add "$ip" "$rate" || \ + /usr/local/bin/trafficctl-shape.sh add "$ip" "$rate" "shape_$ip" "$rate_up" || \ logger -t trafficctl "Failed to restore shape for $ip (${rate}kbit)" fi done @@ -56,8 +61,13 @@ if [ -f "$RULES_FILE" ] && tctl_persist_enabled; then # a subnet does not change meaning across the upgrade. mode=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].mode" 2>/dev/null) [ -z "$mode" ] && mode=$(tctl_ratelimit_default_mode "$ip") + # Absent on every record written before the directions could + # differ, and on every symmetric one since. tctl_ratelimit_add + # reads that as "same as download", which is what those records + # have always meant. + param_up=$(jsonfilter -i "$RULES_FILE" -e "@[$idx].param_up" 2>/dev/null) [ -n "$param" ] && [ "$param" != "0" ] && \ - tctl_ratelimit_add "$ip" "$param" "$(tctl_ratelimit_comment "$ip")" "$mode" 2>/dev/null || \ + tctl_ratelimit_add "$ip" "$param" "$(tctl_ratelimit_comment "$ip")" "$mode" "$param_up" 2>/dev/null || \ logger -t trafficctl "Failed to restore ratelimit for $ip (${param}kbit)" ;; pfw_pause|pfw_limit) diff --git a/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl b/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl index b65f8a323..e26b9cdf6 100755 --- a/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl +++ b/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl @@ -43,10 +43,10 @@ list) printf '"unblock":{"ip":"str","label":"str"},' printf '"macfilter_add":{"ip":"str"},' printf '"macfilter_remove":{"ip":"str"},' - printf '"ratelimit":{"ip":"str","rate_kbit":"int","label":"str","mode":"str"},' + printf '"ratelimit":{"ip":"str","rate_kbit":"int","label":"str","mode":"str","rate_kbit_up":"int"},' printf '"ratelimit_stats":{},' printf '"subnets":{},' - printf '"shape_add":{"ip":"str","rate_kbit":"int","label":"str"},' + printf '"shape_add":{"ip":"str","rate_kbit":"int","label":"str","rate_kbit_up":"int"},' printf '"shape_remove":{"ip":"str","label":"str"},' printf '"shape_status":{"ip":"str"},' printf '"shape_stats":{},' @@ -159,10 +159,16 @@ call) label=$(sanitize_label "$(echo "$input" | jsonfilter -e '@.label' 2>/dev/null)") mode=$(echo "$input" | jsonfilter -e '@.mode' 2>/dev/null) case "$mode" in each|shared) ;; *) mode="" ;; esac + rate_up=$(echo "$input" | jsonfilter -e '@.rate_kbit_up' 2>/dev/null) + # A caller that does not send the field — any UI older than this + # method signature — means "symmetric", NOT "no upload allowed". Zero + # is normalised away for the same reason: a zero ceiling polices every + # packet, which is a total upload block reporting itself as a limit. + [ "$rate_up" = "0" ] && rate_up="" if [ -z "$ip" ]; then echo '{"ok":false,"msg":"ip required"}' else - "$SCRIPTS/trafficctl-ratelimit.sh" "$ip" "${rate:-0}" "$label" "$mode" + "$SCRIPTS/trafficctl-ratelimit.sh" "$ip" "${rate:-0}" "$label" "$mode" "$rate_up" fi ;; ratelimit_stats) @@ -180,10 +186,13 @@ call) ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null) rate=$(echo "$input" | jsonfilter -e '@.rate_kbit' 2>/dev/null) label=$(sanitize_label "$(echo "$input" | jsonfilter -e '@.label' 2>/dev/null)") + rate_up=$(echo "$input" | jsonfilter -e '@.rate_kbit_up' 2>/dev/null) + # Same reading as the limiter: absent or zero means "same as download". + [ "$rate_up" = "0" ] && rate_up="" if [ -z "$ip" ] || [ -z "$rate" ]; then echo '{"ok":false,"msg":"ip and rate_kbit required"}' else - "$SCRIPTS/trafficctl-shape.sh" add "$ip" "$rate" "$label" + "$SCRIPTS/trafficctl-shape.sh" add "$ip" "$rate" "$label" "$rate_up" fi ;; shape_remove) diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh index d5cf45306..b38095de9 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh @@ -119,18 +119,26 @@ tctl_conntrack_flush_v6() { # meter keyed by address); "shared" makes the whole target share one bucket. # For a single host the two are identical. tctl_ratelimit_add() { - local ip="$1" rate_kbit="$2" comment="$3" mode="${4:-shared}" + local ip="$1" rate_kbit="$2" comment="$3" mode="${4:-shared}" rate_kbit_up="$5" + # Empty means "same as download" — the only meaning this had before the two + # directions could differ. Not zero: a zero ceiling policed every packet and + # would be a silent total block reported as a successful limit. + [ -z "$rate_kbit_up" ] && rate_kbit_up="$rate_kbit" local rate_kbyte=$((rate_kbit / 8)) [ "$rate_kbyte" -lt 1 ] && rate_kbyte=1 + local rate_kbyte_up=$((rate_kbit_up / 8)) + [ "$rate_kbyte_up" -lt 1 ] && rate_kbyte_up=1 local slug dl_expr ul_expr ul6_expr mac slug=$(tctl_target_slug "$ip") + # The two directions were already separate rules on separate hooks — they + # were symmetric only because both took the same number. if [ "$mode" = "each" ]; then dl_expr="ip daddr $ip meter tctl_d_$slug { ip daddr limit rate over ${rate_kbyte} kbytes/second }" - ul_expr="ip saddr $ip meter tctl_u_$slug { ip saddr limit rate over ${rate_kbyte} kbytes/second }" + ul_expr="ip saddr $ip meter tctl_u_$slug { ip saddr limit rate over ${rate_kbyte_up} kbytes/second }" else dl_expr="ip daddr $ip limit rate over ${rate_kbyte} kbytes/second" - ul_expr="ip saddr $ip limit rate over ${rate_kbyte} kbytes/second" + ul_expr="ip saddr $ip limit rate over ${rate_kbyte_up} kbytes/second" fi # Upload over IPv6, keyed on the MAC. This direction and only this one: @@ -155,7 +163,7 @@ tctl_ratelimit_add() { # the wrong one does not silently under-match — it fails to load. ul6_expr="" if mac=$(tctl_target_mac "$ip"); then - ul6_expr="meta protocol ip6 ether saddr $mac limit rate over ${rate_kbyte} kbytes/second" + ul6_expr="meta protocol ip6 ether saddr $mac limit rate over ${rate_kbyte_up} kbytes/second" fi if [ "$TCTL_FW" = "nft" ]; then @@ -837,15 +845,18 @@ tctl_persist_enabled() { } tctl_persist_save() { - local type="$1" ip="$2" param="$3" mode="$4" + local type="$1" ip="$2" param="$3" mode="$4" param_up="$5" [ -d "$(dirname "$TCTL_RULES_FILE")" ] || mkdir -p "$(dirname "$TCTL_RULES_FILE")" [ -f "$TCTL_RULES_FILE" ] || echo '[]' > "$TCTL_RULES_FILE" local tmp="${TCTL_RULES_FILE}.tmp" # The mode field is written only when the caller has one, so records for # rule types that have no bucket layout (blocks, port forwards) keep their - # exact previous shape. + # exact previous shape. param_up follows the same rule: the caller passes it + # only for an asymmetric limit, so a symmetric one still writes the record + # it always wrote and an older package can still read it back. local extra="" [ -n "$mode" ] && extra=",\"mode\":\"$mode\"" + [ -n "$param_up" ] && extra="$extra,\"param_up\":\"$param_up\"" # Remove existing entry for same ip+type, append new one awk -v ip="$ip" -v t="$type" -v p="$param" -v x="$extra" ' { diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit.sh index 5b97a08b7..d9551dd78 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit.sh @@ -2,11 +2,13 @@ # shellcheck shell=dash # Rate-limit bandwidth in both directions (policer). # -# Usage: trafficctl-ratelimit.sh [label] [mode] +# Usage: trafficctl-ratelimit.sh [label] [mode] [rate_up] # target a host (10.0.20.122), a CIDR (10.0.20.0/24), or "all" # mode each — every address in the target gets its own bucket (default # for CIDR/all, so "5 Mbit each" means per device) # shared — the whole target shares one bucket (aggregate cap) +# rate_up upload ceiling in kbit/s; omitted means the same as rate_kbit, +# which is what this did before the directions could differ # rate_kbit=0 removes the limit. . /usr/local/bin/trafficctl-fw.sh @@ -14,6 +16,11 @@ IP="$1" RATE="$2" MODE="$4" +# Fifth positional so every existing caller keeps working unchanged. Empty is +# "same as download", never zero — a zero ceiling policed every packet, which +# is a total block dressed up as a limit. +RATE_UP="$5" +[ -z "$RATE_UP" ] && RATE_UP="$RATE" if [ -z "$IP" ] || [ -z "$RATE" ]; then echo '{"ok":false,"msg":"usage: trafficctl-ratelimit.sh [label] [each|shared]"}' @@ -50,11 +57,20 @@ if [ "$RATE" = "0" ]; then exit 1 fi else + # How the applied ceiling is spelled out. Naming one number when the + # operator gave two would hide exactly the thing they asked for. + rate_text() { + if [ "$RATE_UP" != "$RATE" ]; then + echo "${RATE} kbit/s down, ${RATE_UP} kbit/s up" + else + echo "${RATE} kbit/s" + fi + } tctl_ratelimit_remove "$IP" "$COMMENT" 2>/dev/null TCTL_RL_DOWNLOAD_FAILED=0 TCTL_RL_UPLOAD_FAILED=0 TCTL_RL_UPLOAD6_OK=0 - tctl_ratelimit_add "$IP" "$RATE" "$COMMENT" "$MODE" + tctl_ratelimit_add "$IP" "$RATE" "$COMMENT" "$MODE" "$RATE_UP" # IPv6 coverage is partial by construction and the operator has to know # which part: upload is policed on the MAC, download is not policed at all @@ -75,13 +91,16 @@ else # The mode is persisted with the rate. Without it the restore hook fell back # to tctl_ratelimit_add's own default ("shared"), so a subnet limited # "5 Mbit each" came back after a reboot as 5 Mbit for the entire subnet. - tctl_persist_enabled && tctl_persist_save "ratelimit" "$IP" "$RATE" "$MODE" - tctl_log "ratelimit_set" "$IP" "${RATE}kbit" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}" + # The upload rate is persisted alongside, and only when it differs — a + # symmetric limit keeps producing exactly the record it always did. + tctl_persist_enabled && tctl_persist_save "ratelimit" "$IP" "$RATE" "$MODE" \ + "$([ "$RATE_UP" != "$RATE" ] && echo "$RATE_UP")" + tctl_log "ratelimit_set" "$IP" "$(rate_text)" "${TCTL_VIA:-cli}" "${TCTL_SRC:-local}" if [ "$TCTL_RL_DOWNLOAD_FAILED" = "1" ]; then - echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"msg\":\"rate limit ${RATE} kbit/s applied to $IP UPLOAD ONLY — WAN device not resolvable$V6NOTE\"}" + echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"msg\":\"rate limit ${RATE_UP} kbit/s applied to $IP UPLOAD ONLY — WAN device not resolvable$V6NOTE\"}" elif [ "$TCTL_RL_UPLOAD_FAILED" = "1" ]; then echo "{\"ok\":true,\"ipv6_upload\":false,\"msg\":\"rate limit ${RATE} kbit/s applied to $IP DOWNLOAD ONLY — no LAN ingress device [IPv4 only]\"}" else - echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"msg\":\"rate limit ${RATE} kbit/s for $IP (both directions, $MODE)$V6NOTE\"}" + echo "{\"ok\":true,\"ipv6_upload\":$([ "$TCTL_RL_UPLOAD6_OK" = "1" ] && echo true || echo false),\"msg\":\"rate limit $(rate_text) for $IP (both directions, $MODE)$V6NOTE\"}" fi fi diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-shape.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-shape.sh index ce322117d..78c32c8a1 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-shape.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-shape.sh @@ -24,6 +24,13 @@ IP="$2" RATE="$3" # shellcheck disable=SC2034 LABEL="${4:-shape_$IP}" +# Upload ceiling. Appended as a fifth positional so every existing caller — +# the rpcd backend, the Telegram bot, the DHCP new-device hook and the boot +# restore — keeps working unchanged. Empty means "same as download", which is +# what shaping did before this existed; it is deliberately NOT treated as zero, +# since a zero ceiling is a total upload block that would report success. +RATE_UP="$5" +[ -z "$RATE_UP" ] && RATE_UP="$RATE" # A classid minor is 16 bit and two values are already taken — 1:1 is the root # class and 1:fffe the default one — so a minor cannot be derived from the @@ -41,8 +48,14 @@ re_quote() { echo "$1" | sed 's/[].[^$*\/]/\\&/g' } +# Records are extracted by pattern, not parsed, so this expression decides what +# counts as a record at all. A record it does not match is not merely misread — +# it disappears from used_minors(), alloc_classid hands its minor to another +# device, and the two then share an HTB class: removing one tears down the +# other. The optional rate_kbit_up group is therefore at the TAIL, and every +# reader below tolerates both the old three-field form and the new one. shapes_entries() { - grep -oE '\{"ip":"[^"]+","rate_kbit":[0-9]+(,"classid":"1:[0-9a-f]+")?\}' \ + grep -oE '\{"ip":"[^"]+","rate_kbit":[0-9]+(,"classid":"1:[0-9a-f]+")?(,"rate_kbit_up":[0-9]+)?\}' \ "$SHAPES_FILE" 2>/dev/null } @@ -52,7 +65,19 @@ lookup_classid() { ip="$1" esc=$(re_quote "$ip") shapes_entries \ - | sed -n "s/^{\"ip\":\"$esc\",\"rate_kbit\":[0-9]*,\"classid\":\"\(1:[0-9a-f]*\)\"}$/\1/p" \ + | sed -n "s/^{\"ip\":\"$esc\",\"rate_kbit\":[0-9]*,\"classid\":\"\(1:[0-9a-f]*\)\"\(,\"rate_kbit_up\":[0-9]*\)\{0,1\}}$/\1/p" \ + | head -1 +} + +# Upload ceiling recorded for this address. Empty means the record predates +# asymmetric shaping, or was written symmetric — both mean "same as download", +# which is what every shape did before this existed. +lookup_rate_up() { + local ip esc + ip="$1" + esc=$(re_quote "$ip") + shapes_entries \ + | sed -n "s/^{\"ip\":\"$esc\",\"rate_kbit\":[0-9]*,\"classid\":\"1:[0-9a-f]*\",\"rate_kbit_up\":\([0-9]*\)}$/\1/p" \ | head -1 } @@ -179,7 +204,7 @@ shape_detach() { } save_shape() { - local ip="$1" rate="$2" classid="$3" + local ip="$1" rate="$2" classid="$3" rate_up="$4" mkdir -p "$(dirname "$SHAPES_FILE")" [ ! -f "$SHAPES_FILE" ] && echo "[]" > "$SHAPES_FILE" @@ -209,7 +234,12 @@ save_shape() { local filtered filtered=$(echo "$old_entries" | grep -v "^{\"ip\":\"$esc\"," || true) if [ "$rate" -gt 0 ] 2>/dev/null; then - local entry="{\"ip\":\"$ip\",\"rate_kbit\":$rate,\"classid\":\"$classid\"}" + # rate_kbit_up is written only when the two directions differ, so a + # symmetric shape still produces exactly the record it always did. + # Downgrading the package then keeps working for the common case. + local up_field="" + [ -n "$rate_up" ] && [ "$rate_up" != "$rate" ] && up_field=",\"rate_kbit_up\":$rate_up" + local entry="{\"ip\":\"$ip\",\"rate_kbit\":$rate,\"classid\":\"$classid\"$up_field}" if [ -n "$filtered" ]; then filtered=$(printf "%s\n%s" "$filtered" "$entry") else @@ -258,16 +288,27 @@ do_add() { # Upload: shaped pre-NAT on the IFB device fed from LAN ingress. # Reported separately so a missing kmod-ifb doesn't look like total failure. + # The two directions are already separate HTB classes on separate devices, + # so giving them separate rates costs nothing beyond its own burst. + local ul_burst + ul_burst=$(( RATE_UP * 125 / 100 )) + [ "$ul_burst" -lt 1600 ] && ul_burst=1600 local ul_ok=1 if ensure_ifb; then - shape_attach "$IFB_DEV" src "$IP" "$classid" "$RATE" "$burst_bytes" || ul_ok=0 + shape_attach "$IFB_DEV" src "$IP" "$classid" "$RATE_UP" "$ul_burst" || ul_ok=0 else ul_ok=0 fi - save_shape "$IP" "$RATE" "$classid" + save_shape "$IP" "$RATE" "$classid" "$RATE_UP" + local rates="${RATE} kbit/s" + [ "$RATE_UP" != "$RATE" ] && rates="${RATE} kbit/s down, ${RATE_UP} kbit/s up" if [ "$ul_ok" = "1" ]; then - echo "{\"ok\":true,\"msg\":\"shape ${RATE} kbit/s applied to $IP both directions (class $classid)\"}" + echo "{\"ok\":true,\"msg\":\"shape $rates applied to $IP both directions (class $classid)\"}" + elif [ "$RATE_UP" != "$RATE" ]; then + # Saying "applied" here would be a lie about the half the operator + # explicitly asked to differ. + echo "{\"ok\":true,\"msg\":\"shape ${RATE} kbit/s applied to $IP for download only — the ${RATE_UP} kbit/s upload ceiling was NOT applied, it needs kmod-ifb: opkg/apk install kmod-ifb kmod-sched\"}" else echo "{\"ok\":true,\"msg\":\"shape ${RATE} kbit/s applied to $IP (download only — upload needs kmod-ifb: opkg/apk install kmod-ifb kmod-sched)\"}" fi diff --git a/luci-theme-argon/Makefile b/luci-theme-argon/Makefile index 9e65885ad..13755f39d 100644 --- a/luci-theme-argon/Makefile +++ b/luci-theme-argon/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=Argon Theme LUCI_DEPENDS:=+USE_APK:wget-any +!USE_APK:wget +jsonfilter PKG_VERSION:=2.4.7 -PKG_RELEASE:=21 +PKG_RELEASE:=22 CONFIG_LUCI_CSSTIDY:= diff --git a/luci-theme-argon/htdocs/luci-static/argon/css/dark.css b/luci-theme-argon/htdocs/luci-static/argon/css/dark.css index 5a4d9b3b6..871933e2a 100644 --- a/luci-theme-argon/htdocs/luci-static/argon/css/dark.css +++ b/luci-theme-argon/htdocs/luci-static/argon/css/dark.css @@ -1,5 +1,5 @@ body{background:#1e1e1e;color:#ccc}.spinning::before{filter:invert(1)}.login-page .login-container{background-color:rgba(0,0,0,var(--blur-opacity-dark));-webkit-backdrop-filter:blur(var(--blur-radius-dark));backdrop-filter:blur(var(--blur-radius-dark))}.login-page .login-container .login-form{background-color:transparent;-webkit-backdrop-filter:none;backdrop-filter:none}.login-page .login-container .login-form .brand{color:#adb5bd}.login-page .login-container .login-form .form-login .input-group::before{color:#adb5bd}.login-page .login-container .login-form .form-login .input-group input{background-color:transparent!important;color:#adb5bd;border-bottom:#adb5bd 1px solid!important;border-radius:0!important;border-top:none!important;border-left:none!important;border-right:none!important;box-shadow:none}.login-page .login-container .login-form .form-login .cbi-button-apply{background-color:#483d8b!important;background-color:var(--dark-primary)!important}.login-page .login-container .login-form .form-login .cbi-button-apply:focus,.login-page .login-container .login-form .form-login .cbi-button-apply:hover{opacity:.9}header::after{background-color:#1e1e1e!important}.main .main-left{background-color:#333!important;box-shadow:0 0 .5rem 0 rgba(0,0,0,.15)}.main .main-left .sidenav-header .brand{color:#ccc}.main .main-left .nav .slide .slide-menu .active a{color:#ccc}.main .main-left .nav .slide .slide-menu .active a::after{background-color:#ccc!important}.main .main-left .nav .slide .slide-menu .active::after{background-color:var(--dark-primary)!important}.main .main-left .nav .slide .slide-menu li a{color:#ccc}.main .main-left .nav .slide .slide-menu li a:hover{background:0 0!important}.main .main-left .nav .slide .slide-menu li::after{background-color:var(--dark-primary)!important}.main .main-left .nav .slide .menu.active{background-color:#483d8b!important;background-color:var(--dark-primary)!important;color:#fff!important}.main .main-left .nav .slide .menu.active a::after{background-color:#fff!important}.main .main-left .nav li a{color:#ccc!important}.main .main-left .nav li a:hover{background-color:#483d8b!important;background-color:var(--dark-primary)!important;color:#fff!important}.main .main-left::-webkit-scrollbar-thumb{background-color:#252526!important}.main .main-left::-webkit-scrollbar-track{background-color:#333}.main .main-right{background-color:#1e1e1e}h2{color:#ccc;background:#333}h3{color:#ccc;border-bottom:0;background:#333}h4{color:#8c6900}abbr{color:var(--dark-primary)!important}a:active,a:link,a:visited{color:#a5b2ff}input:-webkit-autofill{background-color:#3c3c3c!important}#channel_graph{background-color:transparent!important}.cbi-button-apply,.cbi-button-edit,.cbi-value-field .cbi-input-apply{color:#fff!important;background-color:#483d8b!important;background-color:var(--dark-primary)!important;border-color:#483d8b!important;border-color:var(--dark-primary)!important}.cbi-section em{color:#ccc}header.bg-primary{background-color:#1e1e1e!important}.cbi-map-descr{color:#ccc}.cbi-section{background:0 0;box-shadow:0 0 .5rem 0 rgba(0,0,0,.35)}.panel-title{color:#ccc;background-color:#333}div>.table>.tr:nth-of-type(2n),div>table>tbody>tr:nth-of-type(2n){background-color:#252526}table>tbody>tr>td,table>tfoot>tr>td,table>thead>tr>td{color:#ccc}fieldset>table>tbody>tr:nth-of-type(2n){background-color:#252526}table>tbody>tr>td,table>tfoot>tr>td,table>thead>tr>td{border-top:1px solid #252526}#conns>div>div,#membuff>div>div,#memfree>div>div,#memtotal>div>div,#swapfree>div>div,#swaptotal>div>div{background-color:var(--dark-primary)!important}#conns>div>div>div>small,#membuff>div>div>div>small,#memfree>div>div>div>small,#memtotal>div>div>div>small,#swapfree>div>div>div>small,#swaptotal>div>div>div>small{color:#ccc!important}.node-system-packages>.main .cbi-section-node:first-child .cbi-value-last{line-height:1.8em}.node-system-packages>.main .cbi-section-node:first-child .cbi-value-last div[style="margin:3px 0; width:300px; height:10px; border:1px solid #000000; background-color:#80C080"]{border:1px solid #999!important;background-color:transparent!important}.node-system-packages>.main .cbi-section-node:first-child .cbi-value-last div[style="margin:3px 0; width:300px; height:10px; border:1px solid #000000; background-color:#80C080"] div{background-color:#ba8b00!important}table>tbody>tr>th,table>tfoot>tr>th,table>thead>tr>th{background-color:#252526;border-bottom:#000 1px solid!important}.tr.cbi-section-table-titles.named::before,.tr[data-title]::before{background-color:#252526}#cbi-wireless>#wifi_assoclist_table>.tr:nth-child(2),.cbi-section-table-row::before,.tr>.td,.tr>.th,tr>td,tr>th{border-top:0}.cbi-rowstyle-2{background-color:#1e1e1e}.cbi-rowstyle-1{background-color:#252526}.cbi-rowstyle-2 .cbi-button-down,.cbi-rowstyle-2 .cbi-button-up,body:not(.Interfaces) .cbi-rowstyle-2:first-child{background-color:#666!important}.cbi-section>h3:first-child,.panel-title,h3{color:#ccc}h4{background-color:#1e1e1f}.cbi-progressbar{background:#282a2c}.cbi-progressbar div{background-color:var(--dark-primary)!important}.cbi-section-table .cbi-section-table-titles .cbi-section-table-cell{background-color:#1e1e1f}.cbi-button{color:#fff!important;background-color:#483d8b;background-color:var(--dark-primary)}.cbi-section-node{background:0 0}abbr{color:#5e72e4}div>.table>.tbody>.tr:nth-of-type(2n),div>table>tbody>tr:nth-of-type(2n){background-color:#252526}#content_syslog{box-shadow:0 0 .5rem 0 rgba(0,0,0,.35)}#syslog{color:#ccc;background-color:#1e1e1e}#bwsvg,#iwsvg,#iwsvg2{overflow:hidden;box-shadow:0 0 .5rem 0 rgba(0,0,0,.35);background-color:#1e1e1e!important}.tabs{background-color:#252526}.tabs li:hover,.tabs li[class~=active]{cursor:pointer;color:#ccc!important}.tabs li:hover a,.tabs li[class~=active] a{color:#ccc!important}.tabs>li[class~=active]>a{color:#ccc}.tabs>li:hover,.tabs>li[class~=active]{border-bottom:.18751rem solid #483d8b;border-bottom:.18751rem solid var(--dark-primary);color:var(--dark-primary)!important;background-color:#181819}.cbi-tabmenu>li>a,.tabs>li>a{color:#ccc}.cbi-tabmenu>li>a:hover,.tabs>li>a:hover{color:#ccc}.cbi-tabmenu>li{background:#2d2d2d}.cbi-tabmenu li[class~=cbi-tab] a{color:#ccc}.cbi-tabmenu>li:hover{color:#ccc;background:#2d2d2d;border-bottom:.18751rem solid var(--dark-primary)!important}.cbi-tabmenu>li[class~=cbi-tab]{background-color:#181819;border-bottom:.18751rem solid var(--dark-primary)!important}.cbi-tabcontainer>.cbi-value:nth-of-type(2n){background-color:#252526}.cbi-value-title{color:#ccc}input,select{color:#ccc;background-color:transparent!important;border:1px solid #252526;box-shadow:none}input:focus,select:not([multiple=multiple]):focus{border-color:#483d8b!important;border-color:var(--dark-primary)!important;outline:0}.cbi-dropdown:focus,input:not(.cbi-button):focus,select:not([multiple=multiple]):focus{border-color:#5e72e4;border-color:var(--dark-primary)!important}select{background-color:#1e1e1e!important}#cbi-customfeedconf .cbi-map-descr,#cbi-customfeedconf h2,#cbi-distfeedconf .cbi-map-descr,#cbi-distfeedconf h2,#cbi-download h2,#cbi-dropbear .cbi-map-descr,#cbi-dropbear .cbi-map-descr abbr,#cbi-dropbear h2,#cbi-filelist h2,#cbi-rc .cbi-map-descr,#cbi-rc h2{color:#ccc!important}.cbi-value-field>ul>li .ifacebadge{background-color:#3c3c3c}.cbi-section-descr{color:#ccc}.cbi-input-textarea{background-color:#1e1e1e;color:#ccc;border:1px solid #3c3c3c!important}textarea:focus-visible{border:1px solid var(--dark-primary)}.cbi-section-node:nth-of-type(2n),.cbi-section-remove:nth-of-type(2n){background-color:#1e1e1e}.cbi-section[id] .cbi-section-node:nth-of-type(4n+4),.cbi-section[id] .cbi-section-remove:nth-of-type(4n+3){background-color:#1e1e1e}.node-system-packages>.main table tr td:last-child{color:#ccc}.cbi-tooltip{background-color:var(--primary);color:#fff}.ifacebox{background-color:transparent!important;border:1px solid #1e1e1e}.ifacebox-head{color:#ccc;background:#666}.ifacebox-body{background-color:#333}.zonebadge strong{color:#333}.zonebadge>.ifacebadge{background-color:#3c3c3c}div.cbi-value var,td.cbi-value-field var{color:#5e72e4}#diag-rc-output>pre{color:#ccc;background-color:#1e1e1e}.node-services-vssr .block{background-color:#3c3c3c!important;box-shadow:0 0 .5rem 0 rgba(0,0,0,.35)}.node-services-vssr .block h4{color:#ccc!important}.node-services-vssr .status-bar{color:#ccc;box-shadow:0 0 .5rem 0 rgba(0,0,0,.35);background-color:#1e1e1e}.node-services-vssr .cbi-section-table-row{color:#ccc;background-color:#3c3c3c!important;box-shadow:0 0 5px 0 rgba(0,0,0,.35)}.node-services-vssr .cbi-section-table-row.fast{background:#483d8b!important;background:var(--dark-primary)!important;color:#fff}.node-services-vssr .ssr-button{color:#ccc}.node-services-vssr .incon:nth-child(2){border-right:#1e1e1e 1px solid}#xhr_poll_status>.label.success{color:#ccc!important;background-color:#556b2f!important}.notice{background-color:#483d8b!important;background-color:var(--dark-primary)!important}.cbi-section.fade-in .cbi-title>div:last-child span{background:0 0!important;color:#ced4da}.cbi-button-add,.cbi-button-find,.cbi-button-reload,.cbi-button-save,.cbi-input-find,.cbi-input-reload,.cbi-input-save{color:#fff!important;background:#556b2f!important;border-color:#556b2f!important}.cbi-button-reset,.cbi-input-remove{color:#fff!important;background-color:#ff8c00!important;border-color:#ff8c00!important}.cbi-button-action.important,.cbi-button-apply.important,.cbi-button-edit.important,.cbi-button-reload.important,.cbi-page-actions .cbi-button-apply,.cbi-section-actions .cbi-button-edit{border:1px #483d8b solid!important;border:1px var(--dark-primary) solid!important}.btn[value=Dismiss],.cbi-button-neutral[value=Disable],.cbi-button[onclick="handleReset(event)"],.cbi-button[onclick^=iface_reconnect],.cbi-button[value=Disabled],.cbi-button[value=Reset],.cbi-button[value=Terminate]{color:#fff;border:thin solid #ff8c00!important;background-color:#ff8c00!important}fieldset[id^=cbi-apply-]{background-color:#333}#detail-bubble>div{border:1px solid #ccc;background:#252525}.ifacebox-head.active{background-color:var(--dark-primary)!important}header .fill .status span[data-style=active]{color:#ccc!important;background-color:#556b2f!important}#cbi-network .tr:first-child>.td,#cbi-wireless .td,.table[width="100%"]>.tr:first-child>.td,.tr.cbi-section-table-titles>.th,.tr.table-titles>.th,[data-page=admin-network-diagnostics] .tr>.td{background-color:#252526;border-bottom:#000 1px solid!important}.network-status-table .ifacebox-body .ifacebadge{background-color:#252526;border-bottom:0;box-shadow:none}.td>.ifacebadge,td>.ifacebadge{color:#fff;background-color:#483d8b;background-color:var(--dark-primary);border:0}.alert,.alert-message{background-color:#333}.alert-message.warning{background-color:#986400!important}.alert-message.error,.alert.error{background-color:#784f00}.alert h4,.alert-message h4{background-color:#784f00;color:#ffbf00}.alert-message [class=btn]{background-color:#777;color:#ccc}.uci-dialog .cbi-section .uci-change-legend .uci-change-legend-label>ins{border-color:#00ad00;background-color:#688668}.uci-dialog .cbi-section .uci-change-legend .uci-change-legend-label>del{border-color:#c60000;background-color:#896565}.uci-dialog .cbi-section .uci-change-legend .uci-change-legend-label>var{background-color:#333;border-color:#666}.uci-dialog .cbi-section .uci-change-legend .uci-change-legend-label>var ins{background-color:#688668}.uci-dialog .cbi-section .uci-change-legend .uci-change-legend-label>var del{background-color:#896565}.uci-dialog .cbi-section .uci-change-list>var{background-color:#333;border-color:#666}.uci-dialog .cbi-section .uci-change-list>var del{background-color:#896565}.uci-dialog .cbi-section .uci-change-list>var ins{background-color:#688668}.uci-dialog .cbi-section .uci-change-list>ins{border-color:#00ad00;background-color:#688668}.uci-dialog .cbi-section .uci-change-list>del{border-color:#c60000;background-color:#896565}.uci-dialog .cbi-section .uci-change-list+.right .btn{color:#ccc}.uci-dialog .cbi-section .uci-change-list+.right .cbi-button{border:1px solid #3c3c3c!important}.btn.danger,.cbi-button-negative,.cbi-button-neutral[value=Restart],.cbi-button-remove,.cbi-button-reset,.cbi-button[onclick="reboot(this)"],.cbi-button[value=Kill],.cbi-button[value=Stop],.cbi-section-remove>.cbi-button{border:thin solid #ff8c00!important;background-color:#ff8c00!important}#cbi-network>.cbi-section-node,#cbi-wireless>#wifi_assoclist_table,#cbi-wireless>.cbi-section-node,#iptables,.Firewall form,.cbi-section,.cbi-section-error,[data-page=admin-system-opkg] #maincontent>.container,[data-page^=admin-system-admin]:not(.node-main-login) .cbi-map:not(#cbi-dropbear),[data-tab-title]{background:#1e1e1e!important;box-shadow:0 0 .5rem 0 rgba(0,0,0,.35)}div[style="width:100%;height:300px;border:1px solid #000;background:#fff"]{background:0 0!important}[data-page=admin-system-admin-password] .cbi-map .cbi-map-descr,[data-page=admin-system-admin-password] .cbi-map h2,[data-page=admin-system-admin] .cbi-map .cbi-map-descr,[data-page=admin-system-admin] .cbi-map h2{color:#ccc}[data-page=admin-system-flash] .modal label>input[type=checkbox]{vertical-align:text-top;top:auto;border-color:#fff!important}[data-page=admin-system-flash] .modal .btn{white-space:normal!important;background-color:#8fbc8f}[data-page=admin-system-flash] .modal .alert-message{background-color:transparent!important}[data-page=admin-system-flash] .modal .danger{border:thin solid #ff8c00!important;background-color:#ff8c00!important}.cbi-value input[type=password]+.cbi-button-neutral{background-color:#483d8b!important;background-color:var(--dark-primary)!important;border-color:var(--dark-primary)!important}.btn,.cbi-dropdown,button,input,select{border:1px solid #3c3c3c!important}.cbi-dropdown .preview{color:#ccc}.cbi-section-table-row{background-color:#1e1e1e!important}.modal{background-color:#1e1e1e}.cbi-button-positive{color:#fff!important;background-color:#556b2f!important}[data-page=admin-system-flash] legend{color:#ccc}.logout:before{color:#adb5bd!important}.cbi-dropdown[open]{border-color:#483d8b!important;border-color:var(--dark-primary)!important}.cbi-dropdown[open]>ul.dropdown{background:#252526!important;color:#fff!important;box-shadow:none;border:1px solid #3c3c3c!important}.cbi-dropdown[open]>ul.dropdown li{color:#fff;border-bottom:1px solid #3c3c3c!important}.cbi-dropdown[open]>ul.dropdown>li[selected]{background-color:#483d8b!important;background-color:var(--dark-primary)!important;border-bottom:1px solid #3c3c3c!important}.cbi-dropdown[open]>ul.dropdown>li.focus{background:#483d8b;background:var(--dark-primary);outline:0}.ifacebadge{background-color:#333}.cbi-dynlist>.item>span{border:1px solid #3c3c3c!important}.cbi-button-action,.cbi-button-apply,.cbi-button-edit,.cbi-button-reload,.cbi-button[onclick="addKey(event)"],.cbi-button[value$=Apply],.cbi-button[value=Submit],.cbi-button[value=Upload],.cbi-page-actions .cbi-button-apply,.cbi-section-actions .cbi-button-edit{background:#483d8b!important;background:var(--dark-primary)!important;border-color:var(--dark-primary)!important}.btn.primary,.cbi-button-add,.cbi-button-link,.cbi-button-neutral[onclick="handleConfig(event)"],.cbi-button-positive,.cbi-button-save,.cbi-button[value=Enable],.cbi-button[value=Scan],.cbi-button[value^=Back],.cbi-page-actions .cbi-button-apply+.cbi-button-save,.cbi-page-actions .cbi-button-save{background:#483d8b;background:var(--dark-primary)}[data-page=admin-system-opkg] h2{color:#ccc!important}[data-page=admin-system-startup] textarea{color:#ccc;background-color:transparent}[data-page=admin-system-startup] textarea:focus-visible{border:1px solid var(--dark-primary)}[data-page=admin-network-firewall-custom] #view p textarea,[data-page=admin-status-routes] #view p textarea,[data-page=admin-system-crontab] #view p textarea{color:#ccc;background-color:transparent}#view>.spinning{background:#333!important;box-shadow:0 4px 8px rgba(0,0,0,.03)!important}@media screen and (min-width:600px){::-webkit-scrollbar-thumb{background:var(--dark-primary)}::-webkit-scrollbar-thumb:hover{background:var(--dark-primary)}::-webkit-scrollbar-thumb:active{background:var(--dark-primary)}}@media screen and (max-width:480px){.node-status-iptables>.main div>.cbi-map>form{background-color:#1e1e1e;box-shadow:0 0 .5rem 0 rgba(0,0,0,.35)}}[data-page=""] .main-right>#maincontent .Dashboard,[data-page=admin-status-dashboard] .main-right>#maincontent .Dashboard{color:#ccc!important}[data-page=""] .main-right>#maincontent .Dashboard h3,[data-page=admin-status-dashboard] .main-right>#maincontent .Dashboard h3{color:#ccc}[data-page=""] .main-right>#maincontent .Dashboard hr,[data-page=admin-status-dashboard] .main-right>#maincontent .Dashboard hr{border-top:1px solid #fff}[data-page=""] .main-right>#maincontent .Dashboard .dashboard-bg,[data-page=admin-status-dashboard] .main-right>#maincontent .Dashboard .dashboard-bg{background-color:#333}[data-page=""] .main-right>#maincontent .Dashboard img.svgmonochrome,[data-page=admin-status-dashboard] .main-right>#maincontent .Dashboard img.svgmonochrome{filter:invert(90%)}[data-page=""] .main-right>#maincontent .Dashboard tr,[data-page=admin-status-dashboard] .main-right>#maincontent .Dashboard tr{border-top:thin solid #4d4d4d}[data-page=""] .main-right>#maincontent .Dashboard tr:last-child,[data-page=admin-status-dashboard] .main-right>#maincontent .Dashboard tr:last-child{border-bottom:thin solid #4d4d4d}[data-page=admin-system-fileassistant] .fb-container .panel-container{border-bottom-color:#3c3c3c!important}[data-page=admin-system-fileassistant] .fb-container td[class$="-icon"]::before{filter:invert(.7)}[data-page=admin-system-fileassistant] div#list-content table.cbi-section-table tbody tr:first-child td.parent-icon{background:#1e1e1e!important}[data-page^=admin-services-openclash]{--oc-accent:var(--dark-primary);--oc-surface:#1e1e1e;--oc-surface-muted:#252526;--oc-control-bg:#1e1e1e;--oc-border:#3c3c3c;--oc-text:#ccc;--oc-text-muted:#a1a1aa;--oc-neutral:#5b5b5b;--oc-scrollbar:#5b5b5b}[data-page^=admin-services-openclash] img[src$="arrow-clockwise-light.svg"],[data-page^=admin-services-openclash] img[src$="eye-light.svg"],[data-page^=admin-services-openclash] img[src$="wrench-light.svg"]{filter:invert(.9)}[data-page=admin-network-diagnostics] .cbi-section{background:#252526!important}[data-page=admin-network-diagnostics] textarea{background:0 0;border-radius:.25rem;color:#ccc;border:1px solid #3c3c3c!important}[data-page=admin-network-diagnostics] .tr>.td{background-color:#252526!important}[data-page=admin-network-network] .ifacebox-head[style*="--zone-color-rgb: 144, 240, 144"]{background-color:#497e49!important;color:#ccc!important}[data-page=admin-network-network] .ifacebox-head[style*="--zone-color-rgb: 240, 144, 144;"]{background-color:#9c4f4f!important;color:#ccc!important}[data-page=admin-network-network] .ifacebox-head[style*="--zone-color-rgb: 238, 238, 238;"]{background-color:#666!important}[data-page=admin-network-network] #modal_overlay>.modal.cbi-modal>div>p>textarea{border:1px solid #3c3c3c!important;background-color:transparent!important}[data-page=admin-network-network] #modal_overlay div[data-tab=peers] div[data-name="_peers"] .ifacebadge code{color:#fff!important;background-color:transparent!important}[data-page=admin-network-network] #modal_overlay>.modal.cbi-modal>div>p>textarea{color:#ccc!important}[data-page=admin-network-firewall-rules] #cbi-firewall-rule .zonebadge[style*="--zone-color-rgb:240, 144, 144;"]{--zone-color-rgb:156,79,79!important}[data-page=admin-network-firewall-rules] #cbi-firewall-rule .zonebadge[style*="--zone-color-rgb:240, 144, 144;"] strong{color:#ccc!important}[data-page=admin-network-firewall-rules] #cbi-firewall-rule .zonebadge[style*="--zone-color-rgb:238, 238, 238;"]{--zone-color-rgb:112,112,112!important}[data-page=admin-network-firewall-rules] #cbi-firewall-rule .zonebadge[style*="--zone-color-rgb:238, 238, 238;"] em{color:#ccc!important}[data-page=admin-network-firewall-rules] #cbi-firewall-rule>table>.cbi-section-table-row[data-title]::before{background-color:#252526!important}[data-page=admin-network-firewall-rules] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:240, 144, 144;"],[data-page=admin-network-network] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:240, 144, 144;"]{--zone-color-rgb:156,79,79!important;color:#ccc!important}[data-page=admin-network-firewall-rules] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:240, 144, 144;"] strong,[data-page=admin-network-network] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:240, 144, 144;"] strong{color:#ccc!important}[data-page=admin-network-firewall-rules] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:144, 240, 144;"],[data-page=admin-network-network] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:144, 240, 144;"]{--zone-color-rgb:73,126,73!important;color:#ccc!important}[data-page=admin-network-firewall-rules] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:144, 240, 144;"] strong,[data-page=admin-network-network] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:144, 240, 144;"] strong{color:#ccc!important}[data-page=admin-network-firewall-rules] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:238, 238, 238;"],[data-page=admin-network-network] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:238, 238, 238;"]{--zone-color-rgb:112,112,112!important}[data-page=admin-network-firewall-rules] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:238, 238, 238;"] strong,[data-page=admin-network-network] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:238, 238, 238;"] strong{color:#ccc!important}[data-page=admin-network-firewall-rules] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:238, 238, 238;"] strong+span,[data-page=admin-network-network] .cbi-dropdown .zonebadge[style*="--zone-color-rgb:238, 238, 238;"] strong+span{color:#ccc!important}[data-page=admin-system-commands-dashboard] .commandbox,[data-page=admin-system-commands] .commandbox{border-bottom:thin solid #333;background:#252526;box-shadow:inset 0 1px 0 rgba(0,0,0,.2),0 1px 2px rgba(255,255,255,.05)}.btn{background-color:#707070;color:#fff}[data-page=admin-system-admin-repokeys] #key-input{background-color:#1e1e1e;color:#ccc;border:1px solid #3c3c3c!important}[data-page=admin-system-package-manager] #modal_overlay textarea{background-color:#1e1e1e;color:#ccc;border:1px solid #3c3c3c!important}.cbi-dropdown[open]>ul.dropdown>li[selected]{color:#fff} .modal>pre,.modal>textarea{color: #ccc} .login-page.defwallpaper .login-container{background-color: rgb(136 136 136 / 14%);backdrop-filter: blur(0);margin:0 auto;} -.defwallpaper.defwallpaper .main-bg img{object-position: -99999px -99999px;} +.defwallpaper.defwallpaper .main-bg img{object-position: -99999px -99999px;opacity: 0;} .main .main-right{background-color: transparent} diff --git a/meow/files/arp-hijack.sh b/meow/files/arp-hijack.sh index 85d8d9486..d842e5ee6 100644 --- a/meow/files/arp-hijack.sh +++ b/meow/files/arp-hijack.sh @@ -35,6 +35,7 @@ load_config() { [ -n "$gateway" ] || network_get_gateway gateway "$interface" } +# JSON array of the LAN neighbour table, marking which MACs are selected. cmd_clients() { load_config local sel @@ -52,6 +53,7 @@ cmd_clients() { printf ']\n' } +# Current IP for a selected MAC (resolved fresh each sweep; DHCP may move it). mac_to_ip() { ip neigh show dev "$device" 2>/dev/null | awk -v m="$(echo "$1" | tr 'A-F' 'a-f')" ' { mac=""; for (i=1;i<=NF;i++) if ($i=="lladdr") mac=tolower($(i+1)) @@ -63,6 +65,7 @@ sweep() { for mac in $clients; do ip=$(mac_to_ip "$mac") [ -n "$ip" ] || continue + # ARP reply: "gateway is at ", unicast to the client. if ! /usr/bin/meow arp-reply "$device" "$gateway" "$mac" "$ip"; then logger -t meow-arp "failed to send unicast reply to $mac ($ip)" return 1 diff --git a/meow/files/config.yaml b/meow/files/config.yaml index 881d6f8c3..8f683e1d7 100644 --- a/meow/files/config.yaml +++ b/meow/files/config.yaml @@ -2,6 +2,7 @@ # Edit this file, then restart the service: /etc/init.d/meow restart # Full reference: https://github.com/madeye/meow-rs/blob/main/config.example.yaml +# Combined HTTP + SOCKS5 proxy for LAN clients. mixed-port: 7890 allow-lan: true bind-address: "0.0.0.0" @@ -10,11 +11,27 @@ mode: rule log-level: info ipv6: false +# REST API + built-in web panel (served at /ui). The init script overrides +# the address with `--ext-ctl 0.0.0.0:` and, when set, the secret +# with `--secret ` from /etc/config/meow, so LuCI always knows how to +# reach it. OpenWrt's default firewall still blocks WAN-side access. external-controller: 0.0.0.0:9090 secret: "" +# SO_MARK for DIRECT sockets (loop avoidance on Linux). routing-mark: 9527 +# Transparent-proxy listener for forwarded LAN traffic (gateway / side-router +# mode, enabled in LuCI or via `uci set meow.tproxy.enabled=1`). Must bind a +# non-loopback IPv4 address; the port must match `tproxy_port` in +# /etc/config/meow. +# +# `firewall: false` leaves all rule management to gateway.sh (this package's +# nftables rules) — meow makes no nft calls itself. `udp: true` enables UDP +# TPROXY, which requires `firewall: false`, an IPv4 listen, and the +# deployer-owned fwmark + policy routing that gateway.sh's `tproxy` mode sets +# up. In `redirect` mode gateway.sh uses nat REDIRECT and only TCP is proxied; +# the same listener accepts it (UDP is simply never delivered). listeners: - name: tproxy-lan type: tproxy @@ -22,8 +39,12 @@ listeners: port: 7893 udp: true firewall: false + # A gateway serves many concurrent flows from every LAN client; the 256 + # default saturates under a few busy devices and stalls new connections. + # 0 = unlimited. max-connections: 4096 +# Recover hostnames for connections made to raw IPs. sniffer: enable: true override-destination: false @@ -33,6 +54,8 @@ sniffer: dns: enable: true + # dnsmasq owns :53; meow's resolver listens on 1053. LAN DNS is redirected + # here when transparent proxy + DNS hijack are enabled (`dns_port`). listen: 0.0.0.0:1053 enhanced-mode: fake-ip fake-ip-range: 198.18.0.0/16 @@ -40,5 +63,20 @@ dns: - 223.5.5.5 - 8.8.8.8 +# Add your proxies and groups here, e.g.: +# proxies: +# - name: "ss-example" +# type: ss +# server: 1.2.3.4 +# port: 8388 +# cipher: aes-256-gcm +# password: "your-password" +# udp: true +# +# proxy-groups: +# - name: "PROXY" +# type: select +# proxies: ["ss-example", "DIRECT"] + rules: - MATCH,DIRECT diff --git a/meow/files/gateway.sh b/meow/files/gateway.sh old mode 100644 new mode 100755 index b72b0bcbb..7348e2682 --- a/meow/files/gateway.sh +++ b/meow/files/gateway.sh @@ -1,11 +1,32 @@ #!/bin/sh -# Transparent-proxy rules for traffic forwarded through this router. +# Transparent-proxy rules for traffic forwarded through this router +# (gateway or side-router mode). meow's built-in `inet meow_tproxy` table only +# covers the router's own output traffic; this adds `inet meow_gateway`, which +# hooks prerouting on the LAN device. See docs/tproxy-gateway.md. +# +# Modes (`option mode`): +# tproxy REDIRECT for TCP + kernel TPROXY for UDP (default). meow's TCP +# listener recovers the original destination via SO_ORIGINAL_DST, +# which only nat REDIRECT populates, so TCP is never sent through +# kernel TPROXY; UDP has a real IP_TRANSPARENT listener. +# redirect nat REDIRECT, TCP only +# +# Usage: gateway.sh up|down|wait|status +# Settings come from the `tproxy` section of /etc/config/meow. . /lib/functions.sh . /lib/functions/network.sh TABLE="inet meow_gateway" RULES=/var/run/meow/gateway.nft +# Two marks, deliberately different (see docs/tproxy-gateway.md): +# FWMARK tags the UDP datagrams the mangle chain hands to the TPROXY +# listener; a policy route delivers those marked packets +# locally. TCP never uses it. +# ROUTING_MARK meow's own outbound sockets carry this (SO_MARK, from the +# YAML `routing-mark`), so both capture chains RETURN on it and +# meow's own traffic is never re-proxied into a loop. This MUST +# equal `routing-mark` in the meow YAML config (default 9527). FWMARK=0x2333 ROUTING_MARK=0x2537 ROUTE_TABLE=233 @@ -24,7 +45,8 @@ load_config() { } validate_config() { - case "$mode:$ipv6" in redirect:0|redirect:1|tproxy:0) ;; *) return 1 ;; esac + case "$mode:$ipv6" in redirect:0|redirect:1|tproxy:0) ;; *) + logger -t meow 'gateway: invalid mode/IPv6 combination; IPv6 requires redirect mode'; return 1 ;; esac for port in "$tproxy_port" "$dns_port"; do case "$port" in ''|*[!0-9]*) return 1 ;; esac [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || return 1 @@ -33,9 +55,14 @@ validate_config() { for cidr in $bypass; do case "$cidr" in *[!0-9a-fA-F.:/]*|'') return 1 ;; esac done + local h='[0-9a-f][0-9a-f]' + for mac in $bypass_mac; do + case "$mac" in $h:$h:$h:$h:$h:$h) ;; *) return 1 ;; esac + done } port_listening() { + # /proc/net/tcp{,6} list local ports in hex; state 0A is LISTEN. local hex hex=$(printf '%04X' "$1") grep -qE "^ *[0-9]+: [0-9A-F]+:$hex [0-9A-F]+:[0-9A-F]+ 0A" \ @@ -44,6 +71,7 @@ port_listening() { gen_rules() { local device="$1" extra4="" extra6="" macs="" cidr mac + for cidr in $bypass; do case "$cidr" in *:*) extra6="$extra6, $cidr" ;; @@ -54,6 +82,12 @@ gen_rules() { macs="${macs:+$macs, }$mac" done + # TCP goes through nat REDIRECT in BOTH modes (dstnat chain): meow's TCP + # listener recovers the original destination with SO_ORIGINAL_DST, which + # needs the conntrack DNAT that REDIRECT creates. Kernel TPROXY leaves no + # conntrack entry, so a TPROXY'd TCP flow resolves to the listener's own + # address and meow dials itself — a saturating loop. UDP has no REDIRECT + # equivalent and a real IP_TRANSPARENT listener, so it uses TPROXY. cat <<-NFT table inet meow_gateway { set reserved4 { @@ -68,9 +102,10 @@ gen_rules() { type ipv6_addr; flags interval; auto-merge elements = { ::/128, ::1/128, fc00::/7, fe80::/10, ff00::/8$extra6 } } + # LAN clients (by MAC) that skip both capture and the DNS hijack. set bypass_src { - type ether_addr; - NFT + type ether_addr + NFT [ -n "$macs" ] && echo " elements = { $macs }" cat <<-NFT } @@ -81,7 +116,8 @@ gen_rules() { meta mark $ROUTING_MARK return ether saddr @bypass_src return NFT - [ "$dns_hijack" -eq 1 ] && echo " meta nfproto ipv4 meta l4proto udp th dport 53 redirect to :$dns_port" + [ "$dns_hijack" -eq 1 ] && + echo " meta nfproto ipv4 meta l4proto udp th dport 53 redirect to :$dns_port" [ "$ipv6" -eq 1 ] || echo " meta nfproto ipv6 return" cat <<-NFT fib daddr type local return @@ -91,6 +127,7 @@ gen_rules() { } NFT + # redirect mode stops at TCP; tproxy mode adds the UDP TPROXY chain. if [ "$mode" != redirect ]; then cat <<-NFT @@ -111,6 +148,7 @@ gen_rules() { } NFT fi + echo "}" } @@ -145,18 +183,27 @@ case "$1" in load_config validate_config || { logger -t meow "gateway: invalid configuration"; exit 1; } network_get_device device "$interface" - [ -n "$device" ] || exit 1 + [ -n "$device" ] || { + logger -t meow "gateway: no device for interface '$interface'" + exit 1 + } case "$device" in *[!a-zA-Z0-9_.:-]*) exit 1 ;; esac mkdir -p "${RULES%/*}" gen_rules "$device" > "$RULES" - nft -c -f "$RULES" || exit 1 + if ! nft -c -f "$RULES"; then + logger -t meow 'gateway: invalid rules or missing kernel support; install kmod-nft-socket kmod-nft-tproxy (opkg install, or apk add on newer OpenWrt)' + exit 1 + fi nft delete table $TABLE 2>/dev/null route_down - nft -f "$RULES" || exit 1 + if ! nft -f "$RULES"; then + logger -t meow "gateway: failed to load $RULES" + exit 1 + fi if [ "$mode" = redirect ]; then logger -t meow "gateway: redirect $device tcp -> :$tproxy_port" else - route_up || { "$0" down; exit 1; } + route_up || { "$0" down; logger -t meow "gateway: policy routing failed"; exit 1; } logger -t meow "gateway: tproxy $device tcp(redirect)+udp -> :$tproxy_port" fi ;; @@ -165,6 +212,8 @@ case "$1" in route_down ;; wait) + # Block until meow binds the tproxy listener (30s cap, then load + # anyway so the rules fail closed rather than leaking traffic). load_config i=0 while [ $i -lt 150 ]; do diff --git a/meow/files/meow-arp.init b/meow/files/meow-arp.init index a99932305..83a32a61a 100644 --- a/meow/files/meow-arp.init +++ b/meow/files/meow-arp.init @@ -1,4 +1,9 @@ #!/bin/sh /etc/rc.common +# procd init for meow's ARP client-steering loop (arp-hijack.sh). Self-gates on +# the `arp_hijack` section of /etc/config/meow: does nothing unless enabled and +# at least the feature is turned on. Separate from the meow service so steering +# can be toggled without restarting the proxy. + USE_PROCD=1 START=96 STOP=9 diff --git a/meow/files/meow.config b/meow/files/meow.config index a84a2cc99..84d9c49e8 100644 --- a/meow/files/meow.config +++ b/meow/files/meow.config @@ -1,21 +1,56 @@ config meow 'main' + # Set to '1' to start meow at boot and via /etc/init.d/meow start. option enabled '0' + # meow YAML configuration (proxies, rules, DNS, listeners, ...). option config_file '/etc/meow/config.yaml' + # Working directory for GeoIP databases, caches, downloaded rulesets. option work_dir '/etc/meow' + # Port of the REST API / built-in web panel. Passed to meow as + # `--ext-ctl 0.0.0.0:`, overriding `external-controller` in the YAML. option panel_port '9090' + # A random API secret is generated on install. Empty binds to loopback only. + # When non-empty it is passed as `--secret`, overriding + # `secret:` in the YAML; LuCI uses it to talk to the API and the panel. option secret '' +# Transparent proxy for forwarded LAN traffic (gateway / side-router mode). +# Loads an nftables table `inet meow_gateway` that hands traffic arriving on +# the LAN interface to meow's tproxy listener and hijacks DNS to meow's +# resolver. The YAML config must declare a matching non-loopback tproxy +# listener and DNS listener (the shipped default config does). config transparent 'tproxy' option enabled '0' + # 'tproxy': REDIRECT for TCP + kernel TPROXY for UDP. 'redirect': TCP only. option mode 'tproxy' + # Logical OpenWrt interface whose clients are proxied. option interface 'lan' + # Port of the `type: tproxy` listener in the YAML config. option tproxy_port '7893' + # Redirect LAN DNS (port 53, any resolver) to meow's DNS listener. option dns_hijack '1' + # Port of meow's DNS listener (`dns.listen` in the YAML config). option dns_port '1053' + # Also proxy IPv6. Off by default: fake-ip suppresses AAAA. option ipv6 '0' + # Extra destination CIDRs that bypass the proxy (besides reserved ranges). + # list bypass '203.0.113.0/24' + # LAN client MACs whose traffic bypasses the proxy entirely (no capture, no + # DNS hijack). Managed from LuCI (Services -> meow -> Clients). + # list bypass_mac 'aa:bb:cc:dd:ee:ff' +# ARP-based client steering (side-router / gateway mode). For SELECTED clients, +# periodically announce this device as the LAN gateway so their traffic is +# transparently proxied without per-client config. This is ARP spoofing: use it +# only for devices you administer on a network you control. OFF by default; only +# the MACs listed here are ever touched. Managed from LuCI (Services -> meow -> +# Clients). Uses meow's built-in unicast ARP sender. config arp_hijack 'arp' option enabled '0' + # LAN interface whose clients are steered. option interface 'lan' + # Gateway IP to impersonate. Empty = this interface's real gateway. option gateway '' + # Seconds between ARP announcements (caches expire, so we re-announce). option interval '2' + # Selected client MACs (one per line). Managed from the Clients tab. + # list client 'aa:bb:cc:dd:ee:ff' diff --git a/meow/files/meow.init b/meow/files/meow.init old mode 100644 new mode 100755 index e45ddf86e..9fbe96258 --- a/meow/files/meow.init +++ b/meow/files/meow.init @@ -1,4 +1,8 @@ #!/bin/sh /etc/rc.common +# procd init script for meow-rs. +# Service settings live in /etc/config/meow (UCI); the proxy configuration +# itself is the regular meow YAML file (default /etc/meow/config.yaml). + USE_PROCD=1 START=95 STOP=10 @@ -18,7 +22,10 @@ start_service() { config_get_bool tproxy tproxy enabled 0 [ "$enabled" -eq 1 ] || return 0 - [ "$config_file" = /etc/meow/config.yaml ] && [ "$work_dir" = /etc/meow ] || return 1 + [ "$config_file" = /etc/meow/config.yaml ] && [ "$work_dir" = /etc/meow ] || { + logger -t meow 'managed service requires /etc/meow/config.yaml and work_dir /etc/meow' + return 1 + } if ! "$PROG" --no-external-plugins -d "$work_dir" -f "$config_file" -t >/dev/null 2>&1; then logger -t meow "config test failed for $config_file, not starting" @@ -31,6 +38,7 @@ start_service() { procd_set_param command "$PROG" --no-external-plugins -d "$work_dir" -f "$config_file" \ --ext-ctl "$controller" [ -n "$secret" ] && procd_append_param command --secret "$secret" + # Restart when either the UCI service settings or the YAML config change. procd_set_param file /etc/config/meow "$config_file" procd_set_param respawn 3600 5 5 procd_set_param stdout 1 @@ -39,12 +47,13 @@ start_service() { procd_close_instance if [ "$tproxy" -eq 1 ]; then - procd_open_instance gateway - procd_set_param command "$GATEWAY" run - procd_set_param term_timeout 5 - procd_set_param stdout 1 - procd_set_param stderr 1 - procd_close_instance + # A supervised instance owns the wait and rules, including teardown. + procd_open_instance gateway + procd_set_param command "$GATEWAY" run + procd_set_param term_timeout 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance else "$GATEWAY" down fi diff --git a/meow/files/meow.uci-defaults b/meow/files/meow.uci-defaults index 1b36fda83..e8eb6c50d 100644 --- a/meow/files/meow.uci-defaults +++ b/meow/files/meow.uci-defaults @@ -1,4 +1,6 @@ #!/bin/sh +# Add sections introduced after the first release to preserved configs. +# Preserve existing credentials on upgrades; initialize an empty install. if [ -z "$(uci -q get meow.main.secret)" ]; then secret=$(head -c 32 /dev/urandom | hexdump -v -e '1/1 "%02x"') [ "${#secret}" -eq 64 ] || exit 1 diff --git a/meow/prebuilt-meow b/meow/prebuilt-meow index c92e3058d..ee702ea5a 100755 Binary files a/meow/prebuilt-meow and b/meow/prebuilt-meow differ diff --git a/openwrt-mwgp/Makefile b/openwrt-mwgp/Makefile index 05f645ac2..144f0d7b0 100644 --- a/openwrt-mwgp/Makefile +++ b/openwrt-mwgp/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=mwgp PKG_VERSION:=2.1.7 -PKG_RELEASE:=3 +PKG_RELEASE:=4 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/apernet/mwgp @@ -10,7 +10,7 @@ PKG_SOURCE_VERSION:=bd3cbaa3b81f0b081c425de9ac15a54a6da5ef9a PKG_MIRROR_HASH:=skip PKG_MAINTAINER:=Apernet -PKG_LICENSE:=GPL-3.0 +PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE PKG_BUILD_DEPENDS:=golang/host @@ -18,61 +18,45 @@ PKG_BUILD_PARALLEL:=1 PKG_USE_MIPS16:=0 include $(INCLUDE_DIR)/package.mk +# Absolute environment lookup reference +include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk define Package/mwgp - SECTION:=net - CATEGORY:=Network - SUBMENU:=VPN - TITLE:=Multiple WireGuard Proxy - URL:=https://github.com - DEPENDS:=+ca-bundle + SECTION:=net + CATEGORY:=Network + SUBMENU:=VPN + TITLE:=Multiple WireGuard Proxy + URL:=https://github.com + DEPENDS:=$(GO_ARCH_DEPENDS) +ca-bundle endef define Package/mwgp/description - mwgp is a proxy software for WireGuard traffic that supports port - multiplexing and experimental traffic obfuscation. + mwgp is a proxy software for WireGuard traffic that supports port + multiplexing and experimental traffic obfuscation. endef -# --- UNIVERSAL ARCHITECTURE TRANSLATION --- -# Go uses 'arm64' instead of 'aarch64'. This block detects aarch64 -# and safely translates it, otherwise it passes your SDK's $(ARCH) right to Go. -ifeq ($(ARCH),aarch64) - TARGET_GOARCH:=arm64 -else - TARGET_GOARCH:=$(ARCH) -endif - -# Size Minimization Magic (-s and -w shrink the router binary size) -GO_LDFLAGS:=-s -w +# Setup build targets pointing to the sub-folder module logic +GO_PKG:=://github.com +GO_PKG_BUILD_PKG:=$(GO_PKG)/cmd/mwgp +GO_PKG_LDFLAGS:=-s -w +# FIXED: Explicitly tell OpenWrt to compile this using the Go pipeline macro +# instead of falling back to running a standard Makefile 'make' loop. define Build/Compile - # 1. Clear out any old target files - rm -f $(PKG_BUILD_DIR)/mwgp - - # 2. Compile using the SDK's architecture target variables dynamically - cd $(PKG_BUILD_DIR); \ - $(TARGET_CONFIGURE_OPTS) \ - GOOS=linux \ - GOARCH=$(TARGET_GOARCH) \ - CGO_ENABLED=0 \ - GO111MODULE=on \ - /usr/bin/go build \ - -v \ - -ldflags="$(GO_LDFLAGS)" \ - -o $(PKG_BUILD_DIR)/mwgp \ - ./cmd/mwgp -endef + $(call GoPackage/Golang/Build/Compile) +enddefine define Package/mwgp/install $(INSTALL_DIR) $(1)/usr/bin - # Safely copy the explicitly generated router binary - $(INSTALL_BIN) $(PKG_BUILD_DIR)/mwgp $(1)/usr/bin/mwgp + # Safely capture the compiled Go binary from the architecture build directory + $(INSTALL_BIN) $(GO_PKG_BUILD_BIN_DIR)/mwgp $(1)/usr/bin/mwgp - $(INSTALL_DIR) $(1)/etc/init.d/ + $(INSTALL_DIR) $(1)/etc/init.d $(INSTALL_BIN) ./files/mwgp.init $(1)/etc/init.d/mwgp - $(INSTALL_DIR) $(1)/etc/config/ + $(INSTALL_DIR) $(1)/etc/config $(INSTALL_DATA) ./files/mwgp.config $(1)/etc/config/mwgp endef +$(eval $(call GoBinPackage,mwgp)) $(eval $(call BuildPackage,mwgp))