diff --git a/dnsproxy/Makefile b/dnsproxy/Makefile index de692d70a..c7b620792 100644 --- a/dnsproxy/Makefile +++ b/dnsproxy/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=dnsproxy -PKG_VERSION:=0.85.0 -PKG_RELEASE:=19 +PKG_VERSION:=0.86.0 +PKG_RELEASE:=20 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/AdguardTeam/dnsproxy/tar.gz/v$(PKG_VERSION)? diff --git a/ghttpd/Makefile b/ghttpd/Makefile index 8fb26c119..1a857939e 100644 --- a/ghttpd/Makefile +++ b/ghttpd/Makefile @@ -10,8 +10,8 @@ include $(TOPDIR)/rules.mk PKG_ARCH_ghttpd:=$(ARCH) PKG_NAME:=ghttpd -PKG_VERSION:=0.0.3 -PKG_RELEASE:=5 +PKG_VERSION:=0.0.5 +PKG_RELEASE:=6 PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://github.com/Carseason/openwrt-packages/releases/download/prebuilt/ PKG_HASH:=skip @@ -53,7 +53,7 @@ define Package/$(PKG_NAME)/postrm #!/bin/sh for service in uhttpd nginx; do [ -x "/etc/init.d/$$service" ] || continue - "/etc/init.d/$$service" enabled && "/etc/init.d/$$service" restart + "/etc/init.d/$$service" restart done exit 0 endef diff --git a/ghttpd/files/ghttpd.init b/ghttpd/files/ghttpd.init index f7e375b67..b7da1c766 100755 --- a/ghttpd/files/ghttpd.init +++ b/ghttpd/files/ghttpd.init @@ -19,7 +19,7 @@ stop_default_httpd_services() { start_default_httpd_services() { for service in uhttpd nginx; do [ -x "/etc/init.d/$service" ] || continue - "/etc/init.d/$service" enabled && "/etc/init.d/$service" restart + "/etc/init.d/$service" restart done } diff --git a/golang/golang-version.mk b/golang/golang-version.mk index 6bcdd2c96..bafbbefcc 100644 --- a/golang/golang-version.mk +++ b/golang/golang-version.mk @@ -33,6 +33,7 @@ define Package/$(PKG_NAME)/Default TITLE:=Go programming language URL:=https://go.dev/ DEPENDS:=$(GO_ARCH_DEPENDS) + $(if $(PKG_HOST_ONLY),BUILDONLY:=1) endef define Package/$(PKG_NAME)/Default/description diff --git a/golang/golang1.27/Makefile b/golang/golang1.27/Makefile index a182a277c..2875c3f23 100644 --- a/golang/golang1.27/Makefile +++ b/golang/golang1.27/Makefile @@ -8,10 +8,10 @@ include $(TOPDIR)/rules.mk PKG_NAME:=golang1.27 GO_VERSION_MAJOR_MINOR:=1.27 -GO_VERSION_PATCH:=0 +GO_VERSION_PATCH:=1 GO_VERSION_RC:= GO_BOOTSTRAP_VERSION:=bootstrap -PKG_HASH:=7002403d7cc44529ef6d26f69a44818263395ead7c16c05a5808ae047ebeb0e5 +PKG_HASH:=4e408abae126d916b6164627193f2c54f0e3ca1312d693b86db45f862ab238b1 PKG_VERSION:=$(GO_VERSION_MAJOR_MINOR)$(if $(GO_VERSION_RC),.0)$(if $(GO_VERSION_PATCH),.$(GO_VERSION_PATCH)) PKG_FILE_VERSION:=$(GO_VERSION_MAJOR_MINOR)$(if $(GO_VERSION_RC),rc$(GO_VERSION_RC))$(if $(GO_VERSION_PATCH),.$(GO_VERSION_PATCH)) diff --git a/libdvbcsa/Makefile b/libdvbcsa/Makefile index 91a8057fa..84e8d394a 100644 --- a/libdvbcsa/Makefile +++ b/libdvbcsa/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=libdvbcsa -PKG_RELEASE:=1 +PKG_RELEASE:=2 PKG_SOURCE_VERSION:=bc6c0b164a87ce05e9925785cc6fb3f54c02b026 PKG_HASH:=skip @@ -31,7 +31,6 @@ define Package/libdvbcsa CATEGORY:=Libraries TITLE:=DVB Common Scrambling Algorithm Library URL:=https://www.videolan.org/developers/libdvbcsa.html - DEPENDS:=@BUILD_PATENTED endef define Package/libdvbcsa/description diff --git a/luci-app-adguardhome/.official_channel b/luci-app-adguardhome/.official_channel index 49f33a8c6..8ab67f2e7 100644 --- a/luci-app-adguardhome/.official_channel +++ b/luci-app-adguardhome/.official_channel @@ -1 +1 @@ -both +snapshot diff --git a/luci-app-adguardhome/.official_version_snapshot b/luci-app-adguardhome/.official_version_snapshot index 790d04676..333602713 100644 --- a/luci-app-adguardhome/.official_version_snapshot +++ b/luci-app-adguardhome/.official_version_snapshot @@ -1 +1 @@ -26.273.63965~32775cf +26.274.67354~aa3d488 diff --git a/luci-app-fwlive/Makefile b/luci-app-fwlive/Makefile index 63c2ce8a4..51b89fe58 100644 --- a/luci-app-fwlive/Makefile +++ b/luci-app-fwlive/Makefile @@ -11,8 +11,8 @@ LUCI_DESCRIPTION:=Live firewall log table (firewall4/nft; iptables-tagged lines LUCI_DEPENDS:=+luci-base +logd +jsonfilter LUCI_PKGARCH:=all -PKG_VERSION:=0.1.48 -PKG_RELEASE:=54 +PKG_VERSION:=0.1.49 +PKG_RELEASE:=55 # 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 c8059f7fc..e723d6fbc 100644 --- a/luci-app-fwlive/README.md +++ b/luci-app-fwlive/README.md @@ -11,6 +11,7 @@ LuCI **Firewall Live View** — client-side JS view polling `ubus fwlive poll` ( | `htdocs/luci-static/resources/fwlive/log.js` | Parser/filter module (mirror of repo `core/fwlive-log.js`) | | `htdocs/luci-static/resources/fwlive/proto.js` | Protocol filter pair (select + custom field; typed custom wins) | | `htdocs/luci-static/resources/fwlive/constants.js` | Shared view constants (`baseclass.extend` module) | +| `htdocs/luci-static/resources/fwlive/fwlive.css` | Stylesheet source; run `node scripts/embed-fwlive-css.js` to regenerate `css.js` | | `htdocs/luci-static/resources/fwlive/css.js` | Inline stylesheet string (`styleText` for `E('style', …)`) | | `htdocs/luci-static/resources/fwlive/tint.js` | Row-tint paint helpers (`baseclass.extend` module) | | `htdocs/luci-static/resources/fwlive/links.js` | Link-builder helpers (pure + filter-aware; no host) | 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 20526dd9a..f771e9f8b 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.48', + APP_VERSION: '0.1.49', 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/fwlive/log.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js index ccf909f49..3068bd6a5 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js @@ -61,6 +61,65 @@ const CLASSIFY_SPEC = { ] }; +/* Validate the trusted module configuration once, before constructing regexes. */ +validateClassifySpec(CLASSIFY_SPEC); + +function validateClassifySpec(spec) { + const validateNode = function (node) { + if (!node || typeof node !== 'object' || Array.isArray(node)) + throw new Error('CLASSIFY_SPEC node must be an object'); + const keys = Object.keys(node); + if (keys.length !== 1) + throw new Error( + 'CLASSIFY_SPEC node must have exactly one key: ' + JSON.stringify(node) + ); + const key = keys[0]; + if (key === 'and' || key === 'or') { + const children = node[key]; + if (!Array.isArray(children)) + throw new Error('CLASSIFY_SPEC ' + key + ' node must be an array'); + if (children.length === 0) + throw new Error('CLASSIFY_SPEC ' + key + ' node must be a non-empty array'); + for (let i = 0; i < children.length; i++) validateNode(children[i]); + return; + } + if (key === 'kv' || key === 'kvAny') { + const values = node[key]; + let valid = Array.isArray(values) && values.length > 0; + for (let i = 0; valid && i < values.length; i++) { + if (typeof values[i] !== 'string' || values[i].trim().length === 0) valid = false; + } + if (!valid) + throw new Error( + 'CLASSIFY_SPEC ' + + key + + ' predicate must be a non-empty array of non-empty strings' + ); + for (let i = 0; i < values.length; i++) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(values[i])) + throw new Error( + 'CLASSIFY_SPEC ' + key + ' predicate contains an invalid KV name' + ); + } + return; + } + if (key === 'action') { + if (node.action !== 'known') + throw new Error('CLASSIFY_SPEC action predicate must be "known"'); + return; + } + if (key === 'hint') { + if (node.hint !== true) throw new Error('CLASSIFY_SPEC hint predicate must be true'); + return; + } + throw new Error('unrecognised CLASSIFY_SPEC predicate node: ' + JSON.stringify(node)); + }; + + if (!Array.isArray(spec.rules) || spec.rules.length === 0) + throw new Error('CLASSIFY_SPEC rules must be a non-empty array'); + for (let i = 0; i < spec.rules.length; i++) validateNode(spec.rules[i]); +} + function wordPattern(words) { const alt = words.join('|'); return new RegExp('(^|[^A-Za-z0-9_])(' + alt + ')([^A-Za-z0-9_]|$)', 'i'); @@ -99,6 +158,7 @@ const NETFILTER_KV_GLUE = new RegExp( return baseclass.extend({ CLASSIFY_SPEC: CLASSIFY_SPEC, + validateClassifySpec: validateClassifySpec, TCP_FLAG_TAIL: /\b((?:SYN|ACK|FIN|RST|PSH|URG|ECE|CWR)(?:\s+(?:SYN|ACK|FIN|RST|PSH|URG|ECE|CWR))*)(?:\s+[A-Z][A-Z0-9_]*=[^\s]+)*\s*$/i, @@ -168,77 +228,24 @@ return baseclass.extend({ } }; - const validateNode = function (node) { - if (!node || typeof node !== 'object' || Array.isArray(node)) - throw new Error('CLASSIFY_SPEC node must be an object'); - const keys = Object.keys(node); - if (keys.length !== 1) - throw new Error( - 'CLASSIFY_SPEC node must have exactly one key: ' + JSON.stringify(node) - ); - const key = keys[0]; - if (key === 'and' || key === 'or') { - const children = node[key]; - if (!Array.isArray(children)) - throw new Error('CLASSIFY_SPEC ' + key + ' node must be an array'); - if (children.length === 0) - throw new Error('CLASSIFY_SPEC ' + key + ' node must be a non-empty array'); - for (let i = 0; i < children.length; i++) validateNode(children[i]); - return; - } - if (key === 'kv' || key === 'kvAny') { - const values = node[key]; - let valid = Array.isArray(values) && values.length > 0; - for (let i = 0; valid && i < values.length; i++) { - if (typeof values[i] !== 'string' || values[i].trim().length === 0) - valid = false; - } - if (!valid) - throw new Error( - 'CLASSIFY_SPEC ' + - key + - ' predicate must be a non-empty array of non-empty strings' - ); - return; - } - if (key === 'action') { - if (node.action !== 'known') - throw new Error('CLASSIFY_SPEC action predicate must be "known"'); - return; - } - if (key === 'hint') { - if (node.hint !== true) - throw new Error('CLASSIFY_SPEC hint predicate must be true'); - return; - } - throw new Error('unrecognised CLASSIFY_SPEC predicate node: ' + JSON.stringify(node)); - }; - const evalNode = function (node) { const keys = Object.keys(node); const key = keys[0]; if (key === 'and') { - let matched = true; for (let i = 0; i < node.and.length; i++) { - if (!evalNode(node.and[i])) matched = false; + if (!evalNode(node.and[i])) return false; } - return matched; + return true; } if (key === 'or') { - let matched = false; for (let i = 0; i < node.or.length; i++) { - if (evalNode(node.or[i])) matched = true; + if (evalNode(node.or[i])) return true; } - return matched; + return false; } return pred[key](node); }; - if (!Array.isArray(this.CLASSIFY_SPEC.rules) || this.CLASSIFY_SPEC.rules.length === 0) - throw new Error('CLASSIFY_SPEC rules must be a non-empty array'); - for (let i = 0; i < this.CLASSIFY_SPEC.rules.length; i++) - validateNode(this.CLASSIFY_SPEC.rules[i]); - for (let i = 0; i < this.CLASSIFY_SPEC.rules.length; i++) { if (evalNode(this.CLASSIFY_SPEC.rules[i])) return true; } @@ -547,7 +554,7 @@ return baseclass.extend({ const p = this.parseFilterValue(spec); if (!p.value) return true; - const hit = (haystack || '').indexOf(p.value) !== -1; + const hit = (haystack || '').toLowerCase().indexOf(p.value.toLowerCase()) !== -1; return p.negate ? !hit : hit; }, diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/poll-coordinator.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/poll-coordinator.js index f7bf5174a..22f0a3c02 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/poll-coordinator.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/poll-coordinator.js @@ -25,20 +25,29 @@ function createCoordinator(options) { let active = null; let pending = null; let started = false; - let registered = false; + let registered = null; let disposed = false; let hidden = isHidden(); + /* LuCI poll.add dedupes by function identity, so each registration needs its + * own entry. When the loop is idle, poll.add starts it and runs the entry + * synchronously. */ + function pollEntry() { + return function () { + return requestPoll(); + }; + } + function removePoll() { if (!registered) return; - poll.remove(requestPoll); - registered = false; + poll.remove(registered); + registered = null; } function addPoll() { if (!started || registered || disposed || isHidden()) return; - poll.add(requestPoll, cadenceSec); - registered = true; + registered = pollEntry(); + poll.add(registered, cadenceSec); } function finish(batch, value) { @@ -54,6 +63,7 @@ function createCoordinator(options) { pending = null; /* Claim ownership before invoking user code, including synchronous runs. */ active = batch; + batch.epoch = epoch; let result; try { result = Promise.resolve(run(epoch)); @@ -81,13 +91,27 @@ function createCoordinator(options) { return promise; } + /* Ensure a request that started in the current epoch. Join one that is + * already running (for example, the one poll.add just fired) instead of + * queueing a back-to-back duplicate. */ + function catchUp() { + if (disposed || isHidden()) return Promise.resolve(); + if (active && active.epoch === epoch && !pending) return active.promise; + return requestPoll(); + } + function setCadence(sec) { if (disposed) return; const next = Number.isFinite(sec) && sec > 0 ? sec : initialCadence; if (next === cadenceSec) return; cadenceSec = next; - removePoll(); + if (!registered) return; + /* Add before remove: emptying LuCI's queue stops the loop, and the next + * add would restart it with an immediate extra poll. */ + const previous = registered; + registered = null; addPoll(); + poll.remove(previous); } function onVisibilityChange() { @@ -102,7 +126,7 @@ function createCoordinator(options) { addPoll(); /* A stale request may still be on the wire. Catch up only after it * settles, retaining any refresh requested before the tab was hidden. */ - requestPoll(); + catchUp(); } function startPolling() { @@ -129,6 +153,7 @@ function createCoordinator(options) { return { requestPoll: requestPoll, + catchUp: catchUp, startPolling: startPolling, setCadence: setCadence, dispose: dispose, 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 f1979a124..09caf69d9 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 @@ -148,6 +148,7 @@ return view.extend({ summaryData: null, resolveLoadShed: false, resolveShedUntil: 0, + resolveErrorUntil: 0, filterInputTimer: null, messageLayout: 'wrap', /* Session-new IDs from the last applied batch; this is not buffer growth. */ @@ -1087,9 +1088,7 @@ return view.extend({ /* Raw logd lines, not post-filter rows. Fetch a multiple of the * display limit so mixed syslog still fills the table; pause * reads the ring cap so the buffer can catch up. */ - reply = await callFwlivePoll({ - addresses: [String(fetchLines)] - }); + reply = await callFwlivePoll([String(fetchLines)]); } catch (_e) { reply = null; } @@ -1157,12 +1156,17 @@ return view.extend({ this.notePollRtt(rtt, false); this.updateAdaptiveBanner(); - if (this.clientBackoffEnabled() && rtt > constants.POLL_RTT_SLOW_MS) { - if (!this.summaryMode) this.enterSummaryMode(reply.summary); - else { + if (this.clientBackoffEnabled()) { + if (this.summaryMode) { this.summaryData = - reply.summary && typeof reply.summary === 'object' ? reply.summary : null; + reply.summary && + typeof reply.summary === 'object' && + !Array.isArray(reply.summary) + ? reply.summary + : null; this.renderSummary(); + } else if (rtt > constants.POLL_RTT_SLOW_MS) { + this.enterSummaryMode(reply.summary); } } @@ -1552,7 +1556,8 @@ return view.extend({ enterSummaryMode(summary) { this.summaryMode = true; this.summaryRowsShown = false; - this.summaryData = summary && typeof summary === 'object' ? summary : null; + this.summaryData = + summary && typeof summary === 'object' && !Array.isArray(summary) ? summary : null; this.renderSummary(); }, @@ -1780,6 +1785,13 @@ return view.extend({ /* Invalidate any in-flight resolve from the previous toggle state. */ this.resolveGeneration = (this.resolveGeneration || 0) + 1; this.resolveInFlight = false; + /* A toggle is an explicit retry; drop resolver pauses from the last state. */ + this.resolveErrorUntil = 0; + if (this.resolveLoadShed) { + this.resolveLoadShed = false; + this.resolveShedUntil = 0; + this.updateAdaptiveBanner(); + } /* Paint the existing cache immediately; resolving only fills misses. */ if (this.tablePaused) this.updateStatus(); @@ -1960,7 +1972,15 @@ return view.extend({ const now = this.nowMs(); /* While the router sheds resolve load, hold the paused banner without * re-asking every poll — retry after the cooldown expires. */ - if (this.resolveLoadShed && now < (this.resolveShedUntil || 0)) return; + if (this.resolveLoadShed) { + if (now < (this.resolveShedUntil || 0)) return; + /* The cooldown is over; the next reply re-arms it if load persists. */ + this.resolveLoadShed = false; + this.resolveShedUntil = 0; + this.updateAdaptiveBanner(); + } + /* Resolver errors carry no per-address signal: back off the whole call. */ + if (now < (this.resolveErrorUntil || 0)) return; for (let i = 0; i < ips.length && need.length < constants.RESOLVE_BATCH_MAX; i++) { const ip = ips[i]; @@ -1976,7 +1996,7 @@ return view.extend({ this.resolveInFlight = true; try { - const res = await callFwliveResolve({ addresses: need }); + const res = await callFwliveResolve(need); if (gen !== this.resolveGeneration) return; if (this.isLoadShedReply(res)) { @@ -1992,7 +2012,11 @@ return view.extend({ const names = this.resolveNamesFromReply(res); /* RPC-level failures carry no per-address signal. Do not turn numeric, * null, or malformed replies into negative hostname cache entries. */ - if (names === null) return; + if (names === null) { + this.resolveErrorUntil = this.nowMs() + hostname.FAIL_TTL_MS; + return; + } + this.resolveErrorUntil = 0; const truncated = res && typeof res === 'object' && res.truncated === true; let updated = false; @@ -2013,6 +2037,8 @@ return view.extend({ if (updated) this.scheduleResolvePaint(); } catch (_e) { /* resolve unavailable — show IPs */ + if (gen === this.resolveGeneration) + this.resolveErrorUntil = this.nowMs() + hostname.FAIL_TTL_MS; } finally { if (gen === this.resolveGeneration) this.resolveInFlight = false; } @@ -2296,6 +2322,11 @@ return view.extend({ return this.ensurePollCoordinator().requestPoll(); }, + /* Join the request LuCI poll.add may already have fired. */ + catchUpPoll() { + return this.ensurePollCoordinator().catchUp(); + }, + async runPollRequest(epoch) { try { try { @@ -2366,10 +2397,12 @@ return view.extend({ window.addEventListener('pagehide', this.pagehideHandler); } coordinator.startPolling(); - return Promise.all([this.loadRulesMap(), this.loadLoggingStatus()]).then(() => { - if (this.viewDisposed) return; - return this.requestPoll(); - }); + /* Join the bootstrap request before metadata can outlast it. Later + * refresh, resume and visibility catch-ups must still request fresh data. */ + const startupPoll = this.catchUpPoll(); + return Promise.all([startupPoll, this.loadRulesMap(), this.loadLoggingStatus()]).then( + () => undefined + ); }, renderDisplayDrawer() { diff --git a/luci-app-fwlive/po/de/luci-app-fwlive.po b/luci-app-fwlive/po/de/luci-app-fwlive.po index 40360d5cb..0d2289703 100644 --- a/luci-app-fwlive/po/de/luci-app-fwlive.po +++ b/luci-app-fwlive/po/de/luci-app-fwlive.po @@ -12,31 +12,31 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: 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:1697 msgid "%d matching · %d/%d stored" msgstr "%d Treffer · %d/%d gespeichert" -#: 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:1662 msgid "%dd" msgstr "%dd" -#: 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:1661 msgid "%dh" msgstr "%dh" -#: 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:1660 msgid "%dm" msgstr "%dm" -#: 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:1659 msgid "%ds" msgstr "%ds" -#: 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:1423 msgid "%s · requested up to %d raw lines · %d firewall messages returned" msgstr "%s · bis zu %d Rohzeilen angefordert · %d Firewall-Meldungen zurückgegeben" -#: 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:1413 msgid "%s · requested up to %d raw lines · server limited fetch to %d · %d firewall messages returned" msgstr "%s · bis zu %d Rohzeilen angefordert · Server begrenzte Abruf auf %d · %d Firewall-Meldungen zurückgegeben" @@ -44,11 +44,11 @@ msgstr "%s · bis zu %d Rohzeilen angefordert · Server begrenzte Abruf auf %d msgid "%s: %s" msgstr "%s: %s" -#: 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:1699 msgid "0 matching · %d/%d stored" msgstr "0 Treffer · %d/%d gespeichert" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:452 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:453 msgid "Accessible (teal/orange)" msgstr "Barrierefrei (teal/orange)" @@ -57,11 +57,11 @@ msgstr "Barrierefrei (teal/orange)" msgid "Action" msgstr "Aktion" -#: 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:946 msgid "Administrator access is required to disable logging." msgstr "Zum Deaktivieren der Protokollierung sind Administratorrechte erforderlich." -#: 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:934 msgid "Administrator access is required to enable logging." msgstr "Zum Aktivieren der Protokollierung sind Administratorrechte erforderlich." @@ -69,7 +69,7 @@ msgstr "Zum Aktivieren der Protokollierung sind Administratorrechte erforderlich msgid "Allow/deny rules, LAN logging, or anything else." msgstr "Allow/Deny-Regeln, LAN-Protokollierung oder irgendetwas anderes." -#: 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:1906 msgid "Also seen" msgstr "Weitere" @@ -81,16 +81,16 @@ msgstr "Eine andere Änderung an der Firewall ist vorgemerkt; zuerst anwenden od msgid "Another change overwrote WAN logging after it was saved; check the current state." msgstr "Eine andere Änderung hat die WAN-Protokollierung nach dem Speichern überschrieben; prüfen Sie den aktuellen Zustand." -#: 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:2755 msgid "Any action" msgstr "Beliebige Aktion" -#: 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:1899 msgid "Any protocol" msgstr "Beliebiges Protokoll" -#: 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:1404 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1476 msgid "Auto" msgstr "Auto" @@ -106,11 +106,11 @@ msgstr "Protokollierung kann erst aktiviert werden, wenn die Kernel-Log-Module i msgid "Changes:" msgstr "Änderungen:" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:451 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:452 msgid "Classic (green/red)" msgstr "Klassisch (grün/rot)" -#: 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:2467 msgid "Classic uses green/red; Accessible uses teal/orange" msgstr "Klassisch verwendet grün/rot; Barrierefrei verwendet teal/orange" @@ -118,11 +118,11 @@ msgstr "Klassisch verwendet grün/rot; Barrierefrei verwendet teal/orange" msgid "Clear all" msgstr "Alle löschen" -#: 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:2827 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 "Zelle anklicken zum Filtern · ≠ auf einem Chip zum Ausschließen · Strg+Klick auf eine Regel für Firewall-Einstellungen · in der Einfachen Ansicht Zeile anklicken für die vollständige Meldung" -#: 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:2542 msgid "Click a row (Time or other non-link cells) to see the full log line (Simple view)." msgstr "Klicken Sie auf eine Zeile (Zeit oder andere Nicht-Link-Zellen), um die vollständige Log-Zeile zu sehen (Einfache Ansicht)." @@ -130,15 +130,15 @@ msgstr "Klicken Sie auf eine Zeile (Zeit oder andere Nicht-Link-Zellen), um die msgid "Click a row for the full message" msgstr "Zeile anklicken für die vollständige Meldung" -#: 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:2547 msgid "Click an IP, action, or protocol to filter; use the Protocol menu (or ≠ on a chip) to exclude." msgstr "Klicken Sie auf eine IP, Aktion oder ein Protokoll, um zu filtern; nutzen Sie das Protokoll-Menü (oder ≠ auf einem Chip) zum Ausschließen." -#: 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:1900 msgid "Common" msgstr "Häufig" -#: 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:1710 msgid "Connection lost — retrying…" msgstr "Verbindung verloren — neuer Versuch…" @@ -150,43 +150,43 @@ msgstr "Die Protokollierungssperre konnte nicht erworben werden." msgid "Could not clear the WAN zone log option." msgstr "Die WAN-Zonen-Log-Option konnte nicht gelöscht werden." -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:756 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:757 msgid "Could not create a temporary file for rule names." msgstr "Für die Regelnamen konnte keine temporäre Datei erstellt werden." -#: 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:944 msgid "Could not disable logging." msgstr "Protokollierung konnte nicht deaktiviert werden." -#: 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:926 msgid "Could not enable logging." msgstr "Protokollierung konnte nicht aktiviert werden." -#: 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:860 msgid "Could not load logging status." msgstr "Protokollstatus konnte nicht geladen werden." -#: 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:763 msgid "Could not load rule names." msgstr "Die Regelnamen konnten nicht geladen werden." -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:758 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:759 msgid "Could not process firewall rule names." msgstr "Die Namen der Firewall-Regeln konnten nicht verarbeitet werden." -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:760 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:761 msgid "Could not read the active firewall rules." msgstr "Die aktiven Firewall-Regeln konnten nicht gelesen werden." -#: 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:859 msgid "Could not refresh logging status; showing the last known state." msgstr "Protokollstatus konnte nicht aktualisiert werden; zeige den zuletzt bekannten Zustand." -#: 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:768 msgid "Could not refresh rule names." msgstr "Die Regelnamen konnten nicht aktualisiert werden." -#: 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:766 msgid "Could not refresh rule names; previously loaded names are still shown." msgstr "Die Regelnamen konnten nicht aktualisiert werden; die zuvor geladenen Namen werden weiterhin angezeigt." @@ -206,7 +206,7 @@ msgstr "Die Änderung der Protokollierung konnte nicht sicher nachverfolgt werde msgid "Could not write the WAN zone log option." msgstr "Die WAN-Zonen-Log-Option konnte nicht geschrieben werden." -#: 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:2782 msgid "Custom protocol (prefix ! to exclude). Overrides the menu when set." msgstr "Eigenes Protokoll (! zum Ausschließen). Überschreibt das Menü, wenn gesetzt." @@ -219,7 +219,7 @@ msgstr "Ziel-Port" msgid "Destination" msgstr "Ziel" -#: 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:2812 msgid "Destination IP contains (! to exclude)" msgstr "Ziel-IP enthält (! zum Ausschließen)" @@ -227,15 +227,15 @@ msgstr "Ziel-IP enthält (! zum Ausschließen)" msgid "Destination port" msgstr "Ziel-Port" -#: 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:2817 msgid "Destination port (! to exclude)" msgstr "Ziel-Port (! zum Ausschließen)" -#: 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:2670 msgid "Detail" msgstr "Detail" -#: 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:2623 msgid "Details" msgstr "Details" @@ -247,15 +247,15 @@ msgstr "Richtung" msgid "Disabling…" msgstr "Wird deaktiviert…" -#: 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:1453 msgid "Display limited to %d rows on this device; the larger Limit still buffers rows." msgstr "Anzeige auf diesem Gerät auf %d Zeilen begrenzt; das größere Limit puffert weiterhin Zeilen." -#: 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:2408 msgid "Display options" msgstr "Anzeigeoptionen" -#: 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:2501 msgid "Display options set Limit, Fetch budget, Maximum raw lines, row tint, palette, and hostnames." msgstr "Anzeigeoptionen setzen Limit, Abrufbudget, maximale Rohzeilen, Zeileneinfärbung, Palette und Hostnamen." @@ -275,7 +275,7 @@ msgstr "Drop/Reject-Protokollierung der WAN-Zone aktivieren (wie unter Netzwerk msgid "Enable logging" msgstr "Protokollierung aktivieren" -#: 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:2496 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." msgstr "„Protokollierung aktivieren“ schaltet nur die Drop/Reject-Protokollierung der WAN-Zone ein (wie unter Netzwerk → Firewall). Es fügt keine Regeln hinzu und protokolliert keinen normalen LAN-Verkehr." @@ -283,7 +283,7 @@ msgstr "„Protokollierung aktivieren“ schaltet nur die Drop/Reject-Protokolli msgid "Enabling…" msgstr "Wird aktiviert…" -#: 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:1913 msgid "Exclude" msgstr "Ausschließen" @@ -291,11 +291,11 @@ msgstr "Ausschließen" msgid "Exclude instead" msgstr "Stattdessen ausschließen" -#: 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:2422 msgid "Fetch budget" msgstr "Abrufbudget" -#: 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:2511 msgid "Fetch budget controls raw log lines per poll. Auto derives from Limit; Manual selects a bounded maximum." msgstr "Das Abrufbudget steuert die Rohzeilen pro Abfrage. Auto leitet es aus dem Limit ab; Manuell wählt ein begrenztes Maximum." @@ -309,12 +309,12 @@ msgstr "Filtern nach %s" msgid "Filter logs by rule (hint: %s). Ctrl+click to open firewall settings." msgstr "Logs nach Regel filtern (Hinweis: %s). Strg+Klick öffnet die Firewall-Einstellungen." -#: 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:2585 #: openwrt-feed/luci-app-fwlive/root/usr/share/luci/menu.d/luci-app-fwlive.json:3 msgid "Firewall Live View" msgstr "Firewall-Live-Ansicht" -#: 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:1677 msgid "Firewall log has no current entries; showing rows last received %s ago." msgstr "Im Firewall-Protokoll sind derzeit keine Einträge; angezeigt werden Zeilen, die zuletzt vor %s empfangen wurden." @@ -326,7 +326,7 @@ msgstr "Flags" msgid "Flow" msgstr "Fluss" -#: 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:2521 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." msgstr "Für eine responsive Tabelle auf einem schwachen Gerät sollte Limit auf höchstens 250 Zeilen stehen. Die Schwachgerätebegrenzung betrifft die angezeigten Zeilen; der Puffer kann weiterhin mehr Zeilen behalten." @@ -338,19 +338,19 @@ msgstr "Weitergeleitet" msgid "Grant access to firewall live log view" msgstr "Zugriff auf die Live-Ansicht der Firewall-Protokolle gewähren" -#: 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:2487 msgid "Help" msgstr "Hilfe" -#: 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:1551 msgid "Hide rows" msgstr "Zeilen ausblenden" -#: 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:1449 msgid "Hostname lookup paused while the router is under load." msgstr "Hostnamenauflösung pausiert, solange der Router ausgelastet ist." -#: 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:1904 msgid "ICMPv6" msgstr "ICMPv6" @@ -358,7 +358,7 @@ msgstr "ICMPv6" msgid "IN" msgstr "EINGANG" -#: 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:2558 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 data leaves the device)." msgstr "Wenn die Zeileneinfärbung fehlt, verwendet das aktive LuCI-Theme möglicherweise keine Erfolgs/Fehler-CSS-Variablen; fwlive fällt auf lokale Farben zurück (air-gapped, keine Daten verlassen das Gerät)." @@ -378,7 +378,7 @@ msgstr "Stattdessen einschließen" msgid "Install kmod-nf-log and kmod-nf-log6 with the command for your OpenWrt release, then reload the firewall." msgstr "Installieren Sie kmod-nf-log und kmod-nf-log6 mit dem Befehl für Ihre OpenWrt-Version und laden Sie dann die Firewall neu." -#: 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:1708 msgid "Installation is incomplete. Reinstall luci-app-fwlive." msgstr "Installation unvollständig. Installieren Sie luci-app-fwlive neu." @@ -387,7 +387,7 @@ msgstr "Installation unvollständig. Installieren Sie luci-app-fwlive neu." msgid "Interface" msgstr "Schnittstelle" -#: 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:2797 msgid "Interface (prefix ! to exclude)" msgstr "Schnittstelle (! voranstellen zum Ausschließen)" @@ -404,11 +404,11 @@ msgstr "Kernel-Logmodule fehlen" msgid "Len" msgstr "Länge" -#: 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:2411 msgid "Limit" msgstr "Limit" -#: 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:814 msgid "Live view may be incomplete — a legacy iptables table is registered" msgstr "Die Live-Ansicht kann unvollständig sein — eine veraltete iptables-Tabelle ist registriert" @@ -416,16 +416,16 @@ msgstr "Die Live-Ansicht kann unvollständig sein — eine veraltete iptables-Ta msgid "Logging is off on this router" msgstr "Protokollierung ist auf diesem Router deaktiviert" -#: 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:1404 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1477 msgid "Manual" msgstr "Manuell" -#: 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:2440 msgid "Manual still uses server protection and poll cadence" msgstr "Manuell nutzt weiterhin Serverschutz und Abfrageintervall" -#: 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:2516 msgid "Manual still uses server protection and poll cadence; it changes the fetch budget, not the polling interval." msgstr "Manuell nutzt weiterhin Serverschutz und Abfrageintervall; es ändert das Abrufbudget, nicht das Abfrageintervall." @@ -433,18 +433,18 @@ msgstr "Manuell nutzt weiterhin Serverschutz und Abfrageintervall; es ändert da msgid "Manual test (System → Terminal):" msgstr "Manueller Test (System → Terminal):" -#: 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:2434 msgid "Maximum raw lines" msgstr "Maximale Rohzeilen" #: 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:2682 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2689 msgid "Message" msgstr "Nachricht" -#: 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:2792 msgid "More filters" msgstr "Weitere Filter" @@ -476,7 +476,7 @@ msgstr "Nichts ändert sich, bis Sie auf „Protokollierung aktivieren“ klicke msgid "OUT" msgstr "AUSGANG" -#: 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:2712 msgid "One line" msgstr "Einzeilig" @@ -500,20 +500,20 @@ msgstr "OpenWrt schreibt Firewall-Ereignisse erst ins Log, wenn Sie die Protokol msgid "Outbound" msgstr "Ausgehend" -#: 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:2461 msgid "Palette" msgstr "Palette" -#: 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:1771 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2636 msgid "Pause" msgstr "Pause" -#: 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:1770 msgid "Paused" msgstr "Pausiert" -#: 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:1436 msgid "Polling slowed: the router is responding slowly, so polls run less often." msgstr "Abfragen verlangsamt: Der Router antwortet langsam, daher wird seltener abgefragt." @@ -522,11 +522,11 @@ msgstr "Abfragen verlangsamt: Der Router antwortet langsam, daher wird seltener msgid "Proto" msgstr "Protokoll" -#: 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:2773 msgid "Protocol — common values" msgstr "Protokoll — häufige Werte" -#: 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:2752 msgid "Quick search" msgstr "Schnellsuche" @@ -534,19 +534,19 @@ msgstr "Schnellsuche" msgid "Remove filter" msgstr "Filter entfernen" -#: 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:1771 msgid "Resume" msgstr "Fortsetzen" -#: 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:2451 msgid "Row tint" msgstr "Zeileneinfärbung" -#: 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:2552 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 "Zeilenfärbung zeigt Pass/Deny-Hintergrundfarben, wenn aktiviert. Wählen Sie Klassisch (grün/rot, Standard) oder Barrierefrei (teal/orange). Aktionstext bleibt in beiden Fällen farbig." -#: 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:2608 msgid "Row tint used a local color fallback because the active LuCI theme did not apply pass/deny backgrounds." msgstr "Die Zeileneinfärbung verwendete einen lokalen Farbfallback, da das aktive LuCI-Theme keine Erlauben/Verweigern-Hintergründe anwendet." @@ -554,19 +554,19 @@ msgstr "Die Zeileneinfärbung verwendete einen lokalen Farbfallback, da das akti msgid "Rule" msgstr "Regel" -#: 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:2623 msgid "Rule name details" msgstr "Details zu Regelnamen" -#: 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:785 msgid "Rule-name lookup does not change your firewall rules." msgstr "Das Laden der Regelnamen verändert Ihre Firewall-Regeln nicht." -#: 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:770 msgid "Rule-name lookup failed." msgstr "Das Laden der Regelnamen ist fehlgeschlagen." -#: 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:777 msgid "Rule-name lookup reached a safety limit." msgstr "Beim Laden der Regelnamen wurde eine Schutzgrenze erreicht." @@ -578,48 +578,48 @@ msgstr "Quell-Port" msgid "Search" msgstr "Suche" -#: 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:1433 msgid "Server adaptive protection is disabled." msgstr "Adaptiver Serverschutz ist deaktiviert." -#: 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:1441 msgid "Server limited fetch: the router is under load; at most %d log lines per poll." msgstr "Abruf vom Server begrenzt: Der Router ist ausgelastet; höchstens %d Protokollzeilen pro Abfrage." -#: 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:1446 msgid "Server limited fetch: this poll returned fewer lines to protect the router." msgstr "Abruf vom Server begrenzt: Diese Abfrage lieferte weniger Zeilen, um den Router zu schonen." -#: 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:1431 msgid "Server protection state is unknown." msgstr "Serverschutz-Status unbekannt." -#: 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:2478 msgid "Show hostnames" msgstr "Hostnamen anzeigen" -#: 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:2449 msgid "Show pass/deny row background colors" msgstr "Pass/Deny-Zeilenhintergrundfarben anzeigen" -#: 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:1551 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2735 msgid "Show rows" msgstr "Zeilen anzeigen" -#: 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:1511 msgid "Showing a compact summary of the top of the shown sample." msgstr "Eine kompakte Zusammenfassung des Anfangs der angezeigten Stichprobe." -#: 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:2659 msgid "Simple" msgstr "Einfach" -#: 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:788 msgid "Some entries may show names derived from their log prefixes. Searching by a friendly rule name may miss those entries." msgstr "Bei einigen Einträgen werden möglicherweise Namen aus den Protokollpräfixen angezeigt. Bei der Suche nach einem lesbaren Regelnamen werden diese Einträge möglicherweise nicht gefunden." -#: 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:808 msgid "Some rule names may be missing" msgstr "Einige Regelnamen fehlen möglicherweise" @@ -628,7 +628,7 @@ msgstr "Einige Regelnamen fehlen möglicherweise" msgid "Source" msgstr "Quelle" -#: 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:2802 msgid "Source IP contains (! to exclude)" msgstr "Quell-IP enthält (! zum Ausschließen)" @@ -636,31 +636,31 @@ msgstr "Quell-IP enthält (! zum Ausschließen)" msgid "Source port" msgstr "Quell-Port" -#: 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:2807 msgid "Source port (! to exclude)" msgstr "Quell-Port (! zum Ausschließen)" -#: 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:1513 msgid "Summary data is unavailable; show rows to inspect the sample." msgstr "Zusammenfassungsdaten nicht verfügbar; Zeilen anzeigen, um die Stichprobe zu prüfen." -#: 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:2726 msgid "Summary mode" msgstr "Zusammenfassungsmodus" -#: 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:2506 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 "Zusammenfassungsmodus: Wenn der Router langsam antwortet, ersetzt eine kompakte Zusammenfassung die Tabelle. Mit „Zeilen anzeigen“ sehen Sie die Tabelle; sie kehrt nach drei schnellen Abfragen automatisch zurück." -#: 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:1515 msgid "Summary was bounded before all categories could be included." msgstr "Die Zusammenfassung wurde begrenzt, bevor alle Kategorien aufgenommen werden konnten." -#: 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:2526 msgid "Switching tabs pauses polling; returning performs one catch-up poll. Hostnames are off by default because lookups add work." msgstr "Beim Wechseln des Tabs wird das Polling pausiert; bei der Rückkehr erfolgt eine einmalige Aufholabfrage. Hostnamen sind standardmäßig deaktiviert, da Auflösungen zusätzliche Arbeit verursachen." -#: 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:1606 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 automatically." msgstr "Tabellenaktualisierung gedrosselt: Die Ereignisrate ist hoch, daher wird die Tabelle seltener neu gezeichnet, um den Browser zu schonen. Der Puffer wird weiter aktualisiert; die Aktualisierung wird automatisch fortgesetzt." @@ -668,23 +668,23 @@ msgstr "Tabellenaktualisierung gedrosselt: Die Ereignisrate ist hoch, daher wird msgid "The firewall did not reload; saved and live logging may differ." msgstr "Die Firewall wurde nicht neu geladen; gespeicherte und aktive Protokollierung können voneinander abweichen." -#: 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:2536 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:1722 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1709 msgid "The router could not read the firewall log — retrying…" msgstr "Der Router konnte das Firewall-Protokoll nicht lesen — neuer Versuch…" -#: 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:2531 msgid "The router log ring may evict older events before fwlive reads them. fwlive cannot recover evicted entries or change forwarding behavior." msgstr "Der Protokollring des Routers kann ältere Ereignisse verdrängen, bevor fwlive sie liest. fwlive kann verdrängte Einträge nicht wiederherstellen und ändert das Weiterleitungsverhalten nicht." -#: 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:2491 msgid "The table updates automatically when your firewall logs traffic. Use Pause if it moves too fast." msgstr "Die Tabelle aktualisiert sich automatisch, wenn Ihre Firewall Verkehr protokolliert. Pause nutzen, wenn es zu schnell wird." -#: 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:2611 msgid "Theme tint fallback" msgstr "Theme-Farb-Fallback" @@ -700,15 +700,15 @@ msgstr "Dieser Router hat eine Protokollierungsblockade gemeldet, die Live View msgid "Time" msgstr "Zeit" -#: 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:1520 msgid "Top drops" msgstr "Häufigste Drops" -#: 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:1523 msgid "Top rules" msgstr "Häufigste Regeln" -#: 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:1517 msgid "Top talkers" msgstr "Häufigste Absender" @@ -728,16 +728,16 @@ msgstr "Aktiviert die Protokollierung für die WAN-Firewallzone und lädt die Fi msgid "Undo:" msgstr "Rückgängig:" -#: 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:2555 msgid "Use Detail for all columns (flags, length, raw message)." msgstr "Verwenden Sie „Detail“ für alle Spalten (Flags, Länge, rohe Nachricht)." -#: 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:2641 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2647 msgid "View" msgstr "Ansicht" -#: 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:945 msgid "WAN drop/reject logging is off." msgstr "WAN-Drop/Reject-Protokollierung ist aus." @@ -745,11 +745,11 @@ msgstr "WAN-Drop/Reject-Protokollierung ist aus." msgid "WAN drop/reject logging is on. Blocked inbound WAN traffic will show up here. Normal LAN browsing will not." msgstr "WAN-Drop/Reject-Protokollierung ist an. Blockierter eingehender WAN-Verkehr erscheint hier. Normales LAN-Surfen nicht." -#: 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:930 msgid "WAN drop/reject logging is on. Blocked inbound traffic should appear here as it happens — not normal LAN browsing." msgstr "WAN-Drop/Reject-Protokollierung ist an. Blockierter eingehender Verkehr sollte hier in Echtzeit erscheinen — nicht normales LAN-Surfen." -#: 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:932 msgid "WAN logging is already enabled." msgstr "WAN-Protokollierung ist bereits aktiviert." @@ -778,25 +778,25 @@ msgstr "WAN-Protokollierung nicht verfügbar: keine WAN-Zone" msgid "Waiting for firewall events" msgstr "Warte auf Firewall-Ereignisse" -#: 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:1770 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2599 msgid "Watching" msgstr "Beobachtet" -#: 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:2701 msgid "Wrap" msgstr "Umbrechen" #: 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:2757 msgid "block" msgstr "blockiert" -#: 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:1255 msgid "buffer filling" msgstr "Puffer füllt sich" -#: 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:1259 msgid "buffer full" msgstr "Puffer voll" @@ -808,7 +808,7 @@ msgstr "enthält" msgid "default 10/minute" msgstr "Standard 10/Minute" -#: 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:1264 msgid "display limited to %d rows" msgstr "Anzeige auf %d Zeilen begrenzt" @@ -817,11 +817,11 @@ msgid "does not contain" msgstr "enthält nicht" #: 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:2758 msgid "drop" msgstr "verworfen" -#: 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:1268 msgid "hostname lookup paused" msgstr "Hostnamenauflösung pausiert" @@ -829,16 +829,16 @@ msgstr "Hostnamenauflösung pausiert" msgid "is" msgstr "ist" -#: 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:1254 msgid "loading buffer" msgstr "Lade Puffer" -#: 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:1695 msgid "loading…" msgstr "lädt…" -#: 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:1493 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1502 msgid "none" msgstr "keine" @@ -846,98 +846,98 @@ msgstr "keine" msgid "not" msgstr "nicht" -#: 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:1921 msgid "not AH" msgstr "nicht AH" -#: 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:1920 msgid "not ESP" msgstr "nicht ESP" -#: 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:1919 msgid "not GRE" msgstr "nicht GRE" -#: 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:1916 msgid "not ICMP" msgstr "nicht ICMP" -#: 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:1917 msgid "not ICMPv6" msgstr "nicht ICMPv6" -#: 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:1918 msgid "not IGMP" msgstr "nicht IGMP" -#: 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:1922 msgid "not SCTP" msgstr "nicht SCTP" -#: 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:1914 msgid "not TCP" msgstr "nicht TCP" -#: 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:1915 msgid "not UDP" msgstr "nicht UDP" -#: 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:2763 msgid "not block" msgstr "nicht blockieren" -#: 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:2762 msgid "not drop" msgstr "nicht verwerfen" -#: 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:2761 msgid "not pass" msgstr "nicht erlauben" -#: 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:2764 msgid "not reject" msgstr "nicht zurückweisen" -#: 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:2765 msgid "not unknown" msgstr "nicht unbekannt" -#: 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:2780 msgid "or type…" msgstr "oder tippen…" #: 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:2756 msgid "pass" msgstr "erlaubt" -#: 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:1266 msgid "polling slowed" msgstr "Abfragen verlangsamt" #: 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:2759 msgid "reject" msgstr "abgelehnt" -#: 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:1270 msgid "scroll frozen — scroll to top to follow live" msgstr "Scrollen eingefroren — nach oben scrollen für Live-Ansicht" -#: 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:1267 msgid "server limited fetch" msgstr "Abruf vom Server begrenzt" -#: 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:1261 msgid "table refresh throttled" msgstr "Tabellenaktualisierung gedrosselt" #: 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:2760 msgid "unknown" msgstr "unbekannt" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:749 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:750 msgid "using fw4" msgstr "mit fw4" diff --git a/luci-app-fwlive/po/ru/luci-app-fwlive.po b/luci-app-fwlive/po/ru/luci-app-fwlive.po index c1cd1f417..b208194c5 100644 --- a/luci-app-fwlive/po/ru/luci-app-fwlive.po +++ b/luci-app-fwlive/po/ru/luci-app-fwlive.po @@ -12,31 +12,31 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -#: 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:1697 msgid "%d matching · %d/%d stored" msgstr "%d совпад. · сохранено %d/%d" -#: 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:1662 msgid "%dd" msgstr "%dд" -#: 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:1661 msgid "%dh" msgstr "%dч" -#: 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:1660 msgid "%dm" msgstr "%dм" -#: 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:1659 msgid "%ds" msgstr "%dс" -#: 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:1423 msgid "%s · requested up to %d raw lines · %d firewall messages returned" msgstr "%s · запрошено до %d сырых строк · возвращено %d сообщений firewall" -#: 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:1413 msgid "%s · requested up to %d raw lines · server limited fetch to %d · %d firewall messages returned" msgstr "%s · запрошено до %d сырых строк · сервер ограничил выборку до %d · возвращено %d сообщений firewall" @@ -44,11 +44,11 @@ msgstr "%s · запрошено до %d сырых строк · сервер msgid "%s: %s" msgstr "%s: %s" -#: 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:1699 msgid "0 matching · %d/%d stored" msgstr "0 совпад. · сохранено %d/%d" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:452 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:453 msgid "Accessible (teal/orange)" msgstr "Доступный (бирюзовый/оранжевый)" @@ -57,11 +57,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:946 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:934 msgid "Administrator access is required to enable logging." msgstr "Для включения журналирования требуются права администратора." @@ -69,7 +69,7 @@ msgstr "Для включения журналирования требуютс msgid "Allow/deny rules, LAN logging, or anything else." msgstr "Правила allow/deny, журналирование LAN и любые другие настройки." -#: 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:1906 msgid "Also seen" msgstr "Также встречаются" @@ -81,16 +81,16 @@ msgstr "В брандмауэре уже есть другое незафикс msgid "Another change overwrote WAN logging after it was saved; check the current state." msgstr "Другое изменение перезаписало журналирование WAN после сохранения; проверьте текущее состояние." -#: 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:2755 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:1899 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:1404 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1476 msgid "Auto" msgstr "Авто" @@ -106,11 +106,11 @@ msgstr "Невозможно включить журналирование, по msgid "Changes:" msgstr "Изменения:" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:451 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:452 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:2467 msgid "Classic uses green/red; Accessible uses teal/orange" msgstr "Классический — зелёный/красный; доступный — бирюзовый/оранжевый" @@ -118,11 +118,11 @@ 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:2827 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 "Щёлкните ячейку для фильтра · ≠ на чипе для исключения · Ctrl+щелчок по правилу для настроек МЭ · в Простом виде щёлкните строку для полного сообщения" -#: 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:2542 msgid "Click a row (Time or other non-link cells) to see the full log line (Simple view)." msgstr "Щёлкните строку (Время или другие ячейки без ссылки), чтобы увидеть полную строку журнала (Простой вид)." @@ -130,15 +130,15 @@ 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:2547 msgid "Click an IP, action, or protocol to filter; use the Protocol menu (or ≠ on a chip) to exclude." msgstr "Щёлкните по IP, действию или протоколу для фильтрации; для исключения используйте меню «Протокол» (или ≠ на чипе)." -#: 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:1900 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:1710 msgid "Connection lost — retrying…" msgstr "Соединение потеряно — повторная попытка…" @@ -150,43 +150,43 @@ msgstr "Не удалось получить блокировку журнали msgid "Could not clear the WAN zone log option." msgstr "Не удалось очистить параметр журнала зоны WAN." -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:756 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:757 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:944 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:926 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:860 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:763 msgid "Could not load rule names." msgstr "Не удалось загрузить имена правил." -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:758 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:759 msgid "Could not process firewall rule names." msgstr "Не удалось обработать имена правил межсетевого экрана." -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:760 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:761 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:859 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:768 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:766 msgid "Could not refresh rule names; previously loaded names are still shown." msgstr "Не удалось обновить имена правил; отображаются ранее загруженные имена." @@ -206,7 +206,7 @@ msgstr "Не удалось безопасно отследить изменен msgid "Could not write the WAN zone log option." msgstr "Не удалось записать параметр журнала зоны WAN." -#: 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:2782 msgid "Custom protocol (prefix ! to exclude). Overrides the menu when set." msgstr "Свой протокол (! для исключения). Переопределяет меню, если задан." @@ -219,7 +219,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:2812 msgid "Destination IP contains (! to exclude)" msgstr "IP назначения содержит (! для исключения)" @@ -227,15 +227,15 @@ msgstr "IP назначения содержит (! для исключения) 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:2817 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:2670 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:2623 msgid "Details" msgstr "Подробности" @@ -247,15 +247,15 @@ 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:1453 msgid "Display limited to %d rows on this device; the larger Limit still buffers rows." msgstr "Отображение ограничено %d строками на этом устройстве; больший лимит по-прежнему буферизует строки." -#: 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:2408 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:2501 msgid "Display options set Limit, Fetch budget, Maximum raw lines, row tint, palette, and hostnames." msgstr "Параметры отображения задают лимит, бюджет выборки, максимум сырых строк, цвет строк, палитру и имена узлов." @@ -275,7 +275,7 @@ msgstr "Включить журналирование drop/reject для WAN-з 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:2496 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." msgstr "«Включить журналирование» включает только журналирование drop/reject для WAN-зоны (как в Сеть → Межсетевой экран). Оно не добавляет правил и не журналирует обычный LAN-трафик." @@ -283,7 +283,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:1913 msgid "Exclude" msgstr "Исключить" @@ -291,11 +291,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:2422 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:2511 msgid "Fetch budget controls raw log lines per poll. Auto derives from Limit; Manual selects a bounded maximum." msgstr "Бюджет выборки задаёт число сырых строк журнала за опрос. «Авто» выводится из лимита; «Вручную» задаёт ограниченный максимум." @@ -309,12 +309,12 @@ msgstr "Фильтр по %s" msgid "Filter logs by rule (hint: %s). Ctrl+click to open firewall settings." msgstr "Фильтр журналов по правилу (подсказка: %s). Ctrl+клик открывает настройки файрвола." -#: 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:2585 #: 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:1677 msgid "Firewall log has no current entries; showing rows last received %s ago." msgstr "В журнале межсетевого экрана сейчас нет записей; показаны строки, полученные %s назад." @@ -326,7 +326,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:2521 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." msgstr "Для отзывчивой таблицы на слабом устройстве установите Лимит не выше 250 строк. Ограничение слабого устройства влияет на отображаемые строки; буфер всё ещё может хранить больше." @@ -338,19 +338,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:2487 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:1551 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:1449 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:1904 msgid "ICMPv6" msgstr "ICMPv6" @@ -358,7 +358,7 @@ msgstr "ICMPv6" 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:2558 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 data leaves the device)." msgstr "Если цветовая маркировка строк отсутствует, активная тема LuCI может не содержать CSS-переменные success/error или info/warn; fwlive использует локальные цвета (работает автономно, данные не покидают устройство)." @@ -378,7 +378,7 @@ msgstr "Включить вместо этого" msgid "Install kmod-nf-log and kmod-nf-log6 with the command for your OpenWrt release, then reload the firewall." msgstr "Установите kmod-nf-log и kmod-nf-log6 командой для вашей версии OpenWrt, затем перезагрузите файрвол." -#: 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:1708 msgid "Installation is incomplete. Reinstall luci-app-fwlive." msgstr "Установка не завершена. Переустановите luci-app-fwlive." @@ -387,7 +387,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:2797 msgid "Interface (prefix ! to exclude)" msgstr "Интерфейс (! в начале для исключения)" @@ -404,11 +404,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:2411 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:814 msgid "Live view may be incomplete — a legacy iptables table is registered" msgstr "Живой просмотр может быть неполным — зарегистрирована устаревшая таблица iptables" @@ -416,16 +416,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:1404 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1477 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:2440 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:2516 msgid "Manual still uses server protection and poll cadence; it changes the fetch budget, not the polling interval." msgstr "«Вручную» по-прежнему использует защиту сервера и частоту опроса; меняется бюджет выборки, а не интервал опроса." @@ -433,18 +433,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:2434 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:2682 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2689 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:2792 msgid "More filters" msgstr "Больше фильтров" @@ -476,7 +476,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:2712 msgid "One line" msgstr "Одна строка" @@ -500,20 +500,20 @@ msgstr "OpenWrt не записывает события межсетевого 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:2461 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:1771 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2636 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:1770 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:1436 msgid "Polling slowed: the router is responding slowly, so polls run less often." msgstr "Опрос замедлен: маршрутизатор отвечает медленно, поэтому опросы выполняются реже." @@ -522,11 +522,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:2773 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:2752 msgid "Quick search" msgstr "Быстрый поиск" @@ -534,19 +534,19 @@ 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:1771 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:2451 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:2552 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 "Подсветка строк показывает фоны pass/deny, когда включена. Выберите классический (зелёный/красный, по умолчанию) или доступный (бирюзовый/оранжевый). Цвет текста действия сохраняется в любом случае." -#: 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:2608 msgid "Row tint used a local color fallback because the active LuCI theme did not apply pass/deny backgrounds." msgstr "Цветовая маркировка строк использовала локальный запасной цвет, так как активная тема LuCI не применяет фон пропуска/отказа." @@ -554,19 +554,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:2623 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:785 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:770 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:777 msgid "Rule-name lookup reached a safety limit." msgstr "При загрузке имён правил достигнут защитный лимит." @@ -578,48 +578,48 @@ 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:1433 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:1441 msgid "Server limited fetch: the router is under load; at most %d log lines per poll." msgstr "Сервер ограничил выборку: маршрутизатор нагружен; не более %d строк журнала за опрос." -#: 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:1446 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:1431 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:2478 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:2449 msgid "Show pass/deny row background colors" msgstr "Показывать цвета фона строк pass/deny" -#: 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:1551 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2735 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:1511 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:2659 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:788 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:808 msgid "Some rule names may be missing" msgstr "Некоторые имена правил могут отсутствовать" @@ -628,7 +628,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:2802 msgid "Source IP contains (! to exclude)" msgstr "IP источника содержит (! для исключения)" @@ -636,31 +636,31 @@ msgstr "IP источника содержит (! для исключения)" 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:2807 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:1513 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:2726 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:2506 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:1515 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:2526 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:1606 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 automatically." msgstr "Обновление таблицы ограничено: частота событий высокая, поэтому таблица перерисовывается реже для защиты браузера. Буфер продолжает обновляться; обновление возобновится автоматически." @@ -668,23 +668,23 @@ 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:2536 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:1722 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1709 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:2531 msgid "The router log ring may evict older events before fwlive reads them. fwlive cannot recover evicted entries or change forwarding behavior." msgstr "Кольцевой журнал маршрутизатора может вытеснить старые события до того, как fwlive их прочитает. fwlive не может восстановить вытесненные записи и не изменяет пересылку." -#: 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:2491 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:2611 msgid "Theme tint fallback" msgstr "Запасной цвет темы" @@ -700,15 +700,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:1520 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:1523 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:1517 msgid "Top talkers" msgstr "Частые источники" @@ -728,16 +728,16 @@ msgstr "Включает журналирование для WAN-зоны меж 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:2555 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:2641 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2647 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:945 msgid "WAN drop/reject logging is off." msgstr "Журналирование drop/reject для WAN отключено." @@ -745,11 +745,11 @@ msgstr "Журналирование drop/reject для WAN отключено." msgid "WAN drop/reject logging is on. Blocked inbound WAN traffic will show up here. Normal LAN browsing will not." msgstr "Журналирование drop/reject для WAN включено. Заблокированный входящий WAN-трафик будет отображаться здесь. Обычный LAN-трафик — нет." -#: 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:930 msgid "WAN drop/reject logging is on. Blocked inbound traffic should appear here as it happens — not normal LAN browsing." msgstr "Журналирование drop/reject для WAN включено. Заблокированный входящий трафик должен появляться здесь по мере поступления — обычный LAN-трафик не отображается." -#: 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:932 msgid "WAN logging is already enabled." msgstr "WAN-журналирование уже включено." @@ -778,25 +778,25 @@ msgstr "WAN-журналирование недоступно: нет зоны W 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:1770 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2599 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:2701 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:2757 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:1255 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:1259 msgid "buffer full" msgstr "буфер заполнен" @@ -808,7 +808,7 @@ msgstr "содержит" msgid "default 10/minute" msgstr "по умолчанию 10/мин" -#: 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:1264 msgid "display limited to %d rows" msgstr "отображение ограничено %d строками" @@ -817,11 +817,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:2758 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:1268 msgid "hostname lookup paused" msgstr "разрешение имён приостановлено" @@ -829,16 +829,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:1254 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:1695 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:1493 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1502 msgid "none" msgstr "нет" @@ -846,98 +846,98 @@ 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:1921 msgid "not AH" msgstr "не AH" -#: 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:1920 msgid "not ESP" msgstr "не ESP" -#: 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:1919 msgid "not GRE" msgstr "не GRE" -#: 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:1916 msgid "not ICMP" msgstr "не ICMP" -#: 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:1917 msgid "not ICMPv6" msgstr "не ICMPv6" -#: 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:1918 msgid "not IGMP" msgstr "не IGMP" -#: 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:1922 msgid "not SCTP" msgstr "не SCTP" -#: 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:1914 msgid "not TCP" msgstr "не TCP" -#: 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:1915 msgid "not UDP" msgstr "не UDP" -#: 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:2763 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:2762 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:2761 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:2764 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:2765 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:2780 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:2756 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:1266 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:2759 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:1270 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:1267 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:1261 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:2760 msgid "unknown" msgstr "неизвестно" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:749 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:750 msgid "using fw4" msgstr "используется fw4" diff --git a/luci-app-fwlive/po/templates/luci-app-fwlive.pot b/luci-app-fwlive/po/templates/luci-app-fwlive.pot index 0dae1cc12..f7f5e6984 100644 --- a/luci-app-fwlive/po/templates/luci-app-fwlive.pot +++ b/luci-app-fwlive/po/templates/luci-app-fwlive.pot @@ -1,58 +1,58 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1692 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1697 msgid "%d matching · %d/%d stored" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1657 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1662 msgid "%dd" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1656 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1661 msgid "%dh" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1655 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1660 msgid "%dm" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1654 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1659 msgid "%ds" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1419 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1423 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:1409 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1413 msgid "" "%s · requested up to %d raw lines · server limited fetch to %d · %d firewall " "messages returned" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:543 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:550 msgid "%s: %s" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1694 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1699 msgid "0 matching · %d/%d stored" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:452 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:453 msgid "Accessible (teal/orange)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:403 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:410 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:38 msgid "Action" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:945 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:946 msgid "Administrator access is required to disable logging." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:933 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:934 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:1894 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1906 msgid "Also seen" msgstr "" @@ -74,16 +74,16 @@ msgid "" "state." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2724 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2757 msgid "Any action" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1887 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1899 msgid "Any protocol" msgstr "" -#: 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 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1404 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1476 msgid "Auto" msgstr "" @@ -99,11 +99,11 @@ msgstr "" msgid "Changes:" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:451 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:452 msgid "Classic (green/red)" 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:2469 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:2796 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2829 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:2511 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2544 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:2516 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2549 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:1888 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1900 msgid "Common" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1705 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1710 msgid "Connection lost — retrying…" msgstr "" @@ -149,43 +149,43 @@ msgstr "" msgid "Could not clear the WAN zone log option." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:756 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:757 msgid "Could not create a temporary file for rule names." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:943 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:944 msgid "Could not disable logging." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:925 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:926 msgid "Could not enable logging." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:859 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:860 msgid "Could not load logging status." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:762 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:763 msgid "Could not load rule names." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:758 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:759 msgid "Could not process firewall rule names." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:760 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:761 msgid "Could not read the active firewall rules." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:858 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:859 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:767 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:768 msgid "Could not refresh rule names." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:765 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:766 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:2751 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2784 msgid "Custom protocol (prefix ! to exclude). Overrides the menu when set." msgstr "" @@ -213,28 +213,28 @@ msgstr "" msgid "DPort" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:407 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:414 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:46 msgid "Destination" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2781 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2814 msgid "Destination IP contains (! to exclude)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:409 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:416 msgid "Destination port" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2786 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2819 msgid "Destination port (! to exclude)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2639 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2672 msgid "Detail" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2592 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2625 msgid "Details" msgstr "" @@ -246,17 +246,17 @@ msgstr "" msgid "Disabling…" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1449 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1453 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:2377 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2410 msgid "Display options" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2470 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2503 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:2465 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2498 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:1901 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1913 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:2391 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2424 msgid "Fetch budget" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2480 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2513 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:2554 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2587 #: 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:1672 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1677 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:2490 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2523 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:2456 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2489 msgid "Help" msgstr "" -#: 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:1551 msgid "Hide rows" 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:1449 msgid "Hostname lookup paused while the router is under load." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1892 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1904 msgid "ICMPv6" msgstr "" @@ -367,7 +367,7 @@ msgstr "" msgid "IN" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2527 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2560 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,16 +394,16 @@ msgid "" "release, then reload the firewall." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1703 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1708 msgid "Installation is incomplete. Reinstall luci-app-fwlive." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:404 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:411 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:40 msgid "Interface" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2766 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2799 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:2380 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2413 msgid "Limit" 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:814 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:1400 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1473 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1404 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1477 msgid "Manual" 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:2442 msgid "Manual still uses server protection and poll cadence" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2485 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2518 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:2403 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2436 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:2651 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2658 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2684 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2691 msgid "Message" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2761 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2794 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:2681 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2714 msgid "One line" msgstr "" @@ -522,34 +522,34 @@ msgstr "" msgid "Outbound" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2430 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2463 msgid "Palette" msgstr "" -#: 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 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1771 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2638 msgid "Pause" msgstr "" -#: 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:1770 msgid "Paused" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1432 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1436 msgid "" "Polling slowed: the router is responding slowly, so polls run less often." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:405 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:412 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:44 msgid "Proto" 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:2775 msgid "Protocol — common values" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2721 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2754 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:1766 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1771 msgid "Resume" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2420 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2453 msgid "Row tint" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2521 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2554 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:2577 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2610 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:2592 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2625 msgid "Rule name details" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:784 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:785 msgid "Rule-name lookup does not change your firewall rules." 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:770 msgid "Rule-name lookup failed." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:776 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:777 msgid "Rule-name lookup reached a safety limit." msgstr "" @@ -602,103 +602,103 @@ msgstr "" msgid "SPort" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:402 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:409 msgid "Search" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1429 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1433 msgid "Server adaptive protection is disabled." 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:1441 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:1442 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1446 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:1427 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1431 msgid "Server protection state is unknown." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2447 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2480 msgid "Show hostnames" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2418 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2451 msgid "Show pass/deny row background colors" msgstr "" -#: 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 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1551 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2737 msgid "Show rows" 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:1511 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:2628 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2661 msgid "Simple" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:787 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:788 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:807 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:808 msgid "Some rule names may be missing" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:406 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:413 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:45 msgid "Source" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2771 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2804 msgid "Source IP contains (! to exclude)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:408 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:415 msgid "Source port" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2776 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2809 msgid "Source port (! to exclude)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1509 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1513 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:2695 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2728 msgid "Summary mode" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2475 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2508 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:1511 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1515 msgid "Summary was bounded before all categories could be included." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2495 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2528 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:1601 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1606 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,30 +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:2505 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2538 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:1704 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1709 msgid "The router could not read the firewall log — retrying…" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2500 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2533 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:2460 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2493 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:2580 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2613 msgid "Theme tint fallback" msgstr "" @@ -749,15 +749,15 @@ msgstr "" msgid "Time" msgstr "" -#: 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:1520 msgid "Top drops" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1519 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1523 msgid "Top rules" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1513 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1517 msgid "Top talkers" msgstr "" @@ -780,16 +780,16 @@ msgstr "" msgid "Undo:" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2524 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2557 msgid "Use Detail for all columns (flags, length, raw message)." 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:2616 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2643 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2649 msgid "View" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:944 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:945 msgid "WAN drop/reject logging is off." msgstr "" @@ -799,13 +799,13 @@ msgid "" "here. Normal LAN browsing will not." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:929 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:930 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:931 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:932 msgid "WAN logging is already enabled." msgstr "" @@ -834,25 +834,25 @@ msgstr "" msgid "Waiting for firewall events" msgstr "" -#: 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 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1770 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2601 msgid "Watching" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2670 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2703 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:2726 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2759 msgid "block" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1251 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1255 msgid "buffer filling" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1255 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1259 msgid "buffer full" msgstr "" @@ -864,7 +864,7 @@ msgstr "" msgid "default 10/minute" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1260 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1264 msgid "display limited to %d rows" msgstr "" @@ -873,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:2727 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2760 msgid "drop" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1264 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1268 msgid "hostname lookup paused" msgstr "" @@ -885,16 +885,16 @@ msgstr "" msgid "is" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1250 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1254 msgid "loading buffer" 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:1695 msgid "loading…" msgstr "" -#: 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 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1493 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1502 msgid "none" msgstr "" @@ -902,98 +902,98 @@ msgstr "" msgid "not" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1909 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1921 msgid "not AH" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1908 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1920 msgid "not ESP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1907 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1919 msgid "not GRE" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1904 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1916 msgid "not ICMP" 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:1917 msgid "not ICMPv6" 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:1918 msgid "not IGMP" 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:1922 msgid "not SCTP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1902 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1914 msgid "not TCP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1903 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1915 msgid "not UDP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2732 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2765 msgid "not block" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2731 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2764 msgid "not drop" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2730 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2763 msgid "not pass" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2733 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2766 msgid "not reject" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2734 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2767 msgid "not unknown" 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:2782 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:2725 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2758 msgid "pass" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1262 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1266 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:2728 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2761 msgid "reject" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1266 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1270 msgid "scroll frozen — scroll to top to follow live" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1263 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1267 msgid "server limited fetch" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1257 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1261 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:2729 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2762 msgid "unknown" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:749 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:750 msgid "using fw4" 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 29aa62a8f..128f3510a 100644 --- a/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po +++ b/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po @@ -12,31 +12,31 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: 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:1697 msgid "%d matching · %d/%d stored" msgstr "%d 条匹配 · 已存 %d/%d" -#: 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:1662 msgid "%dd" msgstr "%d天" -#: 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:1661 msgid "%dh" msgstr "%d小时" -#: 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:1660 msgid "%dm" msgstr "%d分钟" -#: 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:1659 msgid "%ds" msgstr "%d秒" -#: 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:1423 msgid "%s · requested up to %d raw lines · %d firewall messages returned" msgstr "%s · 请求最多 %d 行原始日志 · 返回 %d 条防火墙消息" -#: 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:1413 msgid "%s · requested up to %d raw lines · server limited fetch to %d · %d firewall messages returned" msgstr "%s · 请求最多 %d 行原始日志 · 服务器将抓取限制为 %d · 返回 %d 条防火墙消息" @@ -44,11 +44,11 @@ msgstr "%s · 请求最多 %d 行原始日志 · 服务器将抓取限制为 %d msgid "%s: %s" msgstr "%s: %s" -#: 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:1699 msgid "0 matching · %d/%d stored" msgstr "0 条匹配 · 已存 %d/%d" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:452 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:453 msgid "Accessible (teal/orange)" msgstr "无障碍(青绿/橙色)" @@ -57,11 +57,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:946 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:934 msgid "Administrator access is required to enable logging." msgstr "需要管理员权限才能启用日志记录。" @@ -69,7 +69,7 @@ msgstr "需要管理员权限才能启用日志记录。" msgid "Allow/deny rules, LAN logging, or anything else." msgstr "允许/拒绝规则、LAN 日志记录或任何其他设置。" -#: 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:1906 msgid "Also seen" msgstr "其他常见" @@ -81,16 +81,16 @@ msgstr "防火墙另有未提交的更改;请先应用或还原。" msgid "Another change overwrote WAN logging after it was saved; check the current state." msgstr "保存后另有更改覆盖了 WAN 日志设置;请检查当前状态。" -#: 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:2755 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:1899 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:1404 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1476 msgid "Auto" msgstr "自动" @@ -106,11 +106,11 @@ msgstr "无法启用日志记录,直到内核日志模块已安装。" msgid "Changes:" msgstr "变更:" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:451 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:452 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:2467 msgid "Classic uses green/red; Accessible uses teal/orange" msgstr "经典使用绿/红;无障碍使用青绿/橙色" @@ -118,11 +118,11 @@ 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:2827 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 "点击单元格筛选 · 芯片上的 ≠ 排除 · Ctrl+点击规则打开防火墙设置 · 在简单视图中点击行查看完整消息" -#: 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:2542 msgid "Click a row (Time or other non-link cells) to see the full log line (Simple view)." msgstr "点击一行(时间或其他非链接单元格)可查看完整日志行(简单视图)。" @@ -130,15 +130,15 @@ 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:2547 msgid "Click an IP, action, or protocol to filter; use the Protocol menu (or ≠ on a chip) to exclude." msgstr "点击 IP、动作或协议进行筛选;使用协议菜单(或筛选标签上的 ≠)进行排除。" -#: 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:1900 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:1710 msgid "Connection lost — retrying…" msgstr "连接已断开 — 正在重试…" @@ -150,43 +150,43 @@ msgstr "无法获取日志记录锁。" msgid "Could not clear the WAN zone log option." msgstr "无法清除 WAN 区域日志选项。" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:756 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:757 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:944 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:926 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:860 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:763 msgid "Could not load rule names." msgstr "无法加载规则名称。" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:758 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:759 msgid "Could not process firewall rule names." msgstr "无法处理防火墙规则名称。" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:760 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:761 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:859 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:768 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:766 msgid "Could not refresh rule names; previously loaded names are still shown." msgstr "无法刷新规则名称;仍显示之前加载的名称。" @@ -206,7 +206,7 @@ msgstr "无法安全跟踪日志记录的更改;日志记录未更改。" msgid "Could not write the WAN zone log option." msgstr "无法写入 WAN 区域日志选项。" -#: 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:2782 msgid "Custom protocol (prefix ! to exclude). Overrides the menu when set." msgstr "自定义协议(! 排除)。有内容时覆盖菜单。" @@ -219,7 +219,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:2812 msgid "Destination IP contains (! to exclude)" msgstr "目的 IP 包含(! 表示排除)" @@ -227,15 +227,15 @@ msgstr "目的 IP 包含(! 表示排除)" 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:2817 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:2670 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:2623 msgid "Details" msgstr "详情" @@ -247,15 +247,15 @@ 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:1453 msgid "Display limited to %d rows on this device; the larger Limit still buffers rows." msgstr "显示上限为 %d 行(本设备);较大的行数限制仍会缓冲这些行。" -#: 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:2408 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:2501 msgid "Display options set Limit, Fetch budget, Maximum raw lines, row tint, palette, and hostnames." msgstr "显示选项可设置行数限制、抓取配额、最大原始行数、行着色、配色和主机名。" @@ -275,7 +275,7 @@ msgstr "启用 WAN 区域的丢弃/拒绝日志记录(同 网络 → 防火墙 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:2496 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." msgstr "启用日志仅开启 WAN 区域的丢弃/拒绝日志记录(同 网络 → 防火墙),不会添加规则,也不会记录正常的 LAN 浏览。" @@ -283,7 +283,7 @@ msgstr "启用日志仅开启 WAN 区域的丢弃/拒绝日志记录(同 网 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:1913 msgid "Exclude" msgstr "排除" @@ -291,11 +291,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:2422 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:2511 msgid "Fetch budget controls raw log lines per poll. Auto derives from Limit; Manual selects a bounded maximum." msgstr "抓取配额控制每次轮询的原始日志行数。「自动」由行数限制推导;「手动」可选择一个有上限的最大值。" @@ -309,12 +309,12 @@ msgstr "按 %s 筛选" msgid "Filter logs by rule (hint: %s). Ctrl+click to open firewall settings." msgstr "按规则筛选日志(提示:%s)。Ctrl+点击可打开防火墙设置。" -#: 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:2585 #: 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:1677 msgid "Firewall log has no current entries; showing rows last received %s ago." msgstr "防火墙日志当前没有条目;显示的是 %s 前收到的日志行。" @@ -326,7 +326,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:2521 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." msgstr "要让弱设备上的表格保持响应,请将 行数限制 保持在 250 行或更少。弱设备限制影响渲染的行数;缓冲区仍可保留更多行。" @@ -338,19 +338,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:2487 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:1551 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:1449 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:1904 msgid "ICMPv6" msgstr "ICMPv6" @@ -358,7 +358,7 @@ msgstr "ICMPv6" msgid "IN" msgstr "IN" -#: 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:2558 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 data leaves the device)." msgstr "如果行着色看起来缺失,当前 LuCI 主题可能未提供成功/错误或信息/警告 CSS 变量;fwlive 会回退到本地颜色(隔离运行,数据不会离开本设备)。" @@ -378,7 +378,7 @@ msgstr "改为包含" msgid "Install kmod-nf-log and kmod-nf-log6 with the command for your OpenWrt release, then reload the firewall." msgstr "请使用适用于您 OpenWrt 版本的命令安装 kmod-nf-log 和 kmod-nf-log6,然后重新加载防火墙。" -#: 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:1708 msgid "Installation is incomplete. Reinstall luci-app-fwlive." msgstr "安装不完整。请重新安装 luci-app-fwlive。" @@ -387,7 +387,7 @@ msgstr "安装不完整。请重新安装 luci-app-fwlive。" 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:2797 msgid "Interface (prefix ! to exclude)" msgstr "接口(前缀 ! 表示排除)" @@ -404,11 +404,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:2411 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:814 msgid "Live view may be incomplete — a legacy iptables table is registered" msgstr "实时视图可能不完整 — 已注册旧版 iptables 表" @@ -416,16 +416,16 @@ msgstr "实时视图可能不完整 — 已注册旧版 iptables 表" 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:1404 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1477 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:2440 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:2516 msgid "Manual still uses server protection and poll cadence; it changes the fetch budget, not the polling interval." msgstr "手动模式仍受服务器保护和轮询节奏约束;它改变的是抓取配额,而不是轮询间隔。" @@ -433,18 +433,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:2434 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:2682 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2689 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:2792 msgid "More filters" msgstr "更多筛选" @@ -476,7 +476,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:2712 msgid "One line" msgstr "单行" @@ -500,20 +500,20 @@ msgstr "开启日志记录之前,OpenWrt 不会将防火墙事件写入日志 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:2461 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:1771 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2636 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:1770 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:1436 msgid "Polling slowed: the router is responding slowly, so polls run less often." msgstr "轮询已放慢:路由器响应较慢,因此降低轮询频率。" @@ -522,11 +522,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:2773 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:2752 msgid "Quick search" msgstr "快速搜索" @@ -534,19 +534,19 @@ 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:1771 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:2451 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:2552 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:2608 msgid "Row tint used a local color fallback because the active LuCI theme did not apply pass/deny backgrounds." msgstr "由于当前 LuCI 主题未应用放行/拒绝背景色,行着色使用了本地颜色回退。" @@ -554,19 +554,19 @@ msgstr "由于当前 LuCI 主题未应用放行/拒绝背景色,行着色使 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:2623 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:785 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:770 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:777 msgid "Rule-name lookup reached a safety limit." msgstr "规则名称查询已达到安全限制。" @@ -578,48 +578,48 @@ 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:1433 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:1441 msgid "Server limited fetch: the router is under load; at most %d log lines per poll." msgstr "服务器已限制抓取:路由器负载较高;每次轮询最多 %d 行日志。" -#: 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:1446 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:1431 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:2478 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:2449 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:1551 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2735 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:1511 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:2659 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:788 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:808 msgid "Some rule names may be missing" msgstr "部分规则名称可能缺失" @@ -628,7 +628,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:2802 msgid "Source IP contains (! to exclude)" msgstr "源 IP 包含(! 表示排除)" @@ -636,31 +636,31 @@ msgstr "源 IP 包含(! 表示排除)" 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:2807 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:1513 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:2726 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:2506 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:1515 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:2526 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:1606 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 automatically." msgstr "表格刷新已限流:事件速率较高,表格重绘频率降低以保护浏览器。缓冲区仍在更新;刷新将自动恢复。" @@ -668,23 +668,23 @@ 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:2536 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:1722 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1709 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:2531 msgid "The router log ring may evict older events before fwlive reads them. fwlive cannot recover evicted entries or change forwarding behavior." msgstr "路由器日志环形缓冲区可能在 fwlive 读取前淘汰较旧事件。fwlive 无法恢复已淘汰的条目,也不会改变转发行为。" -#: 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:2491 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:2611 msgid "Theme tint fallback" msgstr "主题着色回退" @@ -700,15 +700,15 @@ msgstr "此路由器报告了 Live View 尚无法识别的日志阻止项。" 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:1520 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:1523 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:1517 msgid "Top talkers" msgstr "主要通信方" @@ -728,16 +728,16 @@ msgstr "开启 WAN 防火墙区域的日志记录并重新加载防火墙。" 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:2555 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:2641 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2647 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:945 msgid "WAN drop/reject logging is off." msgstr "WAN 丢弃/拒绝日志记录已关闭。" @@ -745,11 +745,11 @@ msgstr "WAN 丢弃/拒绝日志记录已关闭。" msgid "WAN drop/reject logging is on. Blocked inbound WAN traffic will show up here. Normal LAN browsing will not." msgstr "WAN 丢弃/拒绝日志记录已开启。被阻止的 WAN 入站流量会显示在这里;正常的 LAN 浏览不会。" -#: 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:930 msgid "WAN drop/reject logging is on. Blocked inbound traffic should appear here as it happens — not normal LAN browsing." msgstr "WAN 丢弃/拒绝日志记录已开启。被阻止的入站流量会实时显示在这里 — 正常的 LAN 浏览不会。" -#: 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:932 msgid "WAN logging is already enabled." msgstr "WAN 日志记录已处于启用状态。" @@ -778,25 +778,25 @@ msgstr "WAN 日志不可用:无 WAN 区域" 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:1770 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2599 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:2701 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:2757 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:1255 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:1259 msgid "buffer full" msgstr "缓冲区已满" @@ -808,7 +808,7 @@ msgstr "包含" msgid "default 10/minute" msgstr "默认 10 条/分钟" -#: 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:1264 msgid "display limited to %d rows" msgstr "显示上限为 %d 行" @@ -817,11 +817,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:2758 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:1268 msgid "hostname lookup paused" msgstr "主机名解析已暂停" @@ -829,16 +829,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:1254 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:1695 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:1493 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1502 msgid "none" msgstr "无" @@ -846,98 +846,98 @@ 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:1921 msgid "not AH" msgstr "非 AH" -#: 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:1920 msgid "not ESP" msgstr "非 ESP" -#: 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:1919 msgid "not GRE" msgstr "非 GRE" -#: 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:1916 msgid "not ICMP" msgstr "非 ICMP" -#: 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:1917 msgid "not ICMPv6" msgstr "非 ICMPv6" -#: 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:1918 msgid "not IGMP" msgstr "非 IGMP" -#: 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:1922 msgid "not SCTP" msgstr "非 SCTP" -#: 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:1914 msgid "not TCP" msgstr "非 TCP" -#: 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:1915 msgid "not UDP" msgstr "非 UDP" -#: 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:2763 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:2762 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:2761 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:2764 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:2765 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:2780 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:2756 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:1266 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:2759 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:1270 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:1267 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:1261 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:2760 msgid "unknown" msgstr "未知" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:749 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:750 msgid "using fw4" msgstr "使用 fw4" diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh index 94e6ae8c8..3429c0609 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh +++ b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh @@ -168,11 +168,18 @@ acquire_wan_log_lock() { if [ -z "${FWLIVE_WAN_LOG_LOCK_FILE:-}" ]; then wan_log_lock_dir_safe "$lock_dir" || return 1 fi + # An existing FIFO would block even the create/tighten append below. + # Permit an absent path so normal first-use creation still works. + if [ -e "$WAN_LOG_LOCK_FILE" ] && [ ! -f "$WAN_LOG_LOCK_FILE" ]; then + return 1 + fi ( umask 077; : >> "$WAN_LOG_LOCK_FILE" ) 2>/dev/null || return 1 + [ -f "$WAN_LOG_LOCK_FILE" ] || return 1 [ -L "$WAN_LOG_LOCK_FILE" ] && return 1 chmod 0600 "$WAN_LOG_LOCK_FILE" 2>/dev/null || true chown 0:0 "$WAN_LOG_LOCK_FILE" 2>/dev/null || true [ -L "$WAN_LOG_LOCK_FILE" ] && return 1 + [ -f "$WAN_LOG_LOCK_FILE" ] || return 1 # Probe in a subshell first: a failed `exec` redirection aborts a POSIX # non-interactive shell outright, so `|| return 1` on the real exec would # never run — and `2>/dev/null` on the same exec would permanently diff --git a/luci-app-homeproxy-pro/.gitignore b/luci-app-homeproxy-pro/.gitignore index c48d3917c..f66562172 100644 --- a/luci-app-homeproxy-pro/.gitignore +++ b/luci-app-homeproxy-pro/.gitignore @@ -1,4 +1,5 @@ .DS_Store /homeproxy-pro /docs/ +/demo/ __pycache__/ diff --git a/luci-app-homeproxy-pro/Makefile b/luci-app-homeproxy-pro/Makefile index 0dba15c52..1182ac49d 100644 --- a/luci-app-homeproxy-pro/Makefile +++ b/luci-app-homeproxy-pro/Makefile @@ -22,8 +22,8 @@ LUCI_DEPENDS:= \ +ucode-mod-digest PKG_NAME:=luci-app-homeproxy-pro -PKG_VERSION:=28.9.1.14 -PKG_RELEASE:=14 +PKG_VERSION:=28.10.1.14 +PKG_RELEASE:=15 # 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 7cd12bbc7..7ccab198f 100644 --- a/luci-app-homeproxy-pro/README.md +++ b/luci-app-homeproxy-pro/README.md @@ -15,11 +15,10 @@ ## 运行要求 -- **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。 +- **ImmortalWrt ≥ 25.12**(`apk` 或 `opkg` 均可安装)。 - sing-box ≥ 1.14.0 是硬要求;低于 1.14 时服务拒绝启动并记录明确日志。 -- 启动时会检查 ucode 是否提供 `fs.mkdtemp`,不满足则拒绝启动并记录明确日志(`hp_require_ucode()`,与 `hp_require_singbox()` 并列),而不是让用户看到一段模块加载堆栈。 -## 按功能性对比 +## 与上游 szwjp/luci-app-homeproxy-pro 对比 ### 代码组织 @@ -70,9 +69,6 @@ | 协议增改成本 | 改 110 行 ternary(README 旧行) | 改 5 个表行:`model.uc CREDENTIALS` + `loader.uc PROTOCOL_OPTIONS` + `adapter.uc OPTION_FIELDS` + `fixtures/` + golden snapshot;arch-guard 5 强制 fixtures 同步 | `config/{model,loader,adapter}.uc` + `tests/fixtures/generators/` | | 资源更新 | jsdelivr 单一镜像 | 4 镜像 fallback(`fastly.jsdelivr.net` / `gcore.jsdelivr.net` / `cdn.jsdelivr.net` / `raw.githubusercontent.com`)+ UI「上次成功时间」(arch-guard 17 / 18 锁) | `runtime/dns.sh` + arch-guard 17 / 18 | | ECH 上传 | 后端 case 缺失 | 补齐 `client_ech_conf` 标签 + `isValidECHConfig()` PEM 校验;ACL 显式列 4 个 tmp 路径 | `homeproxy-pro.uc:isValidECHConfig` + `luci.homeproxy-pro:certificate_write` | -| 默认测试主机 | 硬编码作者内网 IP | `HP_TEST_HOST` 注入,未设时 fail-fast;`on-target.yml` ;ssh 前 `ssh -o ConnectTimeout=10` 先确认可达(不要从记忆里写地址) | `tests/run.sh` + `.github/workflows/on-target.yml` | -| 文档 | 标准(README + CONTRIBUTING + SECURITY) | 精简(README only),`docs/` 自 2026-09-16 起只留本地(untrack + `.gitignore`),远端公开仓库不再发布 | `README.md` + `docs/`(本地) | -| 路由 ops | — | 替换文件只动 JS / menu / acl / rpcd ucode(reload 不影响网络);更深层改动前 `cp /etc/config/homeproxy-pro /tmp/hp-r-preflight-/`,保留上一发版 .apk 24h;`killall -HUP rpcd` 由 apk scripts 自动做 | `runtime/{config,service}.sh` | **一句话总结**:pro 的核心价值是**把"单文件能跑"变成"orchestrator + table-driven adapter + 可独立测试的模块"**,并把约束、质量、回滚三件事从靠人盯变成靠代码执行(arch-guard 135 checks 静态锁住跨文件不变量)。 diff --git a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js index e30b3793d..b9f23caef 100644 --- a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js +++ b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js @@ -83,12 +83,21 @@ function getConnStat(o, site) { return hp.rpcCall('connection_check', [site], { params: ['site'], expect: { '': {} } }).then((ret) => { let ele = o.default.firstElementChild.nextElementSibling; + /* The address family comes back with the verdict rather than + * being read here: form.Map has no formvalue() in this LuCI + * (an earlier revision called it and threw), and the backend + * is the only layer that knows which family it probed. With + * IPv6 support on, "passed" now means the IPv6 path works, + * and a failure says the node cannot carry IPv6 - which is + * the truth, not a wget retry-order artefact. IPv4/IPv6 are + * protocol names, so they are appended untranslated. */ + let fam = ret.family ? ' (' + ret.family + ')' : ''; if (ret.result) { ele.style.setProperty('color', 'green'); - ele.innerHTML = _('passed'); + ele.innerHTML = _('passed') + fam; } else { ele.style.setProperty('color', 'red'); - ele.innerHTML = _('failed'); + ele.innerHTML = _('failed') + fam; } }); }) diff --git a/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot b/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot index 416b7985b..2b1e1d8f4 100644 --- a/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot +++ b/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot @@ -1,7 +1,7 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:222 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:231 msgid "%s log" msgstr "" @@ -13,11 +13,11 @@ msgstr "" msgid "(empty)" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:110 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:119 msgid "(last updated %s)" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:111 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:120 msgid "(never updated on this device)" msgstr "" @@ -181,11 +181,11 @@ msgstr "" msgid "Allowed payload size is in the request." msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:129 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:138 msgid "Already at the latest version." msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:126 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:135 msgid "Already in updating." msgstr "" @@ -276,7 +276,7 @@ msgstr "" msgid "BBR profile (1.14)" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:252 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:261 msgid "BaiDu" msgstr "" @@ -358,11 +358,11 @@ msgstr "" msgid "Certificate path" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:95 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:104 msgid "Check" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:139 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:148 msgid "Check update" msgstr "" @@ -370,15 +370,15 @@ msgstr "" msgid "China DNS server" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:261 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:270 msgid "China IPv4 list version" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:265 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:274 msgid "China IPv6 list version" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:269 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:278 msgid "China list version" msgstr "" @@ -394,7 +394,7 @@ msgstr "" msgid "Cisco Public DNS (208.67.222.222)" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:231 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:240 msgid "Clean log" msgstr "" @@ -420,7 +420,7 @@ msgstr "" #: htdocs/luci-static/resources/view/homeproxy-pro/client.js:130 #: htdocs/luci-static/resources/view/homeproxy-pro/server.js:146 -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:212 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:221 msgid "Collecting data..." msgstr "" @@ -432,7 +432,7 @@ msgstr "" msgid "Congestion control algorithm" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:249 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:258 msgid "Connection check" msgstr "" @@ -494,7 +494,7 @@ msgstr "" msgid "DTLS" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:179 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:188 msgid "Debug" msgstr "" @@ -856,7 +856,7 @@ msgstr "" msgid "Endpoint independent" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:182 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:191 msgid "Error" msgstr "" @@ -945,7 +945,7 @@ msgstr "" msgid "Failed to upload %s, error: %s." msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:183 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:192 msgid "Fatal" msgstr "" @@ -999,7 +999,7 @@ msgstr "" msgid "GET" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:273 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:282 msgid "GFW list version" msgstr "" @@ -1029,7 +1029,7 @@ msgstr "" msgid "Generate" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:277 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:286 msgid "GitHub token" msgstr "" @@ -1057,7 +1057,7 @@ msgstr "" msgid "Global settings" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:255 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:264 msgid "Google" msgstr "" @@ -1133,7 +1133,7 @@ msgstr "" #: htdocs/luci-static/resources/view/homeproxy-pro/client.js:70 #: htdocs/luci-static/resources/view/homeproxy-pro/client.js:119 -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:299 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:308 #: root/usr/share/luci/menu.d/luci-app-homeproxy-pro.json:3 msgid "HomeProxy" msgstr "" @@ -1303,7 +1303,7 @@ msgid "" "seconds." msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:180 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:189 msgid "Info" msgstr "" @@ -1423,7 +1423,7 @@ msgstr "" msgid "Listen port" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:210 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:219 msgid "Loading" msgstr "" @@ -1896,7 +1896,7 @@ msgstr "" msgid "Padding scheme" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:184 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:193 msgid "Panic" msgstr "" @@ -2147,7 +2147,7 @@ msgstr "" msgid "Redirect TCP + Tun UDP" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:236 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:245 msgid "Refresh every %s seconds." msgstr "" @@ -2221,7 +2221,7 @@ msgid "" "that lookup on this router." msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:258 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:267 msgid "Resources management" msgstr "" @@ -2346,8 +2346,8 @@ msgstr "" msgid "Same as main node" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:284 -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:290 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:293 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:299 msgid "Save" msgstr "" @@ -2555,7 +2555,7 @@ msgstr "" msgid "Successfully imported %s nodes of total %s." msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:120 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:129 msgid "Successfully updated." msgstr "" @@ -2901,7 +2901,7 @@ msgid "" "kmod-tun" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:178 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:187 msgid "Trace" msgstr "" @@ -2995,7 +2995,7 @@ msgstr "" msgid "UUID" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:132 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:141 msgid "Unknown error." msgstr "" @@ -3015,7 +3015,7 @@ msgstr "" msgid "Update %s subscriptions" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:123 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:132 msgid "Update failed." msgstr "" @@ -3129,7 +3129,7 @@ msgstr "" msgid "WAN IP Policy" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:181 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:190 msgid "Warn" msgstr "" @@ -3194,7 +3194,7 @@ msgstr "" msgid "default" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:91 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:100 msgid "failed" msgstr "" @@ -3243,7 +3243,7 @@ msgstr "" msgid "packet addr (v2ray-core v5+)" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:88 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:97 msgid "passed" msgstr "" @@ -3255,7 +3255,7 @@ msgstr "" msgid "quic-go / uquic chrome" msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:302 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:311 msgid "sing-box client" msgstr "" @@ -3277,7 +3277,7 @@ msgid "" "(Password above) must be 12-255 bytes for v6." msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:305 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:314 msgid "sing-box server" msgstr "" @@ -3291,7 +3291,7 @@ msgid "" "resistance." msgstr "" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:97 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:106 msgid "unchecked" 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 ae14c0fd0..950f50ffb 100644 --- a/luci-app-homeproxy-pro/po/zh_Hans/homeproxy-pro.po +++ b/luci-app-homeproxy-pro/po/zh_Hans/homeproxy-pro.po @@ -8,7 +8,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Transfer-Encoding: 8bit\n" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:222 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:231 msgid "%s log" msgstr "%s 日志" @@ -20,11 +20,11 @@ msgstr "移除了 %s 个节点" msgid "(empty)" msgstr "(空)" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:110 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:119 msgid "(last updated %s)" msgstr "(最后更新 %s)" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:111 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:120 msgid "(never updated on this device)" msgstr "(本设备从未更新)" @@ -191,11 +191,11 @@ msgstr "从订阅获取节点时,默认允许不安全连接。" msgid "Allowed payload size is in the request." msgstr "请求中允许的载荷大小。" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:129 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:138 msgid "Already at the latest version." msgstr "已是最新版本。" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:126 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:135 msgid "Already in updating." msgstr "已在更新中。" @@ -289,7 +289,7 @@ msgstr "BBR 拥塞控制算法档位。仅 Hysteria2。" msgid "BBR profile (1.14)" msgstr "BBR 档位(1.14)" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:252 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:261 msgid "BaiDu" msgstr "百度" @@ -371,11 +371,11 @@ msgstr "取消" msgid "Certificate path" msgstr "证书路径" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:95 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:104 msgid "Check" msgstr "检查" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:139 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:148 msgid "Check update" msgstr "检查更新" @@ -383,15 +383,15 @@ msgstr "检查更新" msgid "China DNS server" msgstr "国内 DNS 服务器" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:261 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:270 msgid "China IPv4 list version" msgstr "国内 IPv4 库版本" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:265 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:274 msgid "China IPv6 list version" msgstr "国内 IPv6 库版本" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:269 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:278 msgid "China list version" msgstr "国内域名列表版本" @@ -407,7 +407,7 @@ msgstr "密码套件" msgid "Cisco Public DNS (208.67.222.222)" msgstr "思科公共 DNS(208.67.222.222)" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:231 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:240 msgid "Clean log" msgstr "清空日志" @@ -433,7 +433,7 @@ msgstr "Cloudflare" #: htdocs/luci-static/resources/view/homeproxy-pro/client.js:130 #: htdocs/luci-static/resources/view/homeproxy-pro/server.js:146 -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:212 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:221 msgid "Collecting data..." msgstr "正在收集数据中..." @@ -445,7 +445,7 @@ msgstr "仅常用端口(绕过 P2P 流量)" msgid "Congestion control algorithm" msgstr "拥塞控制算法" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:249 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:258 msgid "Connection check" msgstr "连接检查" @@ -507,7 +507,7 @@ msgstr "DNS01 验证" msgid "DTLS" msgstr "DTLS" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:179 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:188 msgid "Debug" msgstr "调试" @@ -884,7 +884,7 @@ msgstr "加密方式" msgid "Endpoint independent" msgstr "端点无关" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:182 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:191 msgid "Error" msgstr "错误" @@ -974,7 +974,7 @@ msgstr "读取域名列表失败:%s。" msgid "Failed to upload %s, error: %s." msgstr "上传 %s 失败,错误:%s。" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:183 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:192 msgid "Fatal" msgstr "致命" @@ -1028,7 +1028,7 @@ msgstr "分片回退延迟" msgid "GET" msgstr "GET" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:273 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:282 msgid "GFW list version" msgstr "GFW 域名列表版本" @@ -1058,7 +1058,7 @@ msgstr "Gecko(1.14)" msgid "Generate" msgstr "生成" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:277 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:286 msgid "GitHub token" msgstr "GitHub 令牌" @@ -1086,7 +1086,7 @@ msgstr "全局代理 MAC 地址" msgid "Global settings" msgstr "全局设置" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:255 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:264 msgid "Google" msgstr "谷歌" @@ -1162,7 +1162,7 @@ msgstr "劫持" #: htdocs/luci-static/resources/view/homeproxy-pro/client.js:70 #: htdocs/luci-static/resources/view/homeproxy-pro/client.js:119 -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:299 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:308 #: root/usr/share/luci/menu.d/luci-app-homeproxy-pro.json:3 msgid "HomeProxy" msgstr "HomeProxy" @@ -1337,7 +1337,7 @@ msgid "" "seconds." msgstr "在检查中,关闭空闲时间超过此值的会话(单位:秒)。" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:180 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:189 msgid "Info" msgstr "信息" @@ -1461,7 +1461,7 @@ msgstr "监听接口" msgid "Listen port" msgstr "监听端口" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:210 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:219 msgid "Loading" msgstr "加载中" @@ -1941,7 +1941,7 @@ msgstr "数据包编码" msgid "Padding scheme" msgstr "填充方案" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:184 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:193 msgid "Panic" msgstr "崩溃" @@ -2192,7 +2192,7 @@ msgstr "Redirect TCP + TProxy UDP" msgid "Redirect TCP + Tun UDP" msgstr "Redirect TCP + Tun UDP" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:236 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:245 msgid "Refresh every %s seconds." msgstr "每 %s 秒刷新。" @@ -2268,14 +2268,14 @@ msgid "" "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/" +"解析走中国大陆路径的域名,所以必须是国内服务,才能拿到正确的本地 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 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:267 msgid "Resources management" msgstr "资源管理" @@ -2400,8 +2400,8 @@ msgstr "Salamander" msgid "Same as main node" msgstr "保持与主节点一致" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:284 -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:290 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:293 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:299 msgid "Save" msgstr "保存" @@ -2623,7 +2623,7 @@ msgstr "订阅" msgid "Successfully imported %s nodes of total %s." msgstr "成功导入 %s 个节点,共 %s 个。" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:120 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:129 msgid "Successfully updated." msgstr "更新成功。" @@ -2998,7 +2998,7 @@ msgid "" msgstr "" "要启用 Tun 支持,你需要安装 ip-full 和 kmod-tun。" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:178 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:187 msgid "Trace" msgstr "跟踪" @@ -3092,7 +3092,7 @@ msgstr "URLTest 节点" msgid "UUID" msgstr "UUID" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:132 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:141 msgid "Unknown error." msgstr "未知错误。" @@ -3112,7 +3112,7 @@ msgstr "不支持的指纹!" msgid "Update %s subscriptions" msgstr "更新 %s 个订阅" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:123 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:132 msgid "Update failed." msgstr "更新失败。" @@ -3228,7 +3228,7 @@ msgstr "WAN DNS(从接口获取)" msgid "WAN IP Policy" msgstr "WAN IP 策略" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:181 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:190 msgid "Warn" msgstr "警告" @@ -3294,7 +3294,7 @@ msgstr "主动连接 UDP" msgid "default" msgstr "默认" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:91 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:100 msgid "failed" msgstr "失败" @@ -3343,7 +3343,7 @@ msgstr "无" msgid "packet addr (v2ray-core v5+)" msgstr "packet addr (v2ray-core v5+)" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:88 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:97 msgid "passed" msgstr "通过" @@ -3355,7 +3355,7 @@ msgstr "私钥" msgid "quic-go / uquic chrome" msgstr "quic-go / uquic chrome" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:302 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:311 msgid "sing-box client" msgstr "sing-box 客户端" @@ -3383,7 +3383,7 @@ msgstr "" "sing-box 将 Snell v4/v5 线协议统一为 v4,另提供 v6;v6 的预共享密钥(上方密" "码)长度须为 12-255 字节。" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:305 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:314 msgid "sing-box server" msgstr "sing-box 服务端" @@ -3399,7 +3399,7 @@ msgstr "" "uTLS 是 “crypto/tls” 的一个分支,可提供 ClientHello 指纹识别抵抗能力。uTLS " "是 \"crypto/tls\" 的一个分支,拥有抵抗 ClientHello 指纹识别的能力。" -#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:97 +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:106 msgid "unchecked" msgstr "未检查" @@ -3510,6 +3510,12 @@ msgstr "使用额外标签时 URL 需包含 {tag}" msgid "{tag} in initial path when extra tags are used" msgstr "使用额外标签时初始路径需包含 {tag}" +#~ msgid "BaiDu (%s)" +#~ msgstr "百度 (%s)" + +#~ msgid "Google (%s)" +#~ msgstr "谷歌 (%s)" + #~ msgid "Bootstrap DNS server" #~ msgstr "引导 DNS 服务器" diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/firewall_post.ut b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/firewall_post.ut index 7bd2c53ba..648610034 100755 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/firewall_post.ut +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/firewall_post.ut @@ -165,6 +165,58 @@ if (routing_port === 'common') const proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy', ipv6_support = uci.get(cfgname, 'config', 'ipv6_support') || '0'; +/* --- can IPv6 be classified as mainland at all? ------------------------ + * + * Every IPv6 decision in this template used to hang off ipv6_support + * alone. That flag only says whether the user *wants* IPv6 handled; it + * says nothing about whether we can tell a mainland IPv6 destination from + * a foreign one. That test is the homeproxy_mainland_addr_v6 set, and the + * set is filled straight from resources/china_ip6.txt - so an install whose + * list is missing, empty or entirely malformed produced a *syntactically + * valid but permanently empty* set. `ip6 daddr @homeproxy_mainland_addr_v6 + * counter return` then matched nothing, and every mainland IPv6 connection + * fell through to the proxy hop at the bottom of the chain: a silent, + * unlogged inversion of the mode's promise, and the reason a user who + * enables IPv6 finds Chinese sites taking the long way round. + * + * nft happily accepts `elements = { }`, so nothing downstream could notice. + * The count is taken here, once, with the same isValidCIDR() the set + * rendering uses - a list of entries this function rejects would render an + * empty set too, so counting something else would not be the same test. + * + * v6_handled is what every v6 branch below now keys off. When the list is + * unusable it is false, and the ruleset degrades to exactly the IPv6-off + * shape: IPv6 is not intercepted, not marked, not redirected. That is a + * deliberate choice of failure direction. The alternative - keep + * intercepting and proxy everything - trades a wrong-but-working path for + * a leaked one, and the user cannot see either from the client. Passing + * IPv6 through unproxied is the state the router was in before this package + * touched it, it is recoverable (fix the list, reload), and it is announced: + * see the comment on the dstnat jump below and the warning + * hp_prepare_runtime_files() logs on every start. */ +let cn_ipv6_count = 0; +for (let entry in split(trim(readfile(resources_dir + '/china_ip6.txt')), /[\r\n]/)) + if (isValidCIDR(entry, 6)) + cn_ipv6_count++; + +const cn_ipv6_ready = (cn_ipv6_count > 0); +const v6_handled = (ipv6_support === '1') && cn_ipv6_ready; + +/* The degraded-state warning, as a ready-made nft comment suffix. + * + * A real `comment` rather than a `#` line on purpose: fw4 keeps `#` lines + * only in the generated file, while a rule comment is part of the rule and + * survives into `nft list ruleset` - which is where someone debugging "why + * is my IPv6 not going through the proxy" actually looks. + * + * Computed once and interpolated into all three jump rules rather than + * wrapped around each of them: the rule text is identical either way, only + * the comment differs, and one string is one place to change if the wording + * ever has to. */ +const v6_warn_comment = ((ipv6_support === '1') && !cn_ipv6_ready) + ? ' comment "!homeproxy-pro: WARNING china_ip6.txt has 0 usable prefixes - IPv6 is NOT proxied, run update_resources.sh china_ip6"' + : ''; + let self_mark, redirect_port, tproxy_port, tproxy_mark, tun_name, tun_mark; @@ -232,7 +284,7 @@ set homeproxy_local_addr_v4 { 240.0.0.0/4 } } -{% if (ipv6_support === '1'): %} +{% if (v6_handled): %} set homeproxy_local_addr_v6 { type ipv6_addr flags interval @@ -261,13 +313,13 @@ set homeproxy_gfw_list_v4 { flags interval auto-merge } -{% if (ipv6_support === '1'): %} +{% if (v6_handled): %} set homeproxy_gfw_list_v6 { type ipv6_addr flags interval auto-merge } -{% endif /* ipv6_support */ %} +{% endif /* v6_handled */ %} {% elif (match(routing_mode, /mainland_china/) || bypass_cn_traffic === '1'): %} set homeproxy_mainland_addr_v4 { type ipv4_addr @@ -281,7 +333,7 @@ set homeproxy_mainland_addr_v4 { {% endfor %} } } -{% if ((ipv6_support === '1') || china_dns_server): %} +{% if (v6_handled): %} set homeproxy_mainland_addr_v6 { type ipv6_addr flags interval @@ -294,7 +346,7 @@ set homeproxy_mainland_addr_v6 { {% endfor %} } } -{% endif /* ipv6_support */ %} +{% endif /* v6_handled */ %} {% endif /* routing_mode */ %} {# WAN ACL addresses #} @@ -307,7 +359,7 @@ set homeproxy_wan_proxy_addr_v4 { {% endif %} } -{% if (ipv6_support === '1'): %} +{% if (v6_handled): %} set homeproxy_wan_proxy_addr_v6 { type ipv6_addr flags interval @@ -316,7 +368,7 @@ set homeproxy_wan_proxy_addr_v6 { elements = { {{ ipv6_to_nftarr(control_info.wan_proxy_ipv6_ips) }} } {% endif /* wan_proxy_ipv6_ips*/ %} } -{% endif /* ipv6_support */ %} +{% endif /* v6_handled */ %} set homeproxy_wan_direct_addr_v4 { type ipv4_addr @@ -327,7 +379,7 @@ set homeproxy_wan_direct_addr_v4 { {% endif %} } -{% if (ipv6_support === '1'): %} +{% if (v6_handled): %} set homeproxy_wan_direct_addr_v6 { type ipv6_addr flags interval @@ -336,7 +388,7 @@ set homeproxy_wan_direct_addr_v6 { elements = { {{ ipv6_to_nftarr(control_info.wan_direct_ipv6_ips) }} } {% endif /* wan_direct_ipv6_ips */ %} } -{% endif /* ipv6_support */ %} +{% endif /* v6_handled */ %} {% if (routing_port): %} set homeproxy_routing_port { @@ -356,7 +408,7 @@ chain dstnat { meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dnsmasq_port }} comment "!{{ cfgname }}: DNS hijack" {% endif /* dns_hijacked */ %} {% if (match(proxy_mode, /redirect/)): %} - meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect_lanac + meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect_lanac{{ v6_warn_comment }} {% endif /* proxy_mode */ %} } @@ -410,12 +462,12 @@ chain homeproxy_redirect { meta mark {{ self_mark }} counter return ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_redirect_proxy_port - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_redirect_proxy_port {% endif %} ip daddr @homeproxy_local_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_local_addr_v6 counter return {% endif %} @@ -432,25 +484,25 @@ chain homeproxy_redirect { {% endif /* routing_mode */ %} ip daddr @homeproxy_wan_direct_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_wan_direct_addr_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %} ip daddr != @homeproxy_gfw_list_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr != @homeproxy_gfw_list_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %} ip daddr @homeproxy_mainland_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_mainland_addr_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% elif (routing_mode === 'proxy_mainland_china'): %} ip daddr != @homeproxy_mainland_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr != @homeproxy_mainland_addr_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} {% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %} @@ -468,7 +520,7 @@ chain homeproxy_redirect { chain homeproxy_output_redir { type nat hook output priority filter -105; policy accept - meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect + meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect } {% endif %} @@ -476,7 +528,7 @@ chain homeproxy_output_redir { {% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom')): %} chain homeproxy_mangle_tproxy { meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip to 127.0.0.1:{{ tproxy_port }} counter accept - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip6 to [::1]:{{ tproxy_port }} counter accept {% endif %} } @@ -531,12 +583,12 @@ chain homeproxy_mangle_lanac { chain homeproxy_mangle_prerouting { ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tproxy_port - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tproxy_port {% endif %} ip daddr @homeproxy_local_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_local_addr_v6 counter return {% endif %} @@ -553,29 +605,29 @@ chain homeproxy_mangle_prerouting { {% endif /* routing_mode */ %} ip daddr @homeproxy_wan_direct_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_wan_direct_addr_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %} ip daddr != @homeproxy_gfw_list_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr != @homeproxy_gfw_list_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)" {% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %} ip daddr @homeproxy_mainland_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_mainland_addr_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% if (routing_mode !== 'custom'): %} udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)" {% endif /* routing_mode */ %} {% elif (routing_mode === 'proxy_mainland_china'): %} ip daddr != @homeproxy_mainland_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr != @homeproxy_mainland_addr_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} {% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %} @@ -595,46 +647,46 @@ chain homeproxy_mangle_output { meta mark {{ self_mark }} counter return ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_mark - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_mark {% endif %} ip daddr @homeproxy_local_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_local_addr_v6 counter return {% endif %} ip daddr @homeproxy_wan_direct_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_wan_direct_addr_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %} ip daddr != @homeproxy_gfw_list_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr != @homeproxy_gfw_list_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %} ip daddr @homeproxy_mainland_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_mainland_addr_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% elif (routing_mode === 'proxy_mainland_china'): %} ip daddr != @homeproxy_mainland_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr != @homeproxy_mainland_addr_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} counter goto homeproxy_mangle_mark } chain mangle_prerouting { - meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_lanac + meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_lanac{{ v6_warn_comment }} } chain mangle_output { - meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_output + meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_output } {% endif %} @@ -690,12 +742,12 @@ chain homeproxy_mangle_tun { iifname {{ tun_name }} counter return ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tun_mark - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tun_mark {% endif %} ip daddr @homeproxy_local_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_local_addr_v6 counter return {% endif %} @@ -720,23 +772,23 @@ chain homeproxy_mangle_tun { {% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %} ip daddr != @homeproxy_gfw_list_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr != @homeproxy_gfw_list_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)" {% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %} ip daddr @homeproxy_mainland_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr @homeproxy_mainland_addr_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% if (routing_mode !== 'custom'): %} udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)" {% endif /* routing_mode */ %} {% elif (routing_mode === 'proxy_mainland_china'): %} ip daddr != @homeproxy_mainland_addr_v4 counter return - {% if (ipv6_support === '1'): %} + {% if (v6_handled): %} ip6 daddr != @homeproxy_mainland_addr_v6 counter return - {% endif /* ipv6_support */ %} + {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} {% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %} @@ -753,10 +805,10 @@ chain homeproxy_mangle_tun { } chain mangle_prerouting { - meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_lanac + meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_lanac{{ v6_warn_comment }} } chain mangle_output { - meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_tun + meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_tun } {% endif %} diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generate_client.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generate_client.uc index d1bae8a85..d2ff61612 100755 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generate_client.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generate_client.uc @@ -25,7 +25,7 @@ 'use strict'; import { connect } from 'ubus'; -import { mkdtemp, readfile, writefile } from 'fs'; +import { lstat, mkdtemp, readfile, writefile } from 'fs'; import { Loader } from './config/loader.uc'; import { generate } from './generator/client.uc'; @@ -52,7 +52,36 @@ function resolve_env(dm) { const env = { wan_dns: (ubus?.call('network.interface', 'status', {'interface': 'wan'}))?.['dns-server']?.[0], direct_domain_list: [], - proxy_domain_list: [] + proxy_domain_list: [], + /* Whether the generated mainland rule-set for IPv6 is on disk. + * + * generator/ may not stat anything (guard 27), and this is exactly + * the kind of environment a generator is supposed to be handed: + * geoip-cn.srs and china_ip4.json carry no IPv6, so china_ip6.json - + * written by hp_prepare_runtime_files from the list the resource + * updater maintains - is the only thing that lets the route and DNS + * halves recognise a mainland IPv6 destination. When it is absent + * (the list is missing, or had no usable prefix when the file was + * last written) both halves have to stay silent about IPv6 instead + * of naming a rule-set that is not there: a `rule_set:` pointing at + * a missing file makes sing-box reject the entire config, which the + * health gate turns into a rollback and an unproxied network. The + * firewall has already degraded to passing IPv6 through, with a + * warning in the ruleset and the log. + * + * lstat(), not the two-argument access(). fs.access() on this ucode + * build answers only in its one-argument form: access(path) returns + * true for an existing path and null for a missing one, while + * access(path, mode) returns null either way. The two-argument form + * is therefore the worst possible choice here - it reports every + * file as missing, so this flag would be permanently false and the + * whole IPv6 split would stay switched off with nothing logged. The + * one-argument call sites elsewhere in the tree (homeproxy-pro.uc's + * cleanup_exec_dir) are correct and must stay as they are. + * lstat() is used because it is unambiguous - there is no second + * argument to get wrong - and because the neighbouring stderr-size + * check in homeproxy-pro.uc already reads sizes through it. */ + china_ip6_ready: lstat(HP_DIR + '/resources/china_ip6.json') !== null }; if (routing_mode !== 'custom') { diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/context.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/context.uc index 9fadeeff8..f2fefd639 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/context.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/context.uc @@ -131,6 +131,13 @@ export function build_context(dm, env) { const ctx = { routing_mode, proxy_mode, ipv6_support, log_level, proxy_fallback, + /* Resolved by the caller, which is the only layer allowed to look at + * the filesystem (guard 27). The route rule and the DNS cn-fallback + * match must agree on it: they reference the same generated rule-set, + * and one of them naming a tag the other never declared fails the + * whole config. Default false - an absent env must not turn into an + * optimistic "assume the file is there". */ + china_ip6_ready: (env?.china_ip6_ready === true), self_mark, ntp_server, dns_port, mixed_port, redirect_port, tproxy_port, tun_name, tun_addr4, tun_addr6, tun_mtu, 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 fc9695339..4018f95e9 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 @@ -291,9 +291,24 @@ function append_proxy_dns(config, dm, ctx) { server: 'main-dns', tag: 'cn-fallback' }); + /* The response is matched by the addresses the upstream returned, + * and geoip-cn has no IPv6 in it, so with IPv6 support on a + * mainland domain that resolved to an AAAA address was never + * recognised as mainland and its answer kept coming from the + * proxy resolver. Adding the v6 rule-set here is what makes the + * DNS half of the split agree with the route half, which + * classifies the same destinations through the same list + * (route.uc: china-ip6). Both tags have to be declared before + * this rule runs, which is why the list is built here rather than + * inlined: a rule_set naming an undeclared tag fails the config. + * china-ip6 is only declared when its file exists, so it is only + * named here under the same condition. */ + const cn_fallback_sets = ['geoip-cn']; + if (ctx.ipv6_support === '1' && ctx.china_ip6_ready) + push(cn_fallback_sets, 'china-ip6'); push(config.dns.rules, { match_response: 'cn-fallback', - rule_set: 'geoip-cn', + rule_set: cn_fallback_sets, action: 'route', server: 'china-dns' }); diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/route.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/route.uc index 65319325d..4bf2430d0 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/route.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/route.uc @@ -28,7 +28,6 @@ import { isEmpty, strToInt, strToTime, strToBool, parse_port, HP_DIR } from '../homeproxy-pro.uc'; - import { get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc'; /* --- shared initial block (every routing mode) ------------------------- */ @@ -136,6 +135,25 @@ function build_route_proxy(config, dm, ctx, direct_overrides) { action: 'route', outbound: 'direct-out' }); + + /* IPv6 half of the same split, and the reason a mainland site opened + * over IPv6 used to go through the proxy even with IPv6 support on. + * geoip-cn.srs carries no IPv6 and china_ip4.json is generated from an + * IPv4 list, so a destination that resolved to an AAAA address matched + * neither and fell through to `final` - which in this mode is main-out. + * The rule is emitted in both mainland modes (unlike china-ip, which + * proxy_mainland_china does not need twice because geoip-cn already + * picks the mainland side there): with no IPv6 in geoip-cn there is + * nothing left to pick it, so the side has to be named explicitly. + * `prefer_ipv4` on the resolve rule above is what lets both families + * through - it biases, it does not filter. */ + if (ctx.ipv6_support === '1' && ctx.china_ip6_ready) { + push(config.route.rules, { + rule_set: 'china-ip6', + action: 'route', + outbound: ctx.proxy_fallback ? 'direct-out' : 'main-out' + }); + } } /* Main UDP out */ @@ -250,15 +268,27 @@ function build_route_proxy(config, dm, ctx, direct_overrides) { * from, so the two cannot drift apart. `type: local` is watched by * sing-box with fswatch, so when the resource updater replaces the * file the running instance reloads it in place - no restart and, - * more importantly, no second copy of the list to keep in sync. - * China IPv6 is deliberately absent: the firewall only fills the v6 - * set when ipv6_support is on, and the client refuses to resolve - * AAAA when it is off. */ + * more importantly, no second copy of the list to keep in sync. */ push(config.route.rule_set, { type: 'local', tag: 'china-ip', path: HP_DIR + '/resources/china_ip4.json' }); + + /* The IPv6 counterpart, from china_ip6.txt - the same list the + * firewall renders homeproxy_mainland_addr_v6 from, and the only + * IPv6 mainland data either side of the split has. Declared only + * when the file is actually there: a rule_set pointing at a missing + * file takes the whole config down with it, and a router whose v6 + * list is unusable must still start (the firewall has already + * degraded to passing IPv6 through, with a warning). */ + if (ctx.ipv6_support === '1' && ctx.china_ip6_ready) { + push(config.route.rule_set, { + type: 'local', + tag: 'china-ip6', + path: HP_DIR + '/resources/china_ip6.json' + }); + } } if (isEmpty(config.route.rule_set)) 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 9ef74a560..6b9c9a6f8 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 @@ -166,7 +166,7 @@ hp_prepare_runtime_files() { local client_enabled="$4" local server_enabled="$5" - # The route rule that keeps mainland destinations direct matches a local + # The route rules that keep mainland destinations direct match a local # rule-set generated from china_ip4.txt, the same list the firewall # renders its nft set from, so both sides decide from one source. It is # regenerated here as well as by the resource updater: an install that @@ -184,6 +184,40 @@ hp_prepare_runtime_files() { fi fi + # IPv6 half of the same arrangement, and the only place the "IPv6 is on + # but cannot be classified" condition is reported in a place a user reads + # without tcpdump. geoip-cn.srs and china_ip4.json are both IPv4-only, so + # without a v6 rule-set a mainland destination reached over IPv6 matches + # no route rule at all and falls through to `final` - i.e. to the proxy. + # The firewall would normally have returned it first, but that set is + # filled from the same china_ip6.txt, so a list that is missing or has no + # usable prefix leaves neither side able to classify it. Say so plainly + # instead: the generated ruleset also carries the same text as a rule + # comment, so `nft list ruleset` shows the degraded state too. + local ipv6_support cn_ipv6 + config_get_bool ipv6_support "config" "ipv6_support" "0" + cn_ipv6=0 + if [ -f "$hp_dir/resources/china_ip6.txt" ]; then + cn_ipv6="$(grep -cE '^[0-9a-fA-F:]+:[0-9a-fA-F:]*(/[0-9]{1,3})?[[:space:]]*$' \ + "$hp_dir/resources/china_ip6.txt" 2>"/dev/null")" + [ -n "$cn_ipv6" ] || cn_ipv6=0 + if [ "$cn_ipv6" -gt 0 ] && ucode -S "$hp_dir/scripts/runtime/china_ip_ruleset.uc" \ + "$hp_dir/resources/china_ip6.txt" "$hp_dir/resources/china_ip6.json" >>"$LOG_PATH" 2>&1; then + chown sing-box:sing-box "$hp_dir/resources/china_ip6.json" 2>"/dev/null" \ + || log "Warning: failed to hand ${hp_dir}/resources/china_ip6.json to sing-box." + else + # Only reached when the source is unusable, which china_ip_ruleset.uc + # reports as "no usable CIDR entries". Keep the previous file: an + # empty one would make the route-side set match nothing, which is the + # same silent inversion this whole branch exists to prevent. + log "Warning: could not generate ${hp_dir}/resources/china_ip6.json; the route side keeps the previous list." + fi + fi + if [ "$ipv6_support" -eq 1 ] && [ "$cn_ipv6" -eq 0 ]; then + log "WARNING: IPv6 support is ON but ${hp_dir}/resources/china_ip6.txt has 0 usable prefixes." + log "WARNING: mainland IPv6 cannot be told apart from foreign IPv6, so IPv6 is passed through UNPROXIED (both sides of the split degrade). Run: ${hp_dir}/scripts/update_resources.sh china_ip6" + fi + # cache_file is enabled for bypass_mainland_china and custom alike # (generator/common.uc:attachExperimental). The file has to exist before # the client jail binds it, or sing-box cannot create it in there. diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_resources.sh b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_resources.sh index 03e51f88f..ddf769fb0 100755 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_resources.sh +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_resources.sh @@ -201,15 +201,27 @@ check_list_update() { # not fatal: the previous rule-set is still valid and the firewall # has already switched to the new list, which is exactly the state # the next run (or the next service start) repairs. - if [ "$listtype" = "china_ip4" ]; then + # + # china_ip6 is in the same boat for the same reason: geoip-cn.srs and + # china_ip4.json carry no IPv6 at all, so a mainland destination + # reached over v6 matches no route rule and falls through to `final` + # - the proxy - unless there is a v6 rule-set of our own. + case "$listtype" in + "china_ip4"|"china_ip6") if ucode -S "$SCRIPT_DIR/runtime/china_ip_ruleset.uc" \ - "$RESOURCES_DIR/china_ip4.txt" "$RESOURCES_DIR/china_ip4.json" >>"$LOG_PATH" 2>&1; then - log "[CHINA_IP4] Route-side rule-set regenerated." - chown sing-box:sing-box "$RESOURCES_DIR/china_ip4.json" 2>"/dev/null" + "$RESOURCES_DIR/$listtype.txt" "$RESOURCES_DIR/$listtype.json" >>"$LOG_PATH" 2>&1; then + log "[$(to_upper "$listtype")] Route-side rule-set regenerated." + chown sing-box:sing-box "$RESOURCES_DIR/$listtype.json" 2>"/dev/null" else - log "[CHINA_IP4] Warning: could not regenerate china_ip4.json; the route side keeps the previous list." + # The helper exits non-zero on an empty or fully malformed + # list, and deliberately leaves the previous .json in place. + # Writing an empty one would be worse than not writing any: + # the rule would then match nothing, which is the silent + # "mainland IPv6 goes through the proxy" inversion. + log "[$(to_upper "$listtype")] Warning: could not regenerate $listtype.json (list has no usable CIDR entry?); the route side keeps the previous list." fi - fi + ;; + esac else rm -f "$RUN_DIR/$listname" log "[$(to_upper "$listtype")] Failed to install update (mv failed)." diff --git a/luci-app-homeproxy-pro/root/usr/share/rpcd/ucode/luci.homeproxy-pro b/luci-app-homeproxy-pro/root/usr/share/rpcd/ucode/luci.homeproxy-pro index c0896a492..d1974599d 100644 --- a/luci-app-homeproxy-pro/root/usr/share/rpcd/ucode/luci.homeproxy-pro +++ b/luci-app-homeproxy-pro/root/usr/share/rpcd/ucode/luci.homeproxy-pro @@ -8,6 +8,7 @@ 'use strict'; import { access, error, lstat, popen, readfile, writefile } from 'fs'; +import { cursor } from 'uci'; import { isBinary, isValidECHConfig, isValidPEM, shellQuote, executeCommand } from '/etc/homeproxy-pro/scripts/homeproxy-pro.uc'; import { parse_uri } from '/etc/homeproxy-pro/scripts/parser/uri.uc'; @@ -207,7 +208,34 @@ const methods = { break; } - return { result: (system(`/usr/bin/wget --spider -qT3 ${shellQuote(url)} 2>"/dev/null"`, 3100) === 0) }; + /* Probe the address family the configuration actually has. + * + * busybox wget has no Happy Eyeballs: it walks the resolved + * addresses one at a time, and `-T3` is spent on each. A target + * with seven AAAA records therefore takes over twenty seconds to + * fail, against the 3100 ms budget of the system() call below - + * so the button reported "failed" for Google while the same + * request over IPv4 succeeded, and Baidu passed only because its + * IPv6 goes out direct. The answer was about wget's retry order, + * not about whether the proxy worked. + * + * Forcing the family makes the result mean what the label says. + * With IPv6 support on this is a real IPv6 test: it fails when the + * node cannot carry IPv6 egress, which is true information rather + * than a false negative, and it is the same condition a LAN client + * sees. With IPv6 support off, DNS hands out no AAAA at all, so + * -4 states explicitly what used to happen by accident. */ + const uci = cursor(); + const family = ((uci.get('homeproxy-pro', 'config', 'ipv6_support') || '0') === '1') ? 'IPv6' : 'IPv4'; + + return { + result: (system(`/usr/bin/wget -${(family === 'IPv6') ? '6' : '4'} --spider -qT3 ${shellQuote(url)} 2>"/dev/null"`, 3100) === 0), + /* The backend already knows which family it probed, and it is the + * only layer that can know without the view re-reading UCI. The + * view shows this next to the verdict, which is where a "passed" + * finally means something checkable. */ + family: family + }; } }, diff --git a/luci-app-homeproxy-pro/tests/arch-guard.sh b/luci-app-homeproxy-pro/tests/arch-guard.sh index 6f1d32cd6..8eb6333c8 100755 --- a/luci-app-homeproxy-pro/tests/arch-guard.sh +++ b/luci-app-homeproxy-pro/tests/arch-guard.sh @@ -2132,6 +2132,213 @@ else pass "no view offers a bootstrap_dns field any more" fi +echo +echo "== guard 46: IPv6 is only handled when mainland IPv6 is classifiable ==" + +# issue #4 / the china_ip6 defect share one root cause: every IPv6 decision +# was keyed off ipv6_support, a flag that says what the user *wants*, not +# whether the two halves of the split can agree on what "mainland" means. +# geoip-cn.srs and china_ip4.json are IPv4-only, so with IPv6 on a mainland +# destination reached over IPv6 matched no rule on either side and fell +# through to the default - the proxy. The firewall half was worse than +# useless in the empty-list case: nft accepts `elements = { }`, so the +# mainland v6 return matched nothing while the file looked fine. +# +# The invariants, all of which had to be introduced together: +# a) the template derives v6_handled from the list, not from the flag +# b) the v6 route rule exists, so the route half can classify +# c) the DNS cn-fallback match names the same v6 rule-set +# d) both halves ask the *same* question, so neither can name a tag the +# other never declared +# e) the degraded state is announced, not silent + +# (a) The template must not branch on the raw flag anywhere. A single +# remaining `ipv6_support === '1'` gate is a v6 path that ignores whether the +# list can classify anything - the exact shape of the original defect. +# +# Scoped to template *tags* on purpose: the header legitimately tests the flag +# twice in plain code (defining v6_handled, and building the degraded-warning +# string), and a substring search over the whole file flags those too. What +# must not exist is a `{% ... %}` that branches on the raw flag. +leftover_v6_gates="$(grep -nE "\{%[^%]*ipv6_support[^%]*%\}" "$SCRIPTS/firewall_post.ut" || true)" +if [ -n "$leftover_v6_gates" ]; then + fail "firewall_post.ut still gates an IPv6 path on the raw ipv6_support flag:" + printf '%s\n' "$leftover_v6_gates" | sed 's/^/ /' +else + pass "no template tag in firewall_post.ut branches on the raw ipv6_support flag" +fi + +# v6_handled must be the conjunction, not a rename of the flag. +if grep -q "v6_handled = (ipv6_support === '1') && cn_ipv6_ready" "$SCRIPTS/firewall_post.ut"; then + pass "v6_handled requires both the flag and a usable china_ip6 list" +else + fail "v6_handled is no longer (ipv6_support === '1') && cn_ipv6_ready - a v6 path" + fail "can be re-enabled without the list that makes it correct" +fi + +# (b) The route half. Its absence is the silent proxy hop. +if grep -q "rule_set: 'china-ip6'" "$SCRIPTS/generator/route.uc"; then + pass "the route block carries a china-ip6 rule" +else + fail "no china-ip6 route rule - a mainland IPv6 destination matches nothing and" + fail "falls through to final, which is the proxy in bypass_mainland_china" +fi + +# (c) The DNS half, or the answer for a mainland AAAA keeps coming from the +# proxy resolver even though the route side would now send it direct. +cn_fb_block="$(sed -n '/cn_fallback/,/^ }/p' "$SCRIPTS/generator/dns.uc" || true)" +if printf '%s' "$cn_fb_block" | grep -q "china-ip6"; then + pass "the cn-fallback response match covers china-ip6 as well as geoip-cn" +else + fail "cn-fallback still matches geoip-cn only; the DNS half of the IPv6 split" + fail "is not in agreement with the route half" +fi + +# (d) One question, asked once, by the one layer allowed to ask it. The +# route rule and the DNS match must reference the same rule-set: if one of +# them decides the file is there and the other decides it is not, one names +# a tag the other never declared and sing-box rejects the whole config. The +# stat therefore belongs in the CLI's env (guard 27 forbids fs under +# generator/) and reaches both halves as one context field. +ctx_flag="$(grep -c 'china_ip6_ready' "$SCRIPTS/generator/context.uc")" +route_flag="$(grep -c 'ctx.china_ip6_ready' "$SCRIPTS/generator/route.uc")" +dns_flag="$(grep -c 'ctx.china_ip6_ready' "$SCRIPTS/generator/dns.uc")" +env_flag="$(grep -c 'china_ip6_ready' "$SCRIPTS/generate_client.uc")" +if [ "$ctx_flag" -ge 1 ] && [ "$route_flag" -ge 1 ] && [ "$dns_flag" -ge 1 ] && [ "$env_flag" -ge 1 ]; then + pass "one context field (china_ip6_ready) decides it for both halves, and the" + pass " CLI that is allowed to stat the file is the one that sets it" +else + fail "the route half ($route_flag), the DNS half ($dns_flag), the context ($ctx_flag)" + fail "and the CLI env ($env_flag) do not all read one china_ip6_ready flag - a" + fail "disagreement makes one of them name a rule-set the other never declared" +fi + +# The default must be pessimistic. An env that forgot the field (the server +# path, a future caller) must not turn "no file" into "assume there is one". +if grep -q "china_ip6_ready: (env?.china_ip6_ready === true)" "$SCRIPTS/generator/context.uc"; then + pass "china_ip6_ready defaults to false when the caller did not resolve it" +else + fail "china_ip6_ready is not defaulting to false - a caller that omits it would" + fail "emit a rule naming a rule-set file that may not exist" +fi + +# (e) The degraded state has to be visible without tcpdump: a log line on +# every start, and a rule comment that survives into `nft list ruleset`. +if grep -q "WARNING: IPv6 support is ON but" "$RUNTIME/service.sh"; then + pass "service.sh logs the unusable-china_ip6 state on every start" +else + fail "service.sh says nothing when IPv6 is on but china_ip6.txt is unusable -" + fail "the user is left with an unexplained, unproxied IPv6 path" +fi + +if grep -q 'comment "!homeproxy-pro: WARNING china_ip6' "$SCRIPTS/firewall_post.ut"; then + pass "the degraded state is also carried as an nft rule comment, so" + pass " nft list ruleset shows it without reading the log" +else + fail "the degraded state is not announced in the ruleset itself" +fi + +# (f) The degraded warning stays a single interpolated string. +# +# An earlier version of this guard banned `{% else %}` outright, on the +# strength of a render failure traced to "utpl cannot parse an else branch +# nested in the tproxy/tun chain blocks". That diagnosis was wrong and the +# ban with it: the failure only ever reproduced in stubbed copies of the +# template, never in the template itself - the else-form renders fine on the +# target, and else at top level, nested one level and nested two levels all +# render fine in isolation. The interpolation is kept because it is one +# string instead of three copies of a comment, not because else is banned. +# +# What is worth pinning is the thing that was actually verified: the warning +# has to be present on the jump rules, in a form that survives into +# `nft list ruleset`. +if grep -q "const v6_warn_comment" "$SCRIPTS/firewall_post.ut" && + [ "$(grep -c "{{ v6_warn_comment }}" "$SCRIPTS/firewall_post.ut")" -eq 3 ]; then + pass "the degraded warning is one string interpolated into all three jump rules" +else + fail "v6_warn_comment is gone or no longer used by all three jump rules - the" + fail "degraded state has no in-ruleset marker again" +fi + +echo +echo "== guard 47: fs.access() is only ever called with one argument ==" + +# Found on the target, not by reading: this ucode build's fs.access() answers +# in its one-argument form only. access(path) returns true for an existing +# path and null for a missing one; access(path, mode) returns null for both. +# A two-argument call therefore reports every file as missing, and the caller +# cannot tell that from "the file is not there" - the exact shape of a +# silently disabled feature. The IPv6 split shipped broken this way once: +# generate_client.uc asked access(path, 0) === 0, which is false even for a +# perfectly good china_ip6.json, so route/DNS stayed silent about mainland +# IPv6 while every log line said everything was fine. +# +# The one-argument call sites are correct and are not what this guards. +# Comment lines are filtered out the way guard 27 does it: this file, and the +# source it guards, both have to be able to *name* the bad form in prose. +bad_access="$(grep -rnE '\baccess\([^)]*,' --include='*.uc' "$SCRIPTS" 2>/dev/null | + grep -vE ':[0-9]+:[[:space:]]*(\*|/\*|//|#)' || true)" +if [ -z "$bad_access" ]; then + pass "no fs.access() call passes a second argument (the form that always says 'missing')" +else + fail "a two-argument fs.access() call reads as 'path does not exist' for every path:" + printf '%s\n' "$bad_access" | sed 's/^/ /' + fail " use the one-argument form, or lstat() (which the stderr-size check already uses)" +fi + +# And the reason this is worth a guard rather than a comment: the failure is +# invisible. Both spellings compile, both run, and the wrong one produces a +# correct-looking config that is quietly missing a whole feature. +if grep -q "lstat(HP_DIR + '/resources/china_ip6.json')" "$SCRIPTS/generate_client.uc"; then + pass "the china-ip6 presence check goes through lstat(), which cannot be mis-called" +else + fail "the china-ip6 presence check no longer uses lstat()" +fi + +echo +echo "== guard 48: the connection check probes the configured address family ==" + +# The button used to run `wget --spider` with no family, so its answer was +# about busybox wget's retry order rather than about the proxy. A target with +# seven AAAA records (Google) spends -T3 on each, so it blew the 3100 ms +# system() budget and the button said "failed" while the same request over +# IPv4 worked; Baidu passed only because its IPv6 goes out direct. Pin the two +# halves: the backend has to force the family, and the verdict has to say which +# one, or "passed" stays unfalsifiable. +if grep -qF "wget -\${(family === 'IPv6') ? '6' : '4'} --spider" "$RPC" && + grep -qF "uci.get('homeproxy-pro', 'config', 'ipv6_support')" "$RPC"; then + pass "connection_check forces the address family from homeproxy-pro.config.ipv6_support" +else + fail "connection_check does not force an address family - the result is whatever" + fail "busybox wget happens to try first, which is how a working proxy reads as failed" +fi + +if grep -qF "family: family" "$RPC"; then + pass "the probed family is returned so the view can report it" +else + fail "the response does not carry the family it probed, so the label cannot be" + fail "trusted - and the only way to get it in the view is to re-read UCI there" +fi + +# The view must take the family from that response. An earlier revision called +# form.Map.formvalue(), which does not exist in this LuCI: the status page +# threw "m.formvalue is not a function" on a real router, and it only reached +# CI because the stubs had been given the same invented method. The stubs must +# keep modelling what LuCI actually provides, so a call the device does not +# have fails here instead of on someone's router. +if grep -qF "let fam = ret.family ?" "$VIEWS/view/homeproxy-pro/status.js"; then + pass "the view reports the family the backend returned" +else + fail "the view no longer shows which family was probed" +fi + +if grep -rqE "\bm\.formvalue\(" "$VIEWS/view/homeproxy-pro/"; then + fail "a view calls form.Map.formvalue(), which this LuCI does not provide -" + fail " (formvalue lives on a section; the map has no such method)" +else + pass "no view calls the non-existent form.Map.formvalue()" +fi + echo printf '%s checks, %s failures\n' "$checks" "$([ "$FAILED" = 0 ] && echo 0 || echo 'nonzero')" if [ "$FAILED" != 0 ]; then 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 bec12979c..e7bee8375 100644 --- a/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt +++ b/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt @@ -217,6 +217,8 @@ procd_open_instance log-cleaner procd_set_param command etc/homeproxy-pro/scripts/clean_log.sh procd_set_param respawn procd_close_instance +log WARNING: IPv6 support is ON but etc/homeproxy-pro/resources/china_ip6.txt has 0 usable prefixes. +log WARNING: mainland IPv6 cannot be told apart from foreign IPv6, so IPv6 is passed through UNPROXIED (both sides of the split degrade). Run: etc/homeproxy-pro/scripts/update_resources.sh china_ip6 chown sing-box:sing-box etc/homeproxy-pro/certs chmod 700 etc/homeproxy-pro/certs chown sing-box:sing-box etc/homeproxy-pro/certs/server_privatekey.pem diff --git a/luci-app-homeproxy-pro/tests/ucode/test_generators.sh b/luci-app-homeproxy-pro/tests/ucode/test_generators.sh index 87cf6b6a9..38462e216 100755 --- a/luci-app-homeproxy-pro/tests/ucode/test_generators.sh +++ b/luci-app-homeproxy-pro/tests/ucode/test_generators.sh @@ -42,6 +42,14 @@ run_case() { # explicit than a near-copy fixture file, and the expression is visible at # the call site next to the assertion it serves. variation="${5:-}" + # 6th argument, "no-ipv6-ruleset": model an install whose china_ip6.txt + # yielded no usable prefix, so hp_prepare_runtime_files produced no + # rule-set. A positional argument rather than an environment variable + # because `VAR=x run_case ...` does not reliably scope the assignment to + # the function body across the shells this suite runs under (dash on CI + # kept the staged file, and the degraded case silently tested the normal + # one instead). + no_ipv6_ruleset="${6:-}" dir="$WORK/$name" rm -rf "$dir" @@ -62,6 +70,18 @@ run_case() { printf '{"version":3,"rules":[{"ip_cidr":["192.0.2.0/24"]}]}\n' > "$dir/resources/china_ip4.json" fi + # The IPv6 counterpart, staged by default because that is the normal + # install: the package ships china_ip6.txt and hp_prepare_runtime_files + # turns it into the rule-set both the route rule and the DNS cn-fallback + # match reference. See the 6th argument of run_case for the degraded case. + if [ "$no_ipv6_ruleset" != "no-ipv6-ruleset" ]; then + if ! ucode -S "$ROOT/root/etc/homeproxy-pro/scripts/runtime/china_ip_ruleset.uc" \ + "$ROOT/root/etc/homeproxy-pro/resources/china_ip6.txt" "$dir/resources/china_ip6.json" \ + >>"$dir/resources/china_ip6.log" 2>&1; then + printf '{"version":3,"rules":[{"ip_cidr":["2001:db8::/32"]}]}\n' > "$dir/resources/china_ip6.json" + fi + fi + if grep -q "__RULESET_DIR__" "$fixture"; then # The fixture needs a real local rule-set on disk. The path must # live under /tmp/homeproxy_* (validateHomeProxyPath() in @@ -674,12 +694,17 @@ import { readfile } from 'fs'; const config = json(readfile(ARGV[0])); const rules = config.dns?.rules || []; let has_eval_tag = false, has_match_response = false, geoip_ref = false; +/* rule_set is emitted as an array because the cn-fallback match names more + * than one rule-set when IPv6 support is on (geoip-cn + china-ip6). sing-box + * accepts a bare string for the single-tag case, so accept both shapes here + * rather than pinning the one this build happens to produce. */ +const names = (v) => (type(v) === 'array' ? v : [v]); for (let r in rules) { if (r.action === 'evaluate' && r.tag === 'cn-fallback') has_eval_tag = true; if (r.match_response === 'cn-fallback') { has_match_response = true; - if (r.rule_set === 'geoip-cn') + if (index(names(r.rule_set), 'geoip-cn') >= 0) geoip_ref = true; } } @@ -975,6 +1000,89 @@ else esac fi +# 4) IPv6 mainland split: with ipv6_support='1' the mainland destination +# has to be recognisable over IPv6, or it falls through to `final` +# (main-out) and a Chinese site opens through the proxy. geoip-cn.srs +# and china_ip4.json are both IPv4-only, so the v6 rule-set generated +# from china_ip6.txt is the only thing that can match. Asserted on both +# halves that have to agree: the route rule (which outbound) and the DNS +# cn-fallback match_response (which resolver). +run_case route-china-ip6 "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json \ + "s/option ipv6_support '0'/option ipv6_support '1'/" + +v6_json="$WORK/route-china-ip6/run/sing-box-c.json" +if [ ! -f "$v6_json" ]; then + echo "FAIL: route-china-ip6: no config was generated" + FAILED=1 +else + # The rule-set declaration: a local entry tagged china-ip6 pointing at the + # generated file. Its absence is the bug - a rule referencing an + # undeclared tag fails the whole config. + if ! grep -q '"tag": "china-ip6"' "$v6_json"; then + echo "FAIL: route-china-ip6: rule_set does not declare china-ip6; a mainland IPv6" + echo " destination would match no rule and fall through to final (the proxy)" + FAILED=1 + fi + + # The route rule, and the side it sends to. bypass_mainland_china means + # mainland -> direct-out; a rule pointing at main-out here would be the + # exact inversion this change exists to fix, so assert the outbound + # rather than the rule's presence. + v6_rule_out="$(grep -A 3 '"rule_set": "china-ip6"' "$v6_json" | grep '"outbound":' | head -1)" + case "$v6_rule_out" in + *'"outbound": "direct-out"'*) + echo "PASS: route-china-ip6: mainland IPv6 routes to direct-out in bypass_mainland_china" ;; + *) + echo "FAIL: route-china-ip6: the china-ip6 route rule does not send to direct-out" + echo " (got: ${v6_rule_out:-})" + FAILED=1 ;; + esac + + # The DNS half: the cn-fallback match_response rule must name china-ip6 + # too, or the answer for a mainland domain that resolved to AAAA keeps + # coming from the proxy resolver. ucode prints the array multi-line, so + # look at the rule body rather than for one line containing both tags. + v6_fb="$(sed -n '/"match_response": "cn-fallback"/,/}/p' "$v6_json")" + case "$v6_fb" in + *china-ip6*) + echo "PASS: route-china-ip6: the cn-fallback response match also covers china-ip6" ;; + *) + echo "FAIL: route-china-ip6: cn-fallback still matches geoip-cn only, so a mainland" + echo " AAAA answer is not re-resolved by china-dns" + FAILED=1 ;; + esac +fi + +# 5) The degraded path: ipv6_support='1' but no v6 rule-set on disk (the +# list yielded no usable prefix, so hp_prepare_runtime_files produced +# nothing). Both halves must then stay silent about IPv6 rather than +# name a rule-set that is not there - a `rule_set:` pointing at a missing +# file makes sing-box reject the whole config, which the health gate +# turns into a rollback and an unproxied network. The config still has +# to pass `sing-box check`, which run_case already asserted. +run_case route-china-ip6-missing "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json \ + "s/option ipv6_support '0'/option ipv6_support '1'/" no-ipv6-ruleset + +v6miss_json="$WORK/route-china-ip6-missing/run/sing-box-c.json" +if [ ! -f "$v6miss_json" ]; then + echo "FAIL: route-china-ip6-missing: no config was generated" + FAILED=1 +else + # Match the tag, not the bare string: this case's own directory is named + # "route-china-ip6-missing", so every emitted path in the config + # (data_directory, log output, ...) contains "china-ip6" and a substring + # search reports a reference that is not there. + if grep -qE '"(tag|rule_set)": "?\[\]?"?china-ip6' "$v6miss_json" || + grep -qE '"(tag|rule_set)": \[[^]]*china-ip6' "$v6miss_json"; then + echo "FAIL: route-china-ip6-missing: the config still references china-ip6 with no" + echo " rule-set file present; sing-box would refuse to start" + grep -nE '"(tag|rule_set)": .*china-ip6' "$v6miss_json" | sed 's/^/ /' + FAILED=1 + else + echo "PASS: route-china-ip6-missing: no china-ip6 reference without the file (degrades cleanly)" + fi +fi + # 3) P2 #4 / §2.2.1: the three NAPTR (qtype 35) bypass suffixes must be # emitted as a single domain_suffix entry backed by the # NAPTR_BYPASS_SUFFIXES constant. ucode's pretty-printer emits the diff --git a/luci-app-homeproxy/Makefile b/luci-app-homeproxy/Makefile index 1b881726e..98853e626 100644 --- a/luci-app-homeproxy/Makefile +++ b/luci-app-homeproxy/Makefile @@ -23,7 +23,7 @@ LUCI_DEPENDS:= \ PKG_NAME:=luci-app-homeproxy PKG_VERSION:=27.919.1.14 -PKG_RELEASE:=21 +PKG_RELEASE:=22 define Package/luci-app-homeproxy/conffiles /etc/config/homeproxy diff --git a/luci-app-homeproxy/README.md b/luci-app-homeproxy/README.md index 8c2eec1ab..f5f1b08bd 100644 --- a/luci-app-homeproxy/README.md +++ b/luci-app-homeproxy/README.md @@ -127,7 +127,7 @@ ## 运行要求 - ImmortalWrt / OpenWrt ≥ 24.10+(apk 或 opkg 均可安装) -- sing-box ≥ 1.14.0(ImmortalWrt 25.12 源对应 sing-box 1.14.0-r1) +- sing-box ≥ 1.14.0 - 低于 1.14 时服务会拒绝启动并记录明确日志 diff --git a/luci-app-homeproxy/root/etc/homeproxy/scripts/generate_client.uc b/luci-app-homeproxy/root/etc/homeproxy/scripts/generate_client.uc index cc1fbe7ce..b37298417 100755 --- a/luci-app-homeproxy/root/etc/homeproxy/scripts/generate_client.uc +++ b/luci-app-homeproxy/root/etc/homeproxy/scripts/generate_client.uc @@ -45,6 +45,19 @@ const uciruleset = 'ruleset'; const routing_mode = uci.get(uciconfig, ucimain, 'routing_mode') || 'bypass_mainland_china'; +/* + * Single source of truth for "which config branch are we generating". + * + * The three generation chains below (DNS, outbounds, route) must be selected by + * the routing mode, NOT by whether main_node / default_outbound happen to be + * set. Those two answer a different question: in custom mode default_outbound + * is legitimately 'nil' (every routing rule picks its own outbound, there is no + * default) and main_node is never read. Deriving the mode from them made a + * perfectly normal custom config fall through every branch and emit a config + * containing none of the user's DNS servers, nodes, rules or rule-sets. + */ +const is_custom_mode = (routing_mode === 'custom'); + let wan_dns = ubus.call('network.interface', 'status', {'interface': 'wan'})?.['dns-server']?.[0]; if (!wan_dns) wan_dns = (routing_mode in ['proxy_mainland_china', 'global']) ? '8.8.8.8' : '223.5.5.5'; @@ -598,7 +611,7 @@ if (!isEmpty(main_node)) { }); } } -} else if (!isEmpty(default_outbound)) { +} else if (is_custom_mode) { /* DNS servers */ uci.foreach(uciconfig, ucidnsserver, (cfg) => { if (cfg.enabled !== '1') @@ -888,7 +901,7 @@ if (!isEmpty(main_node)) { config.outbounds[length(config.outbounds)-1].tag = 'cfg-' + i + '-out'; } } -} else if (!isEmpty(default_outbound)) { +} else if (is_custom_mode) { let urltest_nodes = [], routing_nodes = []; @@ -949,6 +962,12 @@ if (isEmpty(config.endpoints)) /* Routing rules start */ /* Default settings */ +/* + * The sniff rule below deliberately carries no `timeout`. Reports from + * sing-box 1.14.2 show it rejected with "route.rules[1].timeout: json: unknown + * field". The official builds accept it, so the exact trigger is still open, + * but the default is already 300ms and omitting the field is behaviour-neutral. + */ config.route = { rules: [ { @@ -956,8 +975,7 @@ config.route = { action: 'hijack-dns' }, { - action: 'sniff', - timeout: '300ms' + action: 'sniff' } ], rule_set: [], @@ -1102,7 +1120,7 @@ if (!isEmpty(main_node)) { if (isEmpty(config.route.rule_set)) config.route.rule_set = null; -} else if (!isEmpty(default_outbound)) { +} else if (is_custom_mode) { config.route.default_domain_resolver = { server: get_resolver(default_outbound_dns) }; @@ -1239,6 +1257,21 @@ if (!isEmpty(main_node)) { }); } +/* + * Belt-and-braces for route.default_domain_resolver. + * + * sing-box 1.14 removed the legacy outbound DNS rules and refuses to start when + * a dial-fields user has a domain to resolve without a resolver. The exemption + * for "only one DNS server configured" never applies here: default-dns and + * system-dns are emitted unconditionally, so every config has at least two. + * That makes this a global requirement, not a per-mode one, so it must not + * depend on which branch above ran. The branches already pick the right + * resolver (the WAN resolver, or default_outbound_dns in custom mode); this + * only covers the degenerate "not custom mode and no main node" case. + */ +if (isEmpty(config.route.default_domain_resolver)) + config.route.default_domain_resolver = { server: 'default-dns' }; + /* Remote rule-sets download through the http_clients collected while the rule-sets were built (see http_client_for). */ if (length(http_clients)) diff --git a/luci-app-homeproxy/tests/fixtures/generators/custom-no-default-outbound.uci b/luci-app-homeproxy/tests/fixtures/generators/custom-no-default-outbound.uci new file mode 100644 index 000000000..20bc7d177 --- /dev/null +++ b/luci-app-homeproxy/tests/fixtures/generators/custom-no-default-outbound.uci @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Regression fixture for the custom routing mode WITHOUT a default outbound. +# +# This is the shape the LuCI UI produces by default in custom mode: every +# routing rule names its own outbound, so `default_outbound` is left at 'nil'. +# The generator used to select its config branch by asking whether +# default_outbound was non-empty, so this configuration fell through the DNS, +# outbound and route branches alike and produced a config containing none of +# the user's DNS servers, nodes, rules or rule-sets. With the two built-in +# DNS servers (default-dns / system-dns) still emitted, sing-box 1.14 then +# refused to start at all: +# +# ERROR missing `route.default_domain_resolver` or `domain_resolver` in +# dial fields ... FATAL ... sing-box 1.14.0 +# +# and generate_client.uc deleted the config instead of moving it into place, +# so the user got no sing-box.json and no log. + +config homeproxy 'config' + option routing_mode 'custom' + option proxy_mode 'redirect_tproxy' + option ipv6_support '0' + option log_level 'error' + +# No default outbound: the point of this fixture. Each routing_rule below +# picks its own. +config homeproxy 'routing' + option default_outbound 'nil' + option default_outbound_dns 'default-dns' + option sniff_override '1' + +config homeproxy 'dns' + option default_server 'default-dns' + +config homeproxy 'infra' + option mixed_port '5330' + option dns_port '5333' + option redirect_port '5331' + option tproxy_port '5332' + option self_mark '100' + option ntp_server 'nil' + +# A node addressed by domain, so the generated outbound carries dial fields +# that sing-box 1.14 requires a resolver for. +config node 'n_hk' + option label 'hk' + option type 'trojan' + option address 'hk.example.com' + option port '443' + option password 'secret' + option tls '1' + option tls_sni 'hk.example.com' + +config node 'n_sg' + option label 'sg' + option type 'vless' + option address 'sg.example.com' + option port '443' + option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339' + option tls '1' + option tls_sni 'sg.example.com' + +# urltest group: exercises the urltest_nodes back-fill loop as well +config routing_node 'Routing_Node_Proxy' + option enabled '1' + option label 'Routing_Node_Proxy' + option node 'urltest' + list urltest_nodes 'n_hk' + list urltest_nodes 'n_sg' + option urltest_url 'https://www.gstatic.com/generate_204' + option urltest_interval '10m' + +# A user-defined DNS server, resolved through the built-in system resolver. +config dns_server 'DNS_Server_Proxy' + option enabled '1' + option label 'DNS_Server_Proxy' + option type 'tls' + option server '8.8.8.8' + option address_resolver 'system-dns' + option address_strategy 'ipv4_only' + option outbound 'Routing_Node_Proxy' + +config dns_rule 'DNS_Rule_Proxy' + option enabled '1' + option label 'DNS_Rule_Proxy' + option mode 'default' + list domain_suffix 'dns-rule-marker.example.com' + list rule_set 'rs_cn' + option action 'route' + option server 'DNS_Server_Proxy' + +config ruleset 'rs_cn' + option enabled '1' + option label 'rs-cn' + option type 'remote' + option format 'binary' + option url 'https://example.com/geoip-cn.srs' + option update_interval '24h' + option outbound 'Routing_Node_Proxy' + +config routing_rule 'Routing_Rule_Proxy' + option enabled '1' + option label 'Routing_Rule_Proxy' + option mode 'default' + list domain_suffix 'routing-rule-marker.example.com' + list rule_set 'rs_cn' + option action 'route' + option outbound 'Routing_Node_Proxy' diff --git a/luci-app-homeproxy/tests/snapshots/client.generated.json b/luci-app-homeproxy/tests/snapshots/client.generated.json index 438629c8e..7924cda75 100644 --- a/luci-app-homeproxy/tests/snapshots/client.generated.json +++ b/luci-app-homeproxy/tests/snapshots/client.generated.json @@ -284,8 +284,7 @@ "action": "hijack-dns" }, { - "action": "sniff", - "timeout": "300ms" + "action": "sniff" }, { "action": "resolve", diff --git a/luci-app-homeproxy/tests/ucode/test_generators.sh b/luci-app-homeproxy/tests/ucode/test_generators.sh index 09dd30260..074ba6405 100755 --- a/luci-app-homeproxy/tests/ucode/test_generators.sh +++ b/luci-app-homeproxy/tests/ucode/test_generators.sh @@ -129,6 +129,40 @@ else fi run_case custom "$ROOT/tests/fixtures/generators/custom.uci" generate_client.uc sing-box-c.json +# Custom mode without a default outbound: the default shape of the custom UI. +# `sing-box check` alone is not enough -- before the fix the generator emitted a +# config with none of the user's DNS servers, nodes, rules or rule-sets, and +# sing-box 1.14 then refused to start it for a missing +# route.default_domain_resolver. Assert on the emitted config so a partial +# regression cannot pass as green. +run_case custom_no_default "$ROOT/tests/fixtures/generators/custom-no-default-outbound.uci" generate_client.uc sing-box-c.json + +CUSTOM_OUT="$WORK/custom_no_default/run/sing-box-c.json" + +assert_json() { + label="$1" + pattern="$2" + if grep -q -- "$pattern" "$CUSTOM_OUT"; then + echo "PASS: $label" + else + echo "FAIL: $label (no match for: $pattern)" + FAILED=1 + fi +} + +# sing-box always emits default-dns and system-dns, so more than one DNS server +# is always present and 1.14 requires a global domain resolver. +assert_json "custom/no-default: a global domain resolver is declared" '"default_domain_resolver"' +assert_json "custom/no-default: the user DNS server survived" '"cfg-DNS_Server_Proxy-dns"' +# A sing-box rule has no label field, so the UCI `option label` never reaches the +# JSON. Match the marker the rule body actually carries. +assert_json "custom/no-default: the user DNS rule survived" 'dns-rule-marker.example.com' +assert_json "custom/no-default: the urltest group survived" '"cfg-Routing_Node_Proxy-out"' +assert_json "custom/no-default: its member nodes survived" '"cfg-n_hk-out"' +assert_json "custom/no-default: the user rule-set survived" '"cfg-rs_cn-rule"' +assert_json "custom/no-default: the user routing rule survived" 'routing-rule-marker.example.com' +assert_json "custom/no-default: dns.final follows default_server" '"final": "default-dns"' + run_case server "$ROOT/tests/fixtures/generators/server.uci" generate_server.uc sing-box-s.json exit $FAILED diff --git a/luci-app-meow/Makefile b/luci-app-meow/Makefile index 0b7e40fd0..d984b72f1 100644 --- a/luci-app-meow/Makefile +++ b/luci-app-meow/Makefile @@ -1,7 +1,13 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-meow -PKG_VERSION?=0.21.2 + +# Keep in sync with meow/Makefile. +PKG_BASE_VERSION?=0.21.2 + +# Rewritten automatically by .github/workflows/sync-upstream.yml; the LuCI +# package always carries the same version as the core it drives. +PKG_VERSION?=$(PKG_BASE_VERSION).202610010807 PKG_RELEASE?=1 include $(INCLUDE_DIR)/package.mk @@ -21,6 +27,10 @@ endef define Package/luci-app-meow/description LuCI interface for the meow OpenWrt package. + + Adds Services -> meow (Overview, Panel, Configuration, Clients, Settings, + Log). Runtime data comes from the meow REST API through authenticated LuCI + RPC; curl is only used against the router's loopback API. endef define Build/Prepare diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index f79dce28c..feb07c9a2 100644 --- a/luci-app-open-box/Makefile +++ b/luci-app-open-box/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-open-box -PKG_VERSION:=0.1.263 -PKG_RELEASE:=2 +PKG_VERSION:=0.1.277 +PKG_RELEASE:=3 PKG_MAINTAINER:=liandu2024 PKG_LICENSE:=GPL-3.0-or-later diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index f6f46aa32..30d609b6b 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall PKG_VERSION:=26.10.1 -PKG_RELEASE:=305 +PKG_RELEASE:=306 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/root/etc/init.d/passwall b/luci-app-passwall/root/etc/init.d/passwall index 9c71bbb53..ff0977b3d 100755 --- a/luci-app-passwall/root/etc/init.d/passwall +++ b/luci-app-passwall/root/etc/init.d/passwall @@ -14,22 +14,14 @@ set_lock() { flock -xn 999 } -unset_lock() { - flock -u 999 - rm -rf "$LOCK_FILE" +set_lock_wait() { + [ ! -d "$LOCK_PATH" ] && mkdir -p "$LOCK_PATH" + exec 999>"$LOCK_FILE" + flock -x 999 } -unlock() { - failcount=1 - while [ "$failcount" -le 10 ]; do - if [ -f "$LOCK_FILE" ]; then - let "failcount++" - sleep 1s - [ "$failcount" -ge 10 ] && unset_lock - else - break - fi - done +unset_lock() { + flock -u 999 } boot_func() { @@ -57,9 +49,8 @@ start() { } stop() { - unlock - set_lock - [ $? == 1 ] && echolog "停止脚本等待超时,不重复运行,退出." && exit 0 + set_lock_wait + [ $? != 0 ] && echolog "无法取得停止锁,退出." && exit 1 $APP_FILE stop $2 unset_lock } 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 c5d6da11a..148502d2e 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 @@ -378,20 +378,23 @@ if CHNLIST ~= "0" and is_file_nonzero(RULES_PATH .. "/chnlist") then end --分流规则 -if IS_SHUNT_NODE then - local white_domain, lookup_white_domain = {}, {} - local shunt_domain, lookup_shunt_domain = {}, {} +if IS_SHUNT_NODE and not only_global then + local direct_domain, lookup_direct_domain = {}, {} + local proxy_domain, lookup_proxy_domain = {}, {} + local black_domain, lookup_black_domain = {}, {} 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_direct_host = CACHE_FLAG_PATH .. "/shunt_direct_host" + local shunt_direct_host_tmp = shunt_direct_host .. "_tmp" + local shunt_proxy_host = CACHE_FLAG_PATH .. "/shunt_proxy_host" + local shunt_black_host = CACHE_FLAG_PATH .. "/shunt_black_host" + local geosite_direct_arg, geosite_proxy_arg, geosite_black_arg = "", "", "" local SHUNT_LIST = "" local t = api.uci_get_c(NODE) local default_node_id = t["default_node"] or "_direct" api.uci_foreach_c("shunt_rules", function(s) local _node_id = t[s[".name"]] - if _node_id and _node_id ~= "_blackhole" and t["shunt_group"] == s.group then + if _node_id and t["shunt_group"] == s.group then if _node_id == "_default" then _node_id = default_node_id end @@ -402,9 +405,11 @@ if IS_SHUNT_NODE then if line:find("geosite:") then line = string.match(line, ":([^:]+)$") if _node_id == "_direct" then - geosite_white_arg = geosite_white_arg .. (geosite_white_arg ~= "" and "," or "") .. line + geosite_direct_arg = geosite_direct_arg .. (geosite_direct_arg ~= "" and "," or "") .. line + elseif _node_id == "_blackhole" then + geosite_black_arg = geosite_black_arg .. (geosite_black_arg ~= "" and "," or "") .. line else - geosite_shunt_arg = geosite_shunt_arg .. (geosite_shunt_arg ~= "" and "," or "") .. line + geosite_proxy_arg = geosite_proxy_arg .. (geosite_proxy_arg ~= "" and "," or "") .. line end else if line:find("domain:") or line:find("full:") then @@ -413,25 +418,26 @@ if IS_SHUNT_NODE then line = api.get_std_domain(line) if line ~= "" and not line:find("#") then if _node_id == "_direct" then - insert_unique(white_domain, line, lookup_white_domain) + insert_unique(direct_domain, line, lookup_direct_domain) + elseif _node_id == "_blackhole" then + insert_unique(black_domain, line, lookup_black_domain) else - insert_unique(shunt_domain, line, lookup_shunt_domain) + insert_unique(proxy_domain, line, lookup_proxy_domain) end end end end end - SHUNT_LIST = SHUNT_LIST .. domain_list .. _node_id + SHUNT_LIST = SHUNT_LIST .. domain_list .. (_node_id:sub(1, 1) == "_" and "not-node" or "node") - if _node_id ~= "_direct" then - log(string.format(" - Sing-Box/Xray分流规则(%s):%s", s.remarks, DNS_TRUST or "默认")) - end + log(string.format(" - Sing-Box/Xray分流规则(%s):%s", s.remarks, DNS_TRUST or "默认")) end end) local MD5_FILE = CACHE_FLAG_PATH .. "/md5.txt" local cache_md5 = "" + local USE_CACHE = true if fs.access(MD5_FILE) then cache_md5 = fs.readfile(MD5_FILE) end @@ -440,86 +446,118 @@ if IS_SHUNT_NODE then api.remove(CACHE_FLAG_PATH) sys.call("mkdir -p %s" % CACHE_FLAG_PATH) fs.writefile(MD5_FILE, new_md5) + USE_CACHE = false end - if is_file_nonzero(file_white_host) == nil then - if #white_domain > 0 then - local f_out = io.open(file_white_host, "w") - for i = 1, #white_domain do - f_out:write(white_domain[i] .. "\n") + if not is_file_nonzero(shunt_direct_host_tmp) then + if #direct_domain > 0 then + local f_out = io.open(shunt_direct_host_tmp, "w") + for i = 1, #direct_domain do + f_out:write(direct_domain[i] .. "\n") end f_out:close() end end - if is_file_nonzero(file_shunt_host) == nil then - if #shunt_domain > 0 then - local f_out = io.open(file_shunt_host, "w") - for i = 1, #shunt_domain do - f_out:write(shunt_domain[i] .. "\n") + if not is_file_nonzero(shunt_proxy_host) then + if #proxy_domain > 0 then + local f_out = io.open(shunt_proxy_host, "w") + for i = 1, #proxy_domain do + f_out:write(proxy_domain[i] .. "\n") end f_out:close() end end - if GFWLIST == "1" and CHNLIST == "0" and USE_GEOVIEW == "1" then --仅GFW模式解析geosite - local return_white, return_shunt - if geosite_white_arg ~= "" then - return_white = get_geosite(geosite_white_arg, file_white_host) + if not is_file_nonzero(shunt_black_host) then + if #black_domain > 0 then + local f_out = io.open(shunt_black_host, "w") + for i = 1, #black_domain do + f_out:write(black_domain[i] .. "\n") + end + f_out:close() end - if geosite_shunt_arg ~= "" then - return_shunt = get_geosite(geosite_shunt_arg, file_shunt_host) + end + + if not USE_CACHE and USE_GEOVIEW == "1" then + local return_direct, return_proxy, return_black + if geosite_direct_arg ~= "" then + return_direct = get_geosite(geosite_direct_arg, shunt_direct_host_tmp) end - if (return_white == nil or return_white == 0) and (return_shunt == nil or return_shunt == 0) then + if geosite_proxy_arg ~= "" then + return_proxy = get_geosite(geosite_proxy_arg, shunt_proxy_host) + end + if geosite_black_arg ~= "" then + return_black = get_geosite(geosite_black_arg, shunt_black_host) + end + if return_direct == 0 and return_proxy == 0 and return_black == 0 then log(" - 解析[分流节点] Geosite 完成") else log(" - 解析[分流节点] Geosite 失败!") end end - local sets = { - setflag .. "psw_shunt", - setflag .. "psw_shunt6" - } - if FLAG ~= "default" then - sets = { - setflag .. "psw_" .. FLAG .. "_shunt", - setflag .. "psw_" .. FLAG .. "_shunt6" - } - end - - if is_file_nonzero(file_white_host) then - if USE_DIRECT_LIST == "1" then - --当白名单启用时,添加到白名单组一同处理 - for i, v in ipairs(config_lines) do - if v == "group-dnl " .. file_direct_host then - config_lines[i] = "group-dnl " .. file_direct_host .. "," .. file_white_host - break + -- 与 chnlist 比对 + if is_file_nonzero(shunt_direct_host_tmp) and CHNLIST ~= "0" and is_file_nonzero(RULES_PATH .. "/chnlist") then + local chn_domain_set = {} + local f_chn = io.open(RULES_PATH .. "/chnlist", "r") + if f_chn then + for line in f_chn:lines() do + if line ~= "" then + chn_domain_set[line] = true end end - else - --当白名单不启用时,创建新组,ipset到shuntlist - tmp_lines = { - "group whitelist", - "group-dnl " .. file_white_host, - "group-upstream " .. DNS_LOCAL, - "group-ipset " .. table.concat(sets, ",") - } - insert_array_after(config_lines, tmp_lines, "#--4") + f_chn:close() end + local f_in = io.open(shunt_direct_host_tmp, "r") + local f_out = io.open(shunt_direct_host, "w") + if f_in and f_out then + for line in f_in:lines() do + line = string.lower(line) + -- 排除 cn、.cn 等缺少有效域名标签的条目 + local valid_domain = line ~= "" and line:find("%.") and line:sub(1,1) ~= "." + if valid_domain and not chn_domain_set[line] then + f_out:write(line .. "\n") + end + end + end + if f_in then f_in:close() end + if f_out then f_out:close() end + api.remove(shunt_direct_host_tmp) + elseif is_file_nonzero(shunt_direct_host_tmp) then + os.rename(shunt_direct_host_tmp, shunt_direct_host) end - if is_file_nonzero(file_shunt_host) then + local shunt_file + if is_file_nonzero(shunt_black_host) then + shunt_file = shunt_black_host + end + if is_file_nonzero(shunt_direct_host) then + shunt_file = (shunt_file and shunt_file .. "," or "") .. shunt_direct_host + end + if is_file_nonzero(shunt_proxy_host) then + shunt_file = (shunt_file and shunt_file .. "," or "") .. shunt_proxy_host + end + + if shunt_file then + local sets = { + setflag .. "psw_shunt", + setflag .. "psw_shunt6" + } + if FLAG ~= "default" then + sets = { + setflag .. "psw_" .. FLAG .. "_shunt", + setflag .. "psw_" .. FLAG .. "_shunt6" + } + end tmp_lines = { "group shuntlist", - "group-dnl " .. file_shunt_host, + "group-dnl " .. shunt_file, "group-upstream " .. DNS_TRUST, "group-ipset " .. table.concat(sets, ",") } - if NO_IPV6_TRUST == "1" then table.insert(tmp_lines, "no-ipv6 tag:shuntlist") end insert_array_after(config_lines, tmp_lines, "#--2") end - end --只使用gfwlist模式,GFW列表以外的域名及默认使用本地DNS diff --git a/luci-app-passwall/root/usr/share/passwall/helper_dnsmasq.lua b/luci-app-passwall/root/usr/share/passwall/helper_dnsmasq.lua index ff7a69050..f6895a2d6 100644 --- a/luci-app-passwall/root/usr/share/passwall/helper_dnsmasq.lua +++ b/luci-app-passwall/root/usr/share/passwall/helper_dnsmasq.lua @@ -193,7 +193,7 @@ function add_rule(var) local CACHE_TEXT_FILE = CACHE_DNS_PATH .. ".txt" local USE_CHINADNS_NG = "0" local IS_SHUNT_NODE = api.uci_get_c(NODE, "protocol") == "_shunt" - local USE_GEOVIEW = api.uci_get_c("@global_rules[0]", "enable_geoview") or "0" + local USE_GEOVIEW = api.uci_get_c("@global_rules[0]", "enable_geoview") local list1 = {} local excluded_domain = {} @@ -351,13 +351,13 @@ function add_rule(var) local t = api.uci_get_c(NODE) api.uci_foreach_c("shunt_rules", function(s) local _node_id = t[s[".name"]] - if _node_id and _node_id ~= "_blackhole" and t["shunt_group"] == s.group then - SHUNT_LIST = SHUNT_LIST .. (s.domain_list or "") .. _node_id + if _node_id and t["shunt_group"] == s.group then + SHUNT_LIST = SHUNT_LIST .. (s.domain_list or "") .. (_node_id:sub(1, 1) == "_" and "not-node" or "node") end end) end new_rules = new_rules .. api.md5_string(SHUNT_LIST) - return TMP_DNSMASQ_PATH .. DNSMASQ_CONF_FILE .. DEFAULT_DNS .. LOCAL_DNS .. TUN_DNS .. USE_DEFAULT_DNS .. CHINADNS_DNS .. USE_DIRECT_LIST .. USE_PROXY_LIST .. USE_BLOCK_LIST .. USE_GFW_LIST .. CHN_LIST .. DEFAULT_PROXY_MODE .. NO_PROXY_IPV6 .. address_md5 .. new_rules .. NFTFLAG .. USE_GEOVIEW + return TMP_DNSMASQ_PATH .. DNSMASQ_CONF_FILE .. DEFAULT_DNS .. LOCAL_DNS .. TUN_DNS .. USE_DEFAULT_DNS .. CHINADNS_DNS .. USE_DIRECT_LIST .. USE_PROXY_LIST .. USE_BLOCK_LIST .. USE_GFW_LIST .. CHN_LIST .. DEFAULT_PROXY_MODE .. NO_PROXY_IPV6 .. address_md5 .. new_rules .. NFTFLAG end local dnsmasq_default_dns @@ -655,50 +655,27 @@ function add_rule(var) end --分流规则 - if IS_SHUNT_NODE and USE_CHINADNS_NG == "0" then + if IS_SHUNT_NODE and USE_CHINADNS_NG == "0" and only_global ~= 1 then local t = api.uci_get_c(NODE) local default_node_id = t["default_node"] or "_direct" api.uci_foreach_c("shunt_rules", function(s) local _node_id = t[s[".name"]] - if _node_id and _node_id ~= "_blackhole" and t["shunt_group"] == s.group then + if _node_id and t["shunt_group"] == s.group then if _node_id == "_default" then _node_id = default_node_id end - fwd_dns = nil - no_ipv6 = nil + fwd_dns = TUN_DNS - local sets = {} - - if _node_id == "_direct" then - fwd_dns = LOCAL_DNS - if USE_DIRECT_LIST == "1" then - table.insert(sets, setflag_4 .. "psw_white") - table.insert(sets, setflag_6 .. "psw_white6") - else - local set_name = "psw_shunt" - local set6_name = "psw_shunt6" - if FLAG ~= "default" then - set_name = "psw_" .. FLAG .. "_shunt" - set6_name = "psw_" .. FLAG .. "_shunt6" - end - table.insert(sets, setflag_4 .. set_name) - table.insert(sets, setflag_6 .. set6_name) - end - else - local set_name = "psw_shunt" - local set6_name = "psw_shunt6" - if FLAG ~= "default" then - set_name = "psw_" .. FLAG .. "_shunt" - set6_name = "psw_" .. FLAG .. "_shunt6" - end - fwd_dns = TUN_DNS - table.insert(sets, setflag_4 .. set_name) - if NO_PROXY_IPV6 ~= "1" then - table.insert(sets, setflag_6 .. set6_name) - else - no_ipv6 = true - end + local sets = { + setflag_4 .. "psw_shunt", + setflag_6 .. "psw_shunt6" + } + if FLAG ~= "default" then + sets = { + setflag_4 .. "psw_" .. FLAG .. "_shunt", + setflag_6 .. "psw_" .. FLAG .. "_shunt6" + } end local domain_list = s.domain_list or "" @@ -714,10 +691,6 @@ function add_rule(var) end line = api.get_std_domain(line) add_excluded_domain(line) - - if no_ipv6 then - set_domain_address(line, "::") - end set_domain_dns(line, fwd_dns) set_domain_ipset(line, table.concat(sets, ",")) end @@ -727,18 +700,13 @@ function add_rule(var) if USE_GFW_LIST == "1" and CHN_LIST == "0" and USE_GEOVIEW == "1" and geosite_arg ~= "" then --仅GFW模式解析geosite foreach_geosite(geosite_arg, function(line) add_excluded_domain(line) - if no_ipv6 then - set_domain_address(line, "::") - end set_domain_dns(line, fwd_dns) set_domain_ipset(line, table.concat(sets, ",")) end) log(string.format(" - 解析分流规则(%s) Geosite 完成", s.remarks)) end - if _node_id ~= "_direct" then - log(string.format(" - Sing-Box/Xray分流规则(%s):%s", s.remarks, fwd_dns or "默认")) - end + log(string.format(" - Sing-Box/Xray分流规则(%s):%s", s.remarks, fwd_dns or "默认")) end end) elseif only_global == 1 and NO_PROXY_IPV6 == "1" then 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 dbb00638a..afe272614 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 @@ -186,7 +186,7 @@ if not REMOTE_GROUP or REMOTE_GROUP == "nil" then end local force_https_soa = api.uci_get_c("@global[0]", "force_https_soa") or 0 -local proxy_server_name = "passwall-proxy-server" +local proxy_server_name = "psw-proxy-server" config_lines = { tonumber(LISTEN_PORT) ~= 0 and "bind [::]:" .. LISTEN_PORT .. "@lo" or "", (tonumber(LOCAL_PORT) ~= 0 and LOCAL_GROUP) and "bind [::]:" .. LOCAL_PORT .. "@lo -group " .. LOCAL_GROUP or "", @@ -268,7 +268,7 @@ if DEFAULT_DNS_GROUP then local domain_rules_str = "domain-rules /./ -nameserver " .. DEFAULT_DNS_GROUP if DEFAULT_DNS_GROUP == REMOTE_GROUP then domain_rules_str = domain_rules_str .. " -speed-check-mode none -d no -no-serve-expired" - domain_rules_str = domain_rules_str .. " -address " .. (NO_PROXY_IPV6 == "1" and "#6" or "-6") + domain_rules_str = domain_rules_str .. " -address " .. ((only_global and IS_SHUNT_NODE) and "-6" or (NO_PROXY_IPV6 == "1" and "#6" or "-6")) elseif DEFAULT_DNS_GROUP == LOCAL_GROUP then domain_rules_str = domain_rules_str .. (LOCAL_EXTEND_ARG ~= "" and " " .. LOCAL_EXTEND_ARG or "") end @@ -279,7 +279,7 @@ local setflag = (NFTFLAG == "1") and "inet#passwall#" or "" local set_type = (NFTFLAG == "1") and "-nftset" or "-ipset" --预设排序标签(越往后优先级越高) -for i = 1, 8 do +for i = 1, 10 do table.insert(config_lines, "#--" .. i) end @@ -319,12 +319,12 @@ if USE_BLOCK_LIST == "1" and not fs.access(file_block_host) then end end if USE_BLOCK_LIST == "1" and is_file_nonzero(file_block_host) then - local domain_set_name = "passwall-block" + local domain_set_name = "psw-block" tmp_lines = { string.format("domain-set -name %s -file %s", domain_set_name, file_block_host), string.format("domain-rules /domain-set:%s/ -a #", domain_set_name) } - insert_array_after(config_lines, tmp_lines, "#--7") + insert_array_after(config_lines, tmp_lines, "#--8") end --始终用国内DNS解析节点域名 @@ -357,7 +357,7 @@ if not is_file_nonzero(file_vpslist) then f_out:close() end if is_file_nonzero(file_vpslist) then - local domain_set_name = "passwall-vpslist" + local domain_set_name = "psw-vpslist" tmp_lines = { string.format("domain-set -name %s -file %s", domain_set_name, file_vpslist) } @@ -369,7 +369,7 @@ if is_file_nonzero(file_vpslist) then domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",") domain_rules_str = domain_rules_str .. (LOCAL_EXTEND_ARG ~= "" and " " .. LOCAL_EXTEND_ARG or "") table.insert(tmp_lines, domain_rules_str) - insert_array_after(config_lines, tmp_lines, "#--8") + insert_array_after(config_lines, tmp_lines, "#--9") log(string.format(" - 节点列表中的域名(vpslist)使用分组:%s", LOCAL_GROUP or "默认")) end @@ -409,7 +409,7 @@ if USE_DIRECT_LIST == "1" and not fs.access(file_direct_host) then end end if USE_DIRECT_LIST == "1" and is_file_nonzero(file_direct_host) then - local domain_set_name = "passwall-directlist" + local domain_set_name = "psw-directlist" tmp_lines = { string.format("domain-set -name %s -file %s", domain_set_name, file_direct_host) } @@ -421,7 +421,7 @@ if USE_DIRECT_LIST == "1" and is_file_nonzero(file_direct_host) then domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",") domain_rules_str = domain_rules_str .. (LOCAL_EXTEND_ARG ~= "" and " " .. LOCAL_EXTEND_ARG or "") table.insert(tmp_lines, domain_rules_str) - insert_array_after(config_lines, tmp_lines, "#--6") + insert_array_after(config_lines, tmp_lines, "#--7") log(string.format(" - 域名白名单(whitelist)使用分组:%s", LOCAL_GROUP or "默认")) end @@ -461,7 +461,7 @@ if USE_PROXY_LIST == "1" and not fs.access(file_proxy_host) then end end if USE_PROXY_LIST == "1" and is_file_nonzero(file_proxy_host) then - local domain_set_name = "passwall-proxylist" + local domain_set_name = "psw-proxylist" tmp_lines = { string.format("domain-set -name %s -file %s", domain_set_name, file_proxy_host) } @@ -478,13 +478,13 @@ if USE_PROXY_LIST == "1" and is_file_nonzero(file_proxy_host) then domain_rules_str = domain_rules_str .. " -address -6 -d no " .. set_type .. " " .. table.concat(sets, ",") end table.insert(tmp_lines, domain_rules_str) - insert_array_after(config_lines, tmp_lines, "#--5") + insert_array_after(config_lines, tmp_lines, "#--6") log(string.format(" - 代理域名表(blacklist)使用分组:%s", REMOTE_GROUP or "默认")) end --GFW列表 if USE_GFW_LIST == "1" and is_file_nonzero(RULES_PATH .. "/gfwlist") then - local domain_set_name = "passwall-gfwlist" + local domain_set_name = "psw-gfwlist" tmp_lines = { string.format("domain-set -name %s -file %s", domain_set_name, RULES_PATH .. "/gfwlist") } @@ -501,13 +501,13 @@ if USE_GFW_LIST == "1" and is_file_nonzero(RULES_PATH .. "/gfwlist") then domain_rules_str = domain_rules_str .. " -address -6 -d no " .. set_type .. " " .. table.concat(sets, ",") end table.insert(tmp_lines, domain_rules_str) - insert_array_after(config_lines, tmp_lines, "#--1") + insert_array_after(config_lines, tmp_lines, "#--5") log(string.format(" - 防火墙域名表(gfwlist)使用分组:%s", REMOTE_GROUP or "默认")) end --中国列表 if CHN_LIST ~= "0" and is_file_nonzero(RULES_PATH .. "/chnlist") then - local domain_set_name = "passwall-chnlist" + local domain_set_name = "psw-chnlist" tmp_lines = { string.format("domain-set -name %s -file %s", domain_set_name, RULES_PATH .. "/chnlist") } @@ -521,7 +521,7 @@ if CHN_LIST ~= "0" and is_file_nonzero(RULES_PATH .. "/chnlist") then domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",") domain_rules_str = domain_rules_str .. (LOCAL_EXTEND_ARG ~= "" and " " .. LOCAL_EXTEND_ARG or "") table.insert(tmp_lines, domain_rules_str) - insert_array_after(config_lines, tmp_lines, "#--2") + insert_array_after(config_lines, tmp_lines, "#--4") log(string.format(" - 中国域名表(chnlist)使用分组:%s", LOCAL_GROUP or "默认")) end @@ -540,26 +540,28 @@ if CHN_LIST ~= "0" and is_file_nonzero(RULES_PATH .. "/chnlist") then domain_rules_str = domain_rules_str .. " -address -6 -d no " .. set_type .. " " .. table.concat(sets, ",") end table.insert(tmp_lines, domain_rules_str) - insert_array_after(config_lines, tmp_lines, "#--2") + insert_array_after(config_lines, tmp_lines, "#--4") log(string.format(" - 中国域名表(chnlist)使用分组:%s", REMOTE_GROUP or "默认")) end end --分流规则 -if IS_SHUNT_NODE then - local white_domain, lookup_white_domain = {}, {} - local shunt_domain, lookup_shunt_domain = {}, {} +if IS_SHUNT_NODE and not only_global then + local direct_domain, lookup_direct_domain = {}, {} + local proxy_domain, lookup_proxy_domain = {}, {} + local black_domain, lookup_black_domain = {}, {} 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_direct_host = CACHE_FLAG_PATH .. "/shunt_direct_host" + local shunt_proxy_host = CACHE_FLAG_PATH .. "/shunt_proxy_host" + local shunt_black_host = CACHE_FLAG_PATH .. "/shunt_black_host" + local geosite_direct_arg, geosite_proxy_arg, geosite_black_arg = "", "", "" local SHUNT_LIST = "" local t = api.uci_get_c(NODE) local default_node_id = t["default_node"] or "_direct" api.uci_foreach_c("shunt_rules", function(s) local _node_id = t[s[".name"]] - if _node_id and _node_id ~= "_blackhole" and t["shunt_group"] == s.group then + if _node_id and t["shunt_group"] == s.group then if _node_id == "_default" then _node_id = default_node_id end @@ -570,9 +572,11 @@ if IS_SHUNT_NODE then if line:find("geosite:") then line = string.match(line, ":([^:]+)$") if _node_id == "_direct" then - geosite_white_arg = geosite_white_arg .. (geosite_white_arg ~= "" and "," or "") .. line + geosite_direct_arg = geosite_direct_arg .. (geosite_direct_arg ~= "" and "," or "") .. line + elseif _node_id == "_blackhole" then + geosite_black_arg = geosite_black_arg .. (geosite_black_arg ~= "" and "," or "") .. line else - geosite_shunt_arg = geosite_shunt_arg .. (geosite_shunt_arg ~= "" and "," or "") .. line + geosite_proxy_arg = geosite_proxy_arg .. (geosite_proxy_arg ~= "" and "," or "") .. line end else if line:find("domain:") or line:find("full:") then @@ -581,25 +585,26 @@ if IS_SHUNT_NODE then line = api.get_std_domain(line) if line ~= "" and not line:find("#") then if _node_id == "_direct" then - insert_unique(white_domain, line, lookup_white_domain) + insert_unique(direct_domain, line, lookup_direct_domain) + elseif _node_id == "_blackhole" then + insert_unique(black_domain, line, lookup_black_domain) else - insert_unique(shunt_domain, line, lookup_shunt_domain) + insert_unique(proxy_domain, line, lookup_proxy_domain) end end end end end - SHUNT_LIST = SHUNT_LIST .. domain_list .. _node_id + SHUNT_LIST = SHUNT_LIST .. domain_list .. (_node_id:sub(1, 1) == "_" and "not-node" or "node") - if _node_id ~= "_direct" then - log(string.format(" - Sing-Box/Xray分流规则(%s)使用分组:%s", s.remarks, REMOTE_GROUP or "默认")) - end + log(string.format(" - Sing-Box/Xray分流规则(%s)使用分组:%s", s.remarks, REMOTE_GROUP or "默认")) end end) local MD5_FILE = CACHE_FLAG_PATH .. "/md5.txt" local cache_md5 = "" + local USE_CACHE = true if fs.access(MD5_FILE) then cache_md5 = fs.readfile(MD5_FILE) end @@ -608,88 +613,104 @@ if IS_SHUNT_NODE then api.remove(CACHE_FLAG_PATH) sys.call("mkdir -p %s" % CACHE_FLAG_PATH) fs.writefile(MD5_FILE, new_md5) + USE_CACHE = false end - if is_file_nonzero(file_white_host) == nil then - if #white_domain > 0 then - local f_out = io.open(file_white_host, "w") - for i = 1, #white_domain do - f_out:write(white_domain[i] .. "\n") + if not is_file_nonzero(shunt_direct_host) then + if #direct_domain > 0 then + local f_out = io.open(shunt_direct_host, "w") + for i = 1, #direct_domain do + f_out:write(direct_domain[i] .. "\n") end f_out:close() end end - if is_file_nonzero(file_shunt_host) == nil then - if #shunt_domain > 0 then - local f_out = io.open(file_shunt_host, "w") - for i = 1, #shunt_domain do - f_out:write(shunt_domain[i] .. "\n") + if not is_file_nonzero(shunt_proxy_host) then + if #proxy_domain > 0 then + local f_out = io.open(shunt_proxy_host, "w") + for i = 1, #proxy_domain do + f_out:write(proxy_domain[i] .. "\n") end f_out:close() end end - if USE_GFW_LIST == "1" and CHN_LIST == "0" and USE_GEOVIEW == "1" then --仅GFW模式解析geosite - local return_white, return_shunt - if geosite_white_arg ~= "" then - return_white = get_geosite(geosite_white_arg, file_white_host) + if not is_file_nonzero(shunt_black_host) then + if #black_domain > 0 then + local f_out = io.open(shunt_black_host, "w") + for i = 1, #black_domain do + f_out:write(black_domain[i] .. "\n") + end + f_out:close() end - if geosite_shunt_arg ~= "" then - return_shunt = get_geosite(geosite_shunt_arg, file_shunt_host) + end + + if not USE_CACHE and USE_GEOVIEW == "1" then + local return_direct, return_proxy, return_black + if geosite_direct_arg ~= "" then + return_direct = get_geosite(geosite_direct_arg, shunt_direct_host) end - if (return_white == nil or return_white == 0) and (return_shunt == nil or return_shunt == 0) then + if geosite_proxy_arg ~= "" then + return_proxy = get_geosite(geosite_proxy_arg, shunt_proxy_host) + end + if geosite_black_arg ~= "" then + return_black = get_geosite(geosite_black_arg, shunt_black_host) + end + if return_direct == 0 and return_proxy == 0 and return_black == 0 then log(" - 解析[分流节点] Geosite 完成") else log(" - 解析[分流节点] Geosite 失败!") end end - if is_file_nonzero(file_white_host) then - local domain_set_name = "passwall-whitehost" + if is_file_nonzero(shunt_direct_host) then + local domain_set_name = "psw-shunt-direct" tmp_lines = { - string.format("domain-set -name %s -file %s", domain_set_name, file_white_host) - } - local domain_rules_str = string.format('domain-rules /domain-set:%s/ %s', domain_set_name, LOCAL_GROUP and "-nameserver " .. LOCAL_GROUP or "") - if USE_DIRECT_LIST == "1" then - local sets = { - "#4:" .. setflag .. "psw_white", - "#6:" .. setflag .. "psw_white6" - } - domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",") - else - local sets = { - "#4:" .. setflag .. "psw_shunt", - "#6:" .. setflag .. "psw_shunt6" - } - domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",") - end - domain_rules_str = domain_rules_str .. (LOCAL_EXTEND_ARG ~= "" and " " .. LOCAL_EXTEND_ARG or "") - table.insert(tmp_lines, domain_rules_str) - insert_array_after(config_lines, tmp_lines, "#--4") - end - - if is_file_nonzero(file_shunt_host) then - local domain_set_name = "passwall-shuntlist" - tmp_lines = { - string.format("domain-set -name %s -file %s", domain_set_name, file_shunt_host) + string.format("domain-set -name %s -file %s", domain_set_name, shunt_direct_host) } local domain_rules_str = string.format('domain-rules /domain-set:%s/ -nameserver %s', domain_set_name, REMOTE_GROUP) - domain_rules_str = domain_rules_str .. " -speed-check-mode none" - domain_rules_str = domain_rules_str .. " -no-serve-expired" + domain_rules_str = domain_rules_str .. " -speed-check-mode none -no-serve-expired" local sets = { - "#4:" .. setflag .. "psw_shunt" + "#4:" .. setflag .. "psw_shunt", + "#6:" .. setflag .. "psw_shunt6" } - if NO_PROXY_IPV6 == "1" then - domain_rules_str = domain_rules_str .. " -address #6 " .. set_type .. " " .. table.concat(sets, ",") - else - table.insert(sets, "#6:" .. setflag .. "psw_shunt6") - domain_rules_str = domain_rules_str .. " -address -6 -d no " .. set_type .. " " .. table.concat(sets, ",") - end + domain_rules_str = domain_rules_str .. " -address -6 -d no " .. set_type .. " " .. table.concat(sets, ",") + table.insert(tmp_lines, domain_rules_str) + insert_array_after(config_lines, tmp_lines, "#--1") + end + + if is_file_nonzero(shunt_proxy_host) then + local domain_set_name = "psw-shunt-proxy" + tmp_lines = { + string.format("domain-set -name %s -file %s", domain_set_name, shunt_proxy_host) + } + local domain_rules_str = string.format('domain-rules /domain-set:%s/ -nameserver %s', domain_set_name, REMOTE_GROUP) + domain_rules_str = domain_rules_str .. " -speed-check-mode none -no-serve-expired" + local sets = { + "#4:" .. setflag .. "psw_shunt", + "#6:" .. setflag .. "psw_shunt6" + } + domain_rules_str = domain_rules_str .. " -address -6 -d no " .. set_type .. " " .. table.concat(sets, ",") + table.insert(tmp_lines, domain_rules_str) + insert_array_after(config_lines, tmp_lines, "#--2") + end + + if is_file_nonzero(shunt_black_host) then + local domain_set_name = "psw-shunt-black" + tmp_lines = { + string.format("domain-set -name %s -file %s", domain_set_name, shunt_black_host) + } + local domain_rules_str = string.format('domain-rules /domain-set:%s/ -nameserver %s', domain_set_name, REMOTE_GROUP) + domain_rules_str = domain_rules_str .. " -speed-check-mode none -no-serve-expired" + local sets = { + "#4:" .. setflag .. "psw_shunt", + "#6:" .. setflag .. "psw_shunt6" + } + domain_rules_str = domain_rules_str .. " -address -6 -d no " .. set_type .. " " .. table.concat(sets, ",") table.insert(tmp_lines, domain_rules_str) insert_array_after(config_lines, tmp_lines, "#--3") end - end if #config_lines > 0 then diff --git a/luci-app-ssr-plus/Makefile b/luci-app-ssr-plus/Makefile index 7aa417af3..e9fb4307c 100644 --- a/luci-app-ssr-plus/Makefile +++ b/luci-app-ssr-plus/Makefile @@ -4,7 +4,7 @@ LUCI_TITLE:=luci-app-ssr-plus LUCI_PKGARCH:=all PKG_NAME:=luci-app-ssr-plus PKG_VERSION:=196 -PKG_RELEASE:=77 +PKG_RELEASE:=78 PKG_CONFIG_DEPENDS:= \ CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \ @@ -30,9 +30,9 @@ PKG_CONFIG_DEPENDS:= \ LUCI_TITLE:=SS/SSR/V2Ray/Trojan/NaiveProxy/Tuic/ShadowTLS/Hysteria/Socks5/Clash LuCI interface LUCI_PKGARCH:=all LUCI_DEPENDS:= \ - +libuci-lua +lua +luci-compat +coreutils +coreutils-base64 +dns2tcp +dnsmasq-full \ + +libuci-lua +lua +luci-compat +dns2tcp +dnsmasq-full \ +jq +ip-full +lua-neturl +libuci-lua +microsocks +ipt2socks +lyaml \ - +resolveip +curl +nping +unzip +xz-utils +xz \ + +resolveip +curl +nping \ +PACKAGE_$(PKG_NAME)_INCLUDE_Xray:xray-core \ +PACKAGE_$(PKG_NAME)_INCLUDE_Xray:coreutils-timeout \ +PACKAGE_$(PKG_NAME)_INCLUDE_Http_Proxy:3proxy \ diff --git a/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm b/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm index b18d4229a..a1d040df9 100644 --- a/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm +++ b/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm @@ -11,7 +11,7 @@ }, mihomo: { label: '<%:Mihomo%>', - help: '<%:The online upgrade only tracks the latest stable Mihomo release and downloads the matching linux archive for the current ARCH from the official GitHub release page.%>' + help: '<%:The online upgrade downloads and installs the matching Mihomo IPK or APK package for this system from fw876/helloworld releases.%>' }, naiveproxy: { label: '<%:NaiveProxy%>', diff --git a/luci-app-ssr-plus/po/templates/ssr-plus.pot b/luci-app-ssr-plus/po/templates/ssr-plus.pot index 013227a8a..b4d5c3eee 100644 --- a/luci-app-ssr-plus/po/templates/ssr-plus.pot +++ b/luci-app-ssr-plus/po/templates/ssr-plus.pot @@ -2673,9 +2673,8 @@ msgstr "" #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:14 msgid "" -"The online upgrade only tracks the latest stable Mihomo release and " -"downloads the matching linux archive for the current ARCH from the official " -"GitHub release page." +"The online upgrade downloads and installs the matching Mihomo IPK or APK " +"package for this system from fw876/helloworld releases." msgstr "" #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:18 diff --git a/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po b/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po index 8cd8c8ea6..8a0441530 100644 --- a/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po +++ b/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po @@ -2696,12 +2696,10 @@ msgstr "" #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:14 msgid "" -"The online upgrade only tracks the latest stable Mihomo release and " -"downloads the matching linux archive for the current ARCH from the official " -"GitHub release page." +"The online upgrade downloads and installs the matching Mihomo IPK or APK " +"package for this system from fw876/helloworld releases." msgstr "" -"在线升级只跟踪 Mihomo 最新稳定版,并根据当前架构从官方 GitHub 发布页下载匹配" -"的 Linux 压缩包。" +"在线升级会从 fw876/helloworld 发布页下载并安装适配当前系统的 Mihomo IPK 或 APK 软件包。" #: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:18 msgid "" diff --git a/luci-app-ssr-plus/root/usr/share/shadowsocksr/update_components.sh b/luci-app-ssr-plus/root/usr/share/shadowsocksr/update_components.sh index 2f23ab2f1..257f8f2b1 100755 --- a/luci-app-ssr-plus/root/usr/share/shadowsocksr/update_components.sh +++ b/luci-app-ssr-plus/root/usr/share/shadowsocksr/update_components.sh @@ -3,9 +3,7 @@ set -u XRAY_RELEASE_PAGE="https://github.com/fw876/helloworld/releases/latest" -MIHOMO_RELEASE_PAGE="https://github.com/MetaCubeX/mihomo/releases/latest" XRAY_BINARY="/usr/bin/xray" -MIHOMO_BINARY="/usr/bin/mihomo" # Geo 数据文件 URL GEOIP_REPO="Loyalsoldier/geoip" @@ -191,7 +189,22 @@ get_xray_current_version() { } get_mihomo_current_version() { - local binary + local pm version binary + + pm="$(detect_package_manager 2>/dev/null || true)" + case "$pm" in + apk) + version="$(apk list -I mihomo 2>/dev/null | sed -n 's/^mihomo-\([^[:space:]]*\).*$/\1/p' | sed -n '1p')" + ;; + opkg) + version="$(opkg status mihomo 2>/dev/null | sed -n 's/^Version:[[:space:]]*//p' | sed -n '1p')" + ;; + esac + + if [ -n "${version:-}" ]; then + printf '%s' "$version" + return 0 + fi binary="$(find_mihomo_binary)" || return 1 "$binary" -v 2>/dev/null | sed -n 's/.* v\([0-9][0-9.]*\).*/\1/p' | sed -n '1p' @@ -225,20 +238,6 @@ require_cmd() { command -v "$1" >/dev/null 2>&1 } -select_gzip_cmd() { - if require_cmd gzip; then - printf '%s' 'gzip -dc' - return 0 - fi - - if busybox gzip >/dev/null 2>&1; then - printf '%s' 'busybox gzip -dc' - return 0 - fi - - return 1 -} - select_wget_cmd() { if require_cmd wget-ssl; then printf '%s' 'wget-ssl' @@ -1066,77 +1065,64 @@ get_xray_latest_info() { return 0 } -get_mihomo_latest_tag() { - local location tag +select_mihomo_asset() { + local asset_list="$1" + local pm="$2" + local arch="$3" + local candidate - location="$(effective_url "$MIHOMO_RELEASE_PAGE")" || return 1 - tag="$(printf '%s' "$location" | sed -n 's#.*/tag/\(v[0-9][^/]*\)$#\1#p' | sed -n '1p')" - [ -n "$tag" ] || return 1 - printf '%s' "$tag" -} - -map_mihomo_asset() { - local arch="$1" - local version="$2" - - case "$arch" in - x86_64*|amd64*) - printf 'mihomo-linux-amd64-compatible-v%s.gz' "$version" + case "$pm" in + apk) + candidate="$(printf '%s\n' "$asset_list" | grep -E '^mihomo-[^[:space:]]+\.apk$' | grep -F "_${arch}.apk" | sed -n '1p')" ;; - i386*|i486*|i586*|i686*|x86*) - printf 'mihomo-linux-386-v%s.gz' "$version" - ;; - aarch64*|arm64*) - printf 'mihomo-linux-arm64-v%s.gz' "$version" - ;; - *armv7*|*cortex-a*|*neon*|*vfpv3*|*vfpv4*) - printf 'mihomo-linux-armv7-v%s.gz' "$version" - ;; - *armv6*|*arm1176*) - printf 'mihomo-linux-armv6-v%s.gz' "$version" - ;; - *armv5*|*arm926*|*xscale*) - printf 'mihomo-linux-armv5-v%s.gz' "$version" - ;; - mips64el*|mips64le*) - printf 'mihomo-linux-mips64le-v%s.gz' "$version" - ;; - mips64*) - printf 'mihomo-linux-mips64-v%s.gz' "$version" - ;; - mipsel*|mips32el*|mips32le*) - printf 'mihomo-linux-mipsle-softfloat-v%s.gz' "$version" - ;; - mips*) - printf 'mihomo-linux-mips-softfloat-v%s.gz' "$version" - ;; - riscv64*) - printf 'mihomo-linux-riscv64-v%s.gz' "$version" - ;; - loongarch64*|loong64*) - printf 'mihomo-linux-loong64-abi1-v%s.gz' "$version" - ;; - powerpc64le*|ppc64le*) - printf 'mihomo-linux-ppc64le-v%s.gz' "$version" - ;; - s390x*) - printf 'mihomo-linux-s390x-v%s.gz' "$version" + opkg) + candidate="$(printf '%s\n' "$asset_list" | grep -E '^mihomo_[^[:space:]]+\.ipk$' | grep -F "_${arch}.ipk" | sed -n '1p')" ;; *) return 1 ;; - esac + esac + + [ -n "$candidate" ] || return 1 + printf '%s' "$candidate" +} + +mihomo_asset_version() { + local asset="$1" + local arch="$2" + local version + + case "$asset" in + mihomo_*_"$arch".ipk) + version="${asset#mihomo_}" + version="${version%_${arch}.ipk}" + ;; + mihomo-*_"$arch".apk) + version="${asset#mihomo-}" + version="${version%_${arch}.apk}" + ;; + *) + return 1 + ;; + esac + + [ -n "$version" ] || return 1 + printf '%s' "$version" } get_mihomo_latest_info() { - local arch tag version asset url + local pm arch tag version asset asset_list release_html url + pm="$(detect_package_manager)" || return 2 arch="$(get_openwrt_arch)" - tag="$(get_mihomo_latest_tag)" || return 3 - version="$(trim_version "$tag")" - asset="$(map_mihomo_asset "$arch" "$version")" || return 2 - url="$(mirror_wrap_url "https://github.com/MetaCubeX/mihomo/releases/download/$tag/$asset")" + tag="$(get_helloworld_latest_tag)" || return 3 + release_html="$(fetch_text "https://github.com/fw876/helloworld/releases/expanded_assets/$tag")" || return 3 + asset_list="$(printf '%s\n' "$release_html" | sed -n 's#.*href="/fw876/helloworld/releases/download/[^/]*/\([^"]*\)".*#\1#p')" + asset="$(select_mihomo_asset "$asset_list" "$pm" "$arch")" || return 4 + version="$(mihomo_asset_version "$asset" "$arch")" || return 4 + url="$(mirror_wrap_url "https://github.com/fw876/helloworld/releases/download/$tag/$asset")" + log_kv package_manager "$pm" log_kv arch "$arch" log_kv asset "$asset" log_kv latest_version "$version" @@ -1490,16 +1476,15 @@ xray_info() { } mihomo_info() { - local current installed latest_output latest_rc latest_version arch asset can_upgrade + local pm current installed latest_output latest_rc latest_version arch asset can_upgrade installed=0 current="" + pm="$(detect_package_manager 2>/dev/null || true)" arch="$(get_openwrt_arch)" + asset="" if current="$(get_mihomo_current_version)" && [ -n "$current" ]; then installed=1 - asset="$(map_mihomo_asset "$arch" "$current" 2>/dev/null || true)" - else - asset="" fi latest_output="$(get_mihomo_latest_info 2>/dev/null)" @@ -1508,13 +1493,14 @@ mihomo_info() { log_kv component mihomo log_kv installed "$installed" log_kv current_version "$current" + log_kv package_manager "$pm" log_kv arch "$arch" log_kv asset "$asset" if [ $latest_rc -ne 0 ]; then log_kv can_upgrade 0 case "$latest_rc" in - 2) log_kv error 'unsupported_arch' ;; + 2) log_kv error 'unsupported_package_manager' ;; 3) log_kv error 'fetch_failed' ;; 4) log_kv error 'asset_not_found' ;; *) log_kv error 'unknown_error' ;; @@ -1523,6 +1509,7 @@ mihomo_info() { fi latest_version="$(printf '%s\n' "$latest_output" | sed -n 's/^latest_version=//p' | sed -n '1p')" + pm="$(printf '%s\n' "$latest_output" | sed -n 's/^package_manager=//p' | sed -n '1p')" arch="$(printf '%s\n' "$latest_output" | sed -n 's/^arch=//p' | sed -n '1p')" asset="$(printf '%s\n' "$latest_output" | sed -n 's/^asset=//p' | sed -n '1p')" can_upgrade=0 @@ -1606,24 +1593,23 @@ xray_local_info() { } mihomo_local_info() { - local current installed arch asset + local pm current installed arch installed=0 current="" + pm="$(detect_package_manager 2>/dev/null || true)" arch="$(get_openwrt_arch)" if current="$(get_mihomo_current_version)" && [ -n "$current" ]; then installed=1 - asset="$(map_mihomo_asset "$arch" "$current" 2>/dev/null || true)" - else - asset="" fi log_kv component mihomo log_kv installed "$installed" log_kv current_version "$current" log_kv latest_version '' + log_kv package_manager "$pm" log_kv arch "$arch" - log_kv asset "$asset" + log_kv asset '' log_kv can_upgrade 0 log_kv error '' } @@ -1725,14 +1711,14 @@ xray_upgrade() { } mihomo_upgrade() { - local latest_output latest_rc latest_version download_url tmp_dir gz_file gzip_cmd backup_file current_before current_after target_binary extracted_binary + local latest_output latest_rc latest_version download_url package_manager asset tmp_dir package_file current_before current_after latest_output="$(get_mihomo_latest_info 2>/dev/null)" latest_rc=$? if [ $latest_rc -ne 0 ]; then log_kv success 0 case "$latest_rc" in - 2) log_kv message 'Unsupported ARCH' ;; + 2) log_kv message 'Unsupported package manager' ;; 3) log_kv message 'Failed to fetch release metadata' ;; 4) log_kv message 'Matching release asset not found' ;; *) log_kv message 'Unknown error' ;; @@ -1740,14 +1726,10 @@ mihomo_upgrade() { return 0 fi - if ! gzip_cmd="$(select_gzip_cmd)"; then - log_kv success 0 - log_kv message 'Missing gzip support' - return 0 - fi - latest_version="$(printf '%s\n' "$latest_output" | sed -n 's/^latest_version=//p' | sed -n '1p')" download_url="$(printf '%s\n' "$latest_output" | sed -n 's/^download_url=//p' | sed -n '1p')" + package_manager="$(printf '%s\n' "$latest_output" | sed -n 's/^package_manager=//p' | sed -n '1p')" + asset="$(printf '%s\n' "$latest_output" | sed -n 's/^asset=//p' | sed -n '1p')" current_before="$(get_mihomo_current_version 2>/dev/null || true)" if [ -n "$current_before" ] && ! version_gt "$latest_version" "$current_before"; then log_kv success 1 @@ -1758,9 +1740,6 @@ mihomo_upgrade() { return 0 fi - target_binary="$(find_mihomo_binary 2>/dev/null || true)" - [ -n "$target_binary" ] || target_binary="$MIHOMO_BINARY" - tmp_dir="$(mktemp -d /tmp/ssrplus-mihomo.XXXXXX)" if [ -z "$tmp_dir" ] || [ ! -d "$tmp_dir" ]; then log_kv success 0 @@ -1768,58 +1747,25 @@ mihomo_upgrade() { return 0 fi - gz_file="$tmp_dir/mihomo.gz" - backup_file="$tmp_dir/mihomo.backup" - extracted_binary="$tmp_dir/mihomo" - + package_file="$tmp_dir/$asset" trap "rm -rf '$tmp_dir'" EXIT INT TERM - if ! download_file "$download_url" "$gz_file"; then + if ! download_file "$download_url" "$package_file"; then log_kv success 0 log_kv message 'Download failed' return 0 fi - if ! sh -c "$gzip_cmd \"$gz_file\" > \"$extracted_binary\""; then - log_kv success 0 - log_kv message 'Extract failed' - return 0 - fi - - if [ ! -s "$extracted_binary" ]; then - log_kv success 0 - log_kv message 'mihomo binary not found in archive' - return 0 - fi - - mkdir -p "$(dirname "$target_binary")" || true - chmod 0755 "$extracted_binary" || true - if [ -x "$target_binary" ]; then - cp -fp "$target_binary" "$backup_file" 2>/dev/null || true - fi - - if ! cp -f "$extracted_binary" "$target_binary"; then - if [ -f "$backup_file" ]; then - cp -f "$backup_file" "$target_binary" 2>/dev/null || true - fi + if [ ! -s "$package_file" ] || ! mainprogram_install_package "$package_manager" "$package_file"; then log_kv success 0 log_kv message 'Install failed' return 0 fi - chmod 0755 "$target_binary" || true - if [ "$target_binary" != "$MIHOMO_BINARY" ] && [ ! -x "$MIHOMO_BINARY" ]; then - ln -sf "$target_binary" "$MIHOMO_BINARY" 2>/dev/null || true - fi - current_after="$(get_mihomo_current_version 2>/dev/null || true)" if [ -z "$current_after" ]; then - if [ -f "$backup_file" ]; then - cp -f "$backup_file" "$target_binary" 2>/dev/null || true - chmod 0755 "$target_binary" || true - fi log_kv success 0 - log_kv message 'Installed binary failed to run' + log_kv message 'Installed package check failed' return 0 fi @@ -1831,6 +1777,9 @@ mihomo_upgrade() { log_kv previous_version "$current_before" log_kv current_version "$current_after" log_kv latest_version "$latest_version" + log_kv package_manager "$package_manager" + log_kv arch "$(get_openwrt_arch)" + log_kv asset "$asset" log_kv message 'Upgrade completed' return 0 } diff --git a/luci-app-substore/CHANGELOG.md b/luci-app-substore/CHANGELOG.md index adbfeff95..d93c04a2b 100644 --- a/luci-app-substore/CHANGELOG.md +++ b/luci-app-substore/CHANGELOG.md @@ -2,6 +2,586 @@ All notable changes to this project will be documented in this file. +## [2.7.0-r1] - ACL 设备实测通过(关闭 2.6.16-r1 遗留的「未实测」) + +**纯文档变更,无代码改动**(`core.lua` 仅同步版本号)。 + +### 背景 + +`[2.6.16-r1]` 引入的 ACL 组(`rpcd/acl.d/luci-app-substore.json` + `menu.d` 的 +`depends.acl`)当时只做到**读上游源码核实**:结论是 ucode dispatcher 在 `dispatch()` +里校验路径上累积的 `depends.acl`,不足即 403,因此这是**入口级**门禁而非仅菜单隐藏。 +本机无 LuCI 运行环境,故在 `docs/SECURITY.md`、`docs/TESTING.md` 第 9 项、 +`docs/LEGACY_ISSUES.md` 1.2 与两份 README 中都**如实标注为「未在设备上实测」**。 + +### 本轮变更 + +按 `docs/TESTING.md` 第 9 项在目标设备完成复核,**三项断言全部通过**: + +| 断言 | 结果 | +|------|------| +| 非 root 且不在 `luci-app-substore` 组内的 LuCI 用户 | **看不到**本应用菜单入口 | +| 把该用户加入组后 | 入口**出现** | +| 该用户直接访问 `/cgi-bin/luci/admin/services/substore/list` | 返回 **403 Forbidden** | + +其中第三项是此前唯一「仅有源码依据」的结论 —— 现已在真实设备上确认, +**入口级 403 的语义成立**,读源码得出的推断与实测一致。 + +同步更新的文件: + +- `docs/SECURITY.md`「访问控制」:末条改为「已在设备上实测通过」并写明三项结果; + 「安全测试」小节从未验证清单中移除 ACL(模板渲染与 cron 落盘**仍未验证**,保持标注) +- `docs/TESTING.md` 第 9 项:标记 ✅ 已实测通过 +- `docs/LEGACY_ISSUES.md` 1.2:将「仍未做的验证」改写为「设备实测(已完成并通过)」, + 逐条列出三项断言 +- `README.md` / `README.en.md`:安全小节的「未在设备上实测 / not measured on a device」 + 改为「已在设备上实测通过 / measured on a device」 + +### 未做 + +- **未记录实测固件版本** —— 用户未提供,不臆测填写 +- LuCI 模板渲染、cron 落盘行为**仍未在设备上验证**(这两项与 ACL 无关,维持原标注) + +### 历史条目说明 + +`[2.6.16-r1]` / `[2.6.17-r1]` 中「未在设备上实测」的记载是那两个版本**当时**的真实 +状态,按惯例**不改写历史条目**;本条即为其后续结论。 + +## [2.6.17-r1] - 明确最低支持系统为 OpenWrt / ImmortalWrt 23.05 + +**纯文档变更,无代码改动**(`core.lua` 仅同步版本号)。 + +### 背景 + +`[2.6.16-r1]` 引入的 ACL 组靠 `menu.d` 的 `depends.acl` 生效。核实上游源码后确认: +**入口级 403 的语义只存在于 ucode dispatcher(23.05 起)**;旧版 Lua dispatcher +(≤ 22.03)里 menu.d 的 `depends.acl` 只影响菜单渲染,入口级拦截需另补 +`entry.acl_depends`。 + +本项目的处置是**明确支持边界**,而不是为旧版补 `entry.acl_depends`: +最低支持 **OpenWrt / ImmortalWrt 23.05**,更早的版本不在支持范围内。这样 ACL 的 +入口级语义在支持范围内始终成立,无需维护两套 dispatcher 分支。 + +### 变更 + +- `README.md` / `README.en.md` / `docs/INSTALL.md`:安装小节新增最低支持版本声明 +- `docs/LEGACY_ISSUES.md` 1.2、`docs/SECURITY.md`「访问控制」:旧版 dispatcher 段落 + 改述为「不在支持范围内,故无需处理」,并注明依据(用户 2026-10-02 确认) +- 版本号同步:`Makefile` `PKG_VERSION` → 2.6.17、`core.lua` `M.version`、 + 三处安装文档中的包名 + +### 未做 + +- 未新增 `entry.acl_depends`(按上述支持边界,无必要) +- 未在设备上实测 ACL 拦截效果 —— 仍需按 `docs/TESTING.md` 第 9 项复核 + +## [2.6.16-r1] - 安全与数据完整性:wget SSRF 缺口、token 校验、ACL、静默失败 + +本轮按 `docs/LEGACY_ISSUES.md`「六」的处置优先级 1–8 实施。**每项都补了自包含回归 +测试,并对 `HEAD` 做了反向验证**(新断言在修复前失败、修复后全绿)—— 只证明「修复后 +测试通过」无法排除「这条断言本来就不会失败」。 + +### 1. wget 后端的 SSRF 缺口(安全) + +`[2.6.12-r1]` 的「连接后复核对端地址」只对 **curl** 成立 —— 它靠 `%{remote_ip}` +拿到真实对端 IP。**wget 后端没有任何等价物**:busybox wget 拿不到对端 IP,`-S` 日志 +里的重定向链也只能**事后**看。于是在「本机没有 DNS 解析手段」的设备上,预检 +(`check_public` 的 fail-open 放行)之后**不存在任何一处校验** —— 等于完全没有 SSRF +防护,而这条路径此前是**静默**的。 + +现按与 `verify_peer_ip` 相同的原则收敛:**校验不了就拒绝**。`fetch_wget` 在 +`opts.unverified` 为真时直接返回错误并提示安装 curl。 + +影响面:仅「无任何解析手段 **且** 目标是域名 **且** 后端是 wget」的设备。字面 IP +目标不受影响(`check_public` 对 IP 提前返回,不产生 `unverified`)。 + +测试:`tests/dns_fallback_test.lua` 用例 H(含桩写入 `evilbody`,确保断言不是因 +「内容为空」而误过)与对照组 H2。反向验证:修复前 3 条断言失败。 + +### 2. 删除订阅部分失败时漏写 cron + +`action_delete` 原先只在**全部删除成功**时调用 `core.write_cron()`,部分失败时直接 +返回、跳过了它。后果:被删订阅的 cron 行仍留在 crontab 里,`substore-cron.sh` 拿着 +已不存在的 id 反复执行、每次非 0 退出,在日志里刷失败并让监控误报。 + +现改为**只要有订阅真的被删掉(`removed > 0`)就重写 cron**,与成功/失败分支无关。 + +测试:`tests/controller_robustness_test.lua` 增加部分失败仍写 cron、全部失败不写 cron +两条断言。反向验证:修复前 1 条断言失败。 + +### 3. 混合格式文本导入不再静默丢一半 + +本地粘贴的文本若混用多种格式,此前 `detect()` 只认优先级最高的一种,其余部分被 +**静默丢弃**,而 `parse` 返回的是合法表 —— 同步报成功,用户以为整份都导进来了 +(实测:「URI + WG conf」只剩 WG 节点,「URI + JSON」只剩 URI 节点,`err` 均为 `nil`)。 + +新增 `M.detect_all()` 收集文本中**全部**出现的格式,本地文本导入在多于一种时 +**明确报错**并列出检出的格式。 + +实施中修正了两处**会把合法配置拒之门外**的误判:INI 段头 `[Proxy]` 被误判为 JSON +数组(加 `is_ini_section_head()` 排除);「URI + JSON」方向漏检(补行级 +`has_json_object_line()`)。 + +**刻意限定范围**:守卫只作用于本地文本导入,远程订阅路径(`M.detect` / `M.parse`) +**未改动** —— 远程内容若混用格式仍是静默取一种,与修复前一致。 + +测试:新增 `tests/parser_mixed_format_test.lua`(70 条断言)。反向验证:修复前探针 +显示「URI + WG conf」得到 `nodes=1, err=nil`。 + +### 4. 写盘失败被静默吞掉 + +`core.lua` 的 `save()` 返回 `util.atomic_write(...)` 的结果(失败时是 `false, err`), +而 `M.remove` / `M.ensure_token` 此前**丢弃了返回值**:写盘失败时 `M.remove` 仍报成功 +(订阅文件已删、索引没更新 → 索引指向不存在的订阅);`M.ensure_token` 会把一个 +**没有落盘**的 token 返回给调用方(页面显示 token,实际不存在)。现改为透传失败与原因。 + +测试:`tests/data_integrity_test.lua` 用 `util.atomic_write` 猴补丁注入写失败。 +反向验证:修复前 5 条断言失败。 + +### 5. 表单 token 校验:空 token 此前可通过(M28) + +`post_ok()` 只判 `formvalue("token") ~= nil`,**空串也通过**;且所有 `entry` 均未声明 +`post`,框架的 `test_post_security` 从未执行(已从上游 `luci/dispatcher.lua` 核实)。 + +已从上游 `luci/template.lua` 核实:viewns 元表的 `token` 键返回 +`disp.context.authtoken` —— 模板里的 `token` **就是** `context.authtoken`,因此 +「提交的 token 与 `authtoken` 比对」正是框架自身的判据,**不会误拒任何合法表单**。 + +现要求 token 存在且非空,并在能取到 `authtoken` 时要求相等;取不到时退回「非空即 +通过」(不因取不到值而拒绝全部请求)。失败原因通过 `post_fail_msg` 回显到列表页。 +同时补齐原先 **9 处忽略返回值**的调用点(7 处 `back_to_list()`、2 处 +`back_to_nodes()`),使校验失败**必然**被用户看到。 + +测试:`tests/controller_robustness_test.lua` M28 段 6 条断言。反向验证:修复前 7 条失败。 + +### 6. 新增 ACL:未授权的 LuCI 用户不再看到本应用(M29) + +此前本应用**没有任何 ACL** —— 没有 acl.d 文件、菜单也没有 `depends.acl`,任意能登录 +LuCI 的用户都能读写全部订阅(含凭据 URL 与下载 token)。 + +新增 `root/usr/share/rpcd/acl.d/luci-app-substore.json` 定义 `luci-app-substore` 组 +(读写 `uci: substore`),`menu.d` 两个条目均声明 `depends.acl` 引用它,`Makefile` +增加对应的安装规则。ACL 结构已对照上游 `luci-base` 与 `luci-app-commands` 核实。 + +**执行范围(已从上游源码核实)**:这是**入口级**门禁,不只是「菜单里看不见」。 +ucode dispatcher(23.05+,现代目标机实际运行的那套)的 `build_pagetree()` 把 menu.d +与 Lua 控制器装进**同一棵树**,`dispatch()` 逐段 `ctx_append` 累积路径上每个节点的 +`depends.acl`,ACL 不足即返回 **403 Forbidden** —— 直接访问 URL 同样被拦住。 +又因 ACL 沿路径累积,挂在父节点 `admin/services/substore` 上的 ACL 已覆盖其下全部 +18 个 `entry`(form / nodes / delete / save / update / probe …)。 +旧版 Lua dispatcher(≤ 22.03)行为不同:menu.d 的 `depends.acl` 只影响菜单渲染, +入口级需另补 `entry.acl_depends`(本轮未做,理由见 LEGACY_ISSUES 1.2)。 + +**未在设备上实测**(如实记录):以上来自读上游源码,本机无 LuCI 运行环境。 +需按 `docs/TESTING.md` 第 9 项复核 —— 未授权用户既看不到菜单入口,直接访问 URL +也应返回 403。 + +回滚:删除 acl.d 文件(及 Makefile 中对应两行)并移除 `menu.d` 两个条目的 `depends` 块。 + +测试:新增 `tests/acl_menu_test.lua`(19 条断言)固化跨文件接线 —— 接线上任何一环写错 +都**不会报错**,只会静默失效(ACL 形同虚设),故必须由测试锁住。反向验证:修复前 +13 条断言失败。 + +### 7 / 8. 确认为已知限制并写入文档(无代码改动) + +- hysteria v1 导出 sing-box 需自行补 `up` / `down`(节点模型无带宽字段,补默认值属猜测) +- hysteria v1 的 URI 只保证「本包导出 → 本包导入」不失真(上游规范站点持续 404) +- `http` 协议不可手工新建(权威协议表不含它;可导入可导出) +- AmneziaWG 参数在界面上是单个 JSON 文本框 +- wget 后端的体积与重定向检查是**请求发出之后**的(busybox wget 无 + `--max-filesize` / `--max-redirect`;超限响应体被丢弃并报错、重定向链逐跳复检, + 差别仅在于晚于请求发出;装 curl 可完全避免) +- 无 DNS 解析能力的设备上 wget 后端拒绝域名订阅(见上文第 1 项) + +已写入 `README.md` / `README.en.md` 新增的「已知限制 / Known limitations」小节、 +`docs/SECURITY.md` 与 `docs/LEGACY_ISSUES.md`(逐项记录「为何不改」的依据)。 + +### 文档 + +- `docs/LEGACY_ISSUES.md`:1.2(M28/M29)、2.1、2.2、2.3、2.4、3.1、3.2、3.4、3.5 + 更新为已实施/已确认;新增「六、本轮实施记录」含逐项回归测试与反向验证结果 +- `docs/SECURITY.md`:新增「访问控制」小节、wget 后端处置、重定向逐跳复检、 + 更新「安全测试」为实际测试文件清单 +- `docs/TESTING.md`:新增 4 个测试文件行与 3 项集成验证(混合格式、部分删除 cron、ACL、token) +- `README.md` / `README.en.md`:新增「已知限制」小节、安全小节更新、目录结构补 acl.d + +## [2.6.15-r1] - 修复:删除订阅后引用它的组合订阅不重算 + +### 缺陷 + +删除一个订阅后,引用它的**组合订阅**会继续输出该订阅的节点: + +- 组合的节点物化在 `nodes/.json`,只有 `combo_refresh` 会重写它; +- 会触发重算的路径只有 `M.sync`(源更新时)与三个节点级控制器入口 + (`action_node_save` / `action_node_delete` / `action_node_set_group`), + **删除订阅这条路径从未调用过**; +- 结果:删掉源订阅后,组合的下载链接 + (`/substore/download?token=&target=...`)继续吐已删订阅的节点, + 直到别的源更新时才被动纠正 —— 看着改了,其实没修。 + +### 修复(`core.remove`) + +在**数据不变量**层面修,而不是在控制器里补一次调用:与 `add_combo` / `save_combo` +在写入口内部调用 `combo_refresh` 的做法保持一致。 + +删除订阅时,在同一次 `pairs(items)` 遍历里处理每个组合的 `sources`: + +1. **摘掉**死 id(不是留着),并记下受影响的组合 id; +2. 与订阅列表**同一次写盘**落盘,不留下「列表已删、`sources` 还引用」的中间状态; +3. 写盘后对每个受影响的组合调用 `combo_refresh`,立刻重算物化节点。 + +摘掉死 id 而不是留着,有两个实际原因:列表页「来源」列用 `name_by_id[sid] or sid` +兜底,留着就会显示 `s00000003` 这种裸 id;而且当组合的来源被删光时, +`combo_refresh` 看到 `#srcs > 0`,不会给出「请选择至少一个订阅」, +组合会**静默变成 0 节点**。摘掉后能正确报错。 + +**实现上的坑**:不能先摘 `sources` 再调用 `M.refresh_combos(id)` —— 后者是按 +「`sources` 里包含 `src_id`」来筛组合的,死 id 一旦摘掉就一个都匹配不到。 +必须在同一趟里收集受影响的组合 id,再直接对每个调用 `combo_refresh`。 + +### 测试 + +`tests/core_combo_test.lua` 增加 13 项(23 → 36 项): + +- 删除源后组合的 `node_count` **立刻**下降,物化节点里不再有已删源的节点 +- 下载链接(真正被 Passwall / OpenClash 拉取的东西)不再含已删源的节点名, + 且保留其余源的节点 +- 死 id 已从 `sources` 摘掉 +- 来源被删光时报错(`error` 非空、`node_count == 0`、`sources` 为空) +- 只被某个组合引用的订阅被删后该组合被清空,**无关组合不受影响** + +反向验证:拆掉本次改动后 8 项断言失败;只加刷新、不做剪除的变体恰好 3 项剪除断言失败 —— +两半都是承重的,不是恒真断言。 + +## [2.6.14-r1] - 订阅列表页勾选批量删除 + 「自定义」User-Agent 用法说明 + +### 新增:订阅列表页的勾选批量删除 + +订阅一多,只能一条条点行内的「删除」。改成与节点页一致的勾选批量删除: + +- 「名称」列**之前**新增选择框列:表头的选择框**全选 / 全不选**,每条订阅另有独立选择框 +- 「添加组合订阅」**之后**新增「删除」按钮:勾选后删除单条或多条; + **一个都没勾选时不删除任何东西**(只提示,与节点页 `substore_delete_selected` 同一套交互) +- 批量删除走一个位于表格**之外**的隐藏表单(每一行已各有一个删除表单,HTML 不允许表单嵌套), + 选中订阅的 id 以逗号拼接后写入 + +控制器 `action_delete` 的 `id` 参数改为支持「单个」或「逗号分隔多个」,与节点页 +`action_node_delete` 的 `idx` 是同一套约定。订阅 id 形如 `s%08x`,不含逗号,切分不会切坏。 + +**部分失败必须回显**(§18):勾了 3 条只删掉 1 条时显示「已删除 1 个,另有 2 个删除失败」, +而不是显示成功 —— 否则用户不会再回头管剩下那两条。全部失败时回显 `core.remove` 给出的原因; +一个可用 id 都没有时报「未指定要删除的订阅」,不静默跳回。 + +### 优化:「订阅客户端类型」的提示补充「自定义」用法 + +原提示只说了「不确定就保持『默认』」,没说「自定义」该怎么填。补充说明:填**要原样发送的 +User-Agent 请求头**,格式与预设里的值同形(`客户端名/版本号`,例如 `clash-verge/v2.5.0`、 +`v2rayN/7.22.0`、`mihomo.party/v2.0.0 (clash.meta)`);可含空格、括号、分号、下划线等可见字符 +(整串原样发送,不解析、不改写),不能含换行等控制字符,最长 256 字符;版本号填机场要求的 +最低可用版本;选「自定义」但留空 = 不发送 UA(等同「默认」)。以上均与 +`http.validate_user_agent` / `core.resolve_user_agent` 的实际行为一致,不是另写一套说法。 + +### 测试 + +- 新增 `tests/subscriptions_bulk_delete_test.lua`(34 项):把 `subscriptions.htm` 按 LuCI 的 + 方式重建成 Lua chunk **真的渲染一遍**,再对生成的 HTML 断言 —— 选择框列在「名称」之前、 + 每行一个且 value 是订阅 id、删除按钮在「添加组合订阅」之后、隐藏表单在表格之外且带 token/id、 + **空选时先 `alert` 且不提交**、id 以逗号拼接、全选同步所有行、空列表 `colspan` 与表头列数一致 +- `tests/controller_robustness_test.lua` 增加 7 项:多个 id 逐个删除、逗号两侧带空格、 + 成功后写一次 cron、部分失败回显且**每个 id 都尝试过**、全部失败回显原因、无可用 id 报错、 + 纯分隔符不得交给 core +- 反向验证:拆掉本次改动后,视图侧 27 项、控制器侧 6 项断言确实失败(不是恒真断言) + +### 文档 + +`README.md` / `README.en.md` / `docs/INSTALL.md` 同步版本号与批量删除说明, +`docs/TESTING.md` 补测试清单。 + +## [2.6.13-r1] - 新增「订阅客户端类型」(User-Agent)支持 + +### 新增:按订阅指定下载用的 User-Agent + +**用户实测**:同一机场(Allblue 加速器,`8.217.0.49`)的 4 个订单链接, +分别只有用 Clash Verge / v2rayN / Clash Party / FlClash 才解析得出节点; +本应用只能解析出 7 个「描述信息」节点: + +``` +- name: 当前更新的订阅链接 type: ss server: 127.0.0.1 port: 1080 +- name: 与您使用客户端不兼容 type: ss server: 127.0.0.1 port: 1080 +- name: 请复制 curl/8.22.0 类型 type: ss server: 127.0.0.1 port: 1080 +...(共 7 条,密码均为 00000000-0000-0000-0000-000000000000) +``` + +**根因**:机场按请求的 `User-Agent` 决定返回真实节点还是占位内容。本应用此前 +**完全不发送 UA**,curl 用的是自带的 `curl/x.y.z`,被机场判为「不兼容的客户端」, +于是返回一段把提示语写进节点名的占位内容 —— 服务器把请求方的 UA 回显进了那句 +提示里(所以路由器上看到的是 `curl/8.22.0`,沙箱里看到的是 `curl/8.18.0`)。 +占位内容本身是**合法**的 `ss` 节点,所以解析不报错,症状是「更新成功但节点全不可用」。 +每个订单链接绑定**唯一**一种客户端:用错客户端的 UA 同样只拿到占位内容, +不存在一个通用 UA 能同时适配四种链接。 + +**修复**:新增按订阅的「订阅客户端类型」设置,下载时以 `-A`(curl)/ `-U`(busybox wget) +发送对应 UA。四个预设的 UA 字符串**取自各客户端源码**(非猜测),版本号取用户给出的 +最低可用版本: + +| 预设 | User-Agent | 来源 | +|------|-----------|------| +| Clash Verge | `clash-verge/v2.5.0` | clash-verge-rev `src-tauri/src/utils/network.rs` | +| v2rayN | `v2rayN/7.22.0` | v2rayN `ServiceLib/Common/Utils.cs`(无 `v` 前缀) | +| Clash Party | `mihomo.party/v2.0.0 (clash.meta)` | Clash Party `src/main/config/profile.ts` | +| FlClash | `FlClash/v0.8.93 clash-verge Platform/linux` | FlClash `lib/common/package.dart`(三段空格分隔) | + +另可选「自定义」自行填写。默认「不设置」= 保持原行为(发送下载工具自带的 UA)。 + +**验证**:4 个链接各用对应预设,均解析出 **310 个 vless 节点**(Clash 系 UA 返回 +Clash YAML,v2rayN 返回 base64 URI 列表);交叉验证用错预设仍是占位内容, +不设 UA 仍是 7 个假节点。 + +**安全**:UA 来自表单,属不可信输入。`http.validate_user_agent` 拒绝控制字符 +(换行会让 curl 把它当成额外请求头拼进去)与超过 256 字符的值;进入命令行前经 +`util.shq` 引用。与代理一致,**非法值明确失败而非静默忽略**(§12):静默忽略会让 +用户以为 UA 已生效,实际拿到的仍是占位节点。 + +**新增测试** `tests/user_agent_test.lua`(64 项,全部不触网):UA 取值校验、 +`-A`/`-U` 确实进入命令行且经 shell 引用、**重定向的每一跳**都带 UA、预设解析与往返、 +`core.add`/`core.sync` 的存储与透传、非法 UA 不发起下载、以及「表单 → 控制器 → +core」整条接线的端到端断言。已按反向验证确认:拆掉接线后对应断言确实失败 +(http 侧 6 项、控制器侧 5 项)。 + +## [2.6.12-r1] - 修复 [2.6.8-r1] 引入的「无法解析目标主机名」回归 + 格式下拉启用条件 + +### 修复:所有域名订阅在缺少 luci-lib-nixio 的设备上报「无法解析目标主机名」 + +**用户实测**:2.6.7-r1 能正常解析的订阅(`update.glados-config.com` 的 mihomo YAML、 +`s.feijiyunduijie999999.com` 的 quantumult / quantumultx / shadowrocket 三种格式), +从 2.6.8-r1 起状态一律变成「无法解析目标主机名」。 + +**根因**(`392c658`,2.6.8-r1):`http.check_public` 里「解析不出 IP」被改成一律 +fail-closed 拒绝,理由是「本包依赖 luci-lua-runtime,后者硬依赖 luci-lib-nixio, +所以解析失败只意味着真的解析不了,代价为零」。这个前提在用户设备上不成立: +`resolve()` 只有 `nixio.getaddrinfo` 一条路,nixio 取不到时恒返回 `nil`, +于是**每一个域名**都被判成「解析失败」——而 curl/wget 自带 libc 解析器, +照样能把域名解析出来并下载。真正的错误是把「本机没有解析手段」与 +「这个域名解析不出来」合并成了同一个 `nil`。 + +**修复**: +- `resolve()` 改为多级回退:`nixio.getaddrinfo` → busybox `nslookup`(`io.popen`, + 只取 `Name:` 段之后的 `Address:` 行,避免把 DNS 服务器自己的地址当解析结果)。 +- 返回值拆成 `ips, have_resolver`:有解析手段却解析不出来 → 仍 fail-closed + (`127.0.0.1.nip.io` 这类绕过口必须继续堵死);完全没有解析手段 → 放行, + 但返回第三个值 `unverified = true`。 +- 新增连接时对端校验 `verify_peer_ip`:`fetch_curl` 的 `-w` 同时取 + `%{http_code}` 与 `%{remote_ip}`,用**真正建立连接的那个 IP** 复核。 + 这既补上了 `unverified` 放行路径的校验,也顺带免疫「预检时解析到公网、 + 连接时解析到内网」的 DNS rebinding。重定向的**第一跳**同样复核 + (`Location` 检查只能拦第二跳)。走代理时跳过(`%{remote_ip}` 是代理地址)。 +- `unverified` 且拿不到对端 IP → 拒绝:「无法校验」不等于「放行」。 + +**验证**:同一台机器上对上述真实主机名调用 `check_public`, +`HEAD` 返回 `false / 无法解析目标主机名`,修复后返回 `ok = true`。 +新增 `tests/dns_fallback_test.lua`(36 断言,覆盖 nslookup 解析、Server 段误用、 +NXDOMAIN fail-closed、无解析手段放行、连接时对端校验、代理跳过), +对 `HEAD` 反向验证 **14 条失败**,修复后全绿。 + +### 优化:订阅列表「订阅链接转换」的格式下拉,未解析出节点时禁用 + +刚添加还没点「更新」、更新失败、或解析结果为空的订阅,`node_count` 为 0, +此时格式下拉置灰不可选(`disabled="disabled"` + `opacity:0.5` + +`cursor:not-allowed` + `title` 提示),下方说明文案同步切换为 +「更新并解析出节点后才能选择格式」(新增 msgid,已补 `po/zh-cn/substore.po`)。 +避免用户选出一个必然为空的订阅链接。 + +新增 `tests/subscriptions_format_gate_test.lua`(20 断言):把模板按 LuCI 的方式 +重建成 Lua chunk 并用替身环境**真实渲染**,再对生成的 HTML 断言 +(含「同一行在 node_count 变大后必须立刻变为可选」的反面对照)。 +对 `HEAD` 反向验证 **8 条失败**。 + +## [2.6.11-r1] - P4:遗留项决策后实施(1.1 / 1.3 / 1.4 / 1.5 / 2.5,3.3 补文档) + +`docs/LEGACY_ISSUES.md`「五」的推荐方案中,除标记为**暂缓**的(2.1 / 2.2 / 2.4 / +3.1 / 3.2 / 3.5)与**维持现状**的(2.3)外,其余全部实施。每项都补了自包含回归 +测试,并对 `HEAD` 做了反向验证 —— 新断言在修复前失败、修复后全绿: + +| 项 | 回归测试 | HEAD 上失败断言数 | +|---|---|---| +| 1.1 `mkdir` 锁 | `tests/list_lock_test.lua`(39 条) | 18 | +| 1.3 sing-box `transport` | `tests/singbox_transport_test.lua`(28 条) | 18 | +| 1.5 QX `tag=` 逗号 | `tests/qx_tag_comma_test.lua`(24 条) | 9 | + +全量:44 个 Lua 测试文件 + `tests/cron_result_test.sh`,全部通过。 + +### 1.1 订阅列表读写加锁(`load()` → `save()` 丢更新) + +- 读整表、改、写整表全程无互斥:LuCI 页面保存订阅、cron 定时更新、组合订阅自动 + 重算三者同时发生时,后写者整表覆盖先写者,**订阅静默丢失且不报错**。 +- 本机没有 `nixio`(用不了 `flock`),Lua 5.1 的 `io.open` 也没有 `O_EXCL`, + 因此用 `mkdir` 做锁(成功者唯一)。**陈旧锁回收**用锁目录**自身的 mtime** 判定, + 而不是目录里的文件:mtime 由 `mkdir` 原子设好,不存在「目录已建、时间戳还没写」 + 的窗口。阈值 `util.LOCK_STALE = 60` 秒;持有者被 kill 后残留的锁最多 60 秒即可回收, + 不会永久死锁(那比丢数据更糟 —— 订阅从此再也改不了)。 +- `core.lua` 新增 `with_list_lock`,把 7 个**写**入口(`add` / `add_local` / + `ensure_token` / `save_meta` / `remove` / `add_combo` / `save_combo`)统一包一层, + 而不是在每个函数体里手写 acquire/release —— 后者一旦有人中途 `return` + (`if lerr then return nil, lerr end` 这种)就会漏掉释放。包在最外层则无论从哪条 + 路径返回都会释放。 +- **可重入**:`save_combo` 内部会调 `save_meta`,两者都要保护;不可重入的话第二次 + 取锁会把自己挡在门外,组合订阅永远保存不了。本进程已持锁时只加计数、不再取锁。 +- 实施中修正了推荐方案本身的两处疏漏:① 锁目录最初写成常量 `M.LOCK_DIR`,而测试 + 普遍在 `require` 之后改写 `core.DATA_DIR`,常量不会跟着变 —— 会去锁真实的 + `/etc/substore`;改为由 `M.DATA_DIR` 现算。② 全新安装时 `DATA_DIR` 尚不存在, + `mkdir /.lock` 失败,而失败在 `lock_acquire` 眼里等同于「他人持锁」, + 症状是第一次保存订阅就报「正被另一个进程修改」;已在取锁前先 `ensure_dirs()`。 +- `M.merge` 是**只读**的(只读各订阅节点、过滤排序后返回数组),包进锁后一旦取锁 + 失败会返回 `nil` 顶掉原本的数组,把「拿不到锁」变成调用方眼里的「没有数据」—— + 比不加锁更糟。已移出包装列表。 + +### 1.3 sing-box 读取侧 transport 整层丢失 + +- 两个读取侧都不认 sing-box 的 `transport` 对象:`parser_json_config` 读的是 + `outbound.network`(sing-box 出站里**根本没有这个字段**),简易 YAML 解析器只展开 + `tls:` 子块。于是 ws / grpc / h2 节点全部按 tcp 导入 —— 客户端拿明文 tcp 去连 + 只开了 ws 的端口,握手必然失败且**不报错**。这类节点在机场导出的 sing-box 配置里 + 占比很高。简易 YAML 侧更彻底:`path` / `host` 连带都没带进 `node_data`。 +- 按上游 `configuration/shared/v2ray-transport/` 的字段名补全(已核实,非推测): + ws → `path` + `headers.Host`;grpc → `service_name`;http → `path` + `host` + (**数组**,取首个);httpupgrade → `path` + `host`(**单个字符串**)。`quic` + 在本项目模型里没有对应传输方式,**不猜映射**。 +- 顺带补上 `tls.utls.fingerprint` → `fp`:简易 YAML 侧早就读了这个字段,JSON 侧一直 + 漏着,同一条订阅走两条导入路径会得到不同的节点。 + +### 1.4 / 2.5 删除死代码 + +- 删除 `converter.lua`、`node_converter.lua`、`parser_yaml.lua` 三个模块与四个对应 + 测试文件。三者均已确认无任何**实际**引用:前两者只被彼此与测试引用; + `parser_yaml.lua` 只被 `parser.lua` 一行 `require` 引入且从未使用(该行一并删除)。 +- `tests/p2_batch7_test.lua` 中对已删模块的依赖改为直接验证 `util.uuid` 本身 —— + 那才是唯一在用的实现,形状要求(36 字符、只含十六进制与连字符)与当初一致。 +- `tests/parser_yaml_test.lua` **保留**:它实际 `require` 的是 `substore.parser` + (活跃路径),文件名有误导性但内容有效。 + +### 1.5 QX 节点名含逗号时整条丢弃 + +- `[server_local]` 行是逗号分隔的 `key=value` 序列,语法里没有引号 / 转义:节点名 + `A,B` 输出成 `..., tag=A,B` 会被读成 `tag=A` 加一个悬空字段。QX 对这种行的实际 + 处置未经核实(本机没有 Quantumult X 可实测),但无论报错还是静默忽略,用户拿到的 + 都不是他填的那个名字。 +- 按推荐方案 B **整条丢弃**:定义行与 `[policy]` 成员一并去掉,与 Surge 家族丢 + wireguard / ssr、Clash 原版丢不支持协议同一约定,也与 `names_of` 的排除条件 + 保持一致(判定用 `one_line` 之后的同一个字符串,两边不会再对不上)。 +- Surge 家族**不受影响**:其 `[Proxy]` 行是 `NAME = type, host, port, …`,名字在 + `=` 左侧,逗号不影响该行解析。 + +### 3.3 改名规则语法补文档说明 + +- Lua 模式无 alternation 语义。`|` 表示「或」但只在**顶层**生效:`(a|b)` 不会展开成 + 「a 或 b」,而是按字面匹配;字符类 `[...]` 内的 `|` 同样是字面。已在 + `README.md` / `README.en.md` 的「使用方法」补上说明。**无代码改动。** + +## [2.6.10-r1] - P3:输出层代码级复审(11 项)+ README 精简 + +对 8 个输出模块与 `output.lua` 做了逐行复审,按「生成的配置能否被目标客户端加载」 +这一条标准筛出 10 项缺陷;另在复核 shadowsocks 数据通路时发现 SIP003 插件被全链路 +丢弃,共 11 项。**全部已修复、已补回归测试,并对 `HEAD` 反向验证**: +`tests/output_layer_fixes_test.lua`(47 条断言)与 `tests/ss_plugin_test.lua` +(41 条断言)在修复前分别失败 30 / 25 条,修复后全绿。 + +共同特征与 P2 批次七一致 —— **都不报错**:导出成功、客户端却拒绝加载或静默跑错。 + +### 输出层 + +- **F1 `ws-opts` 父键缺失**(`output_clash_meta`):`ws-opts:` 那一行写在 + `if node.path` 里面,于是「有 host、无 path」的 ws 节点输出一个缩进 6 空格的 + `headers:`,而它的父键根本不存在 —— YAML 直接报 `mapping values are not allowed + here`,客户端拒绝整份配置。改为 path 与 headers 共用同一个 `ws-opts` 父键。 +- **F2 空传输层编码成数组**(`output_v2ray`):`wsSettings` / `grpcSettings` / + `httpSettings` 无参数时被 `json_encode` 的 `is_array` 判成空表,编码为 `[]`。 + Xray 用标准库 `json.Unmarshal` 解析,这些字段是结构体指针,解进数组直接 + `UnmarshalTypeError` 拒绝启动。触发条件很普通:grpc 节点没填服务名、 + ws 节点既没 path 也没 host。为 `JSON_EMPTY_OBJECT` 加元表标记,编码为 `{}`; + 解码出的空对象不带该标记,后续写入键仍照常编码。 +- **F3 节点重名不消解**(`output_clash_meta`):mihomo 的 `proxies` 里 `name` 是 + 主键,重名直接拒绝加载整份配置。节点之间重名、节点与生成的策略组 + (`Proxy` / `URL-Test` / `Load-Balance`)或保留名(`DIRECT` / `REJECT`)撞名 + 都会触发。改用既有的 `util.unique_tags` 统一分配名字。 +- **F4 未引用的非法 YAML 标量**(`output_clash_meta`):`esc_yaml` 的需引号字符类 + 漏了 `%`(YAML 指令前缀)、`!`(标签前缀)与行首 `-`(块序列项),这三种开头的 + 裸标量都是非法 YAML。 +- **F9 `amnezia-wg-option` 子键未转义**(`output_clash_meta`):子键来自导入的 + YAML / JSON(不可信),键名里的 `:` 或引号会写坏映射。 + +### Surge 家族 / Quantumult X + +- **F5 QX 的 vless 丢传输层**(`output_formats`):QX 的 vless 与 vmess 共用同一套 + 参数名,但只有 vmess 分支写了 `obfs` / `obfs-uri` / `obfs-host` / `tls-host` / + `tls-verification`。vless + ws + tls 的节点导出成明文 tcp 条目,客户端按 tcp 去连 + 只开了 ws 的端口,必然失败且不报错。 +- **F6 Surge 家族的 vless 丢传输层**:同上,`ws=true` / `ws-path` / `ws-headers` + 一个都没写。抽出 `add_ws()` 供 vmess / vless 共用。 +- **F7 参数值里的逗号静默截断凭据**:`[Proxy]` 行与 QX 的 `[server_local]` 行都是 + 逗号分隔的 `key=value` 序列,语法里没有引号 / 转义机制 —— `password=pa,ss` 会被 + 读成 `password=pa` 加一个悬空的 `ss`。含逗号的整条丢弃,并同步从成员列表 + (`[Proxy Group]` / `[policy]`)剔除,避免引用不存在的代理。 + 顺带把 `to_qx` 从「边拼字符串边追加」改为「先把具名字段攒成列表再拼接」: + 原先的实现无法区分行内本就有的 `, ` 结构分隔符与值里的逗号,会把每一条合法行 + 都误判成非法。 +- **多值 alpn 的处置**:`tests/output_formats_test.lua` 原断言 tuic 的 alpn 数组 + 输出 `alpn=h3,h2`。多值 alpn 在 Surge 家族的行语法里同样表达不了,而 F7 的通用 + 检查会因此丢掉整个节点。alpn 只是**协商提示**(缺省时客户端用服务端给出的列表), + 不像凭据一旦截断就静默发错值 —— 所以只省略该参数、保留节点。断言已按此契约更新。 + +### 分享链接 + +- **F8 IPv6 字面量未加方括号**(`output_uri`):RFC 3986 的 authority 里 IPv6 必须 + 写成 `[addr]`,否则 `::` 与端口分隔符无法区分。已是方括号形态的不重复包裹。 + +### shadowsocks SIP003 插件(F11) + +`plugin` 能被解析、能通过 `node.normalize` 存活,但三个输出模块全都静默丢掉它, +表单侧还会在保存时清空 —— 带 obfs / v2ray-plugin 的节点导出后以明文 SS 去连只接受 +带插件握手的服务端,必然失败且不报错;在界面上编辑一次该节点,插件配置即永久消失。 + +处置依据均核对上游源码 / 文档(非推测): + +- **SIP002**:`SS-URI = "ss://" userinfo "@" host ":" port [ "/" ] [ "?" plugin ] [ "#" tag ]`, + 插件参数整体做百分号编码。新增 `util.parse_sip003_plugin`(含 `\;` `\=` 反斜杠转义)。 +- **sing-box**:`shadowsocks` 出站只有 `plugin`(字符串,官方文档明确 *"Only two are + supported: obfs-local and v2ray-plugin"*)与 `plugin_opts`(SIP003 原始参数串, + 原样透传)。按白名单过滤,其余名字会被拒绝加载整份配置。 +- **mihomo**:`plugin-opts` 是**映射**而非字符串,名称与参数名都要翻译 + (`obfs-local` / `simple-obfs` → `obfs`,参数 `obfs` → `mode`、`obfs-host` → `host`; + `v2ray-plugin` 的 `mode` / `host` / `path` / `tls`)。`adapter/outbound/shadowsocks.go` + 对这两类插件是**强校验**的:obfs 的 mode 不在 `{tls,http}` 里报 + `"ss %s obfs mode error"`,v2ray-plugin 的 mode 不是 `websocket` 同样报错 —— + 都会让整个 outbound 构造失败,进而拒绝加载整份配置。因此参数不全时宁可整个不输出 + 插件,也不能输出一个必然被拒绝的组合。 + +模型与表单侧:`node.PROTO_FIELDS.shadowsocks` 补 `plugin`(不进这个清单,表单不渲染 +它,且 `core.merge_form_node` 会在保存时把原值清掉)、`core.FORM_KEYS` 补 `plugin` +(不进这个表,用户在表单里清空输入框也删不掉旧值),两个视图补字段标签。 + +### 其他 + +- **F10 空 target 的响应头与后缀落空**(`output.lua`):`?target=` 传进来的是 `""`, + 而 `""` 在 Lua 里是**真值**,`format or DEFAULT_FORMAT` 兜不住它 —— `M.generate` + 一直在做归一化,但 `content_type_for` / `extension_for` 漏了,同一请求里正文按 + clashmeta 生成,Content-Type 变成 `nil`(响应头缺失)、文件名后缀退回兜底的 `.txt`。 + 抽出 `normalize_format` 三处共用。 +- **`parser.lua` 集中丢弃残缺节点**:`finish()` 统一做 `valid_hostport` 校验, + YAML / JSON / Surge / wireguard-conf 路径与 URI 路径行为一致。 +- **`core.cron_time_valid` 收紧**:要求恰好 5 个空白分隔字段、无控制字符。 +- **`controller` 的 `action_node_set_group`** 补 `core.refresh_combos(id)`, + 与 `node_save` / `node_delete` 对齐。 +- **`util.json_decode` 深度上限 64**,防止深嵌套输入耗尽栈。 +- **README / README.en 的「功能特性」精简**:删去逐字段罗列与「协议转换:任意协议 → + 任意协议」的过度声明(实现侧并无该能力,见 `docs/LEGACY_ISSUES.md` 1.4)。 + +### 文档 + +- `docs/LEGACY_ISSUES.md`:新增「四、P3 输出层复审」逐项记录;新增「五、其余遗留项 + 的修复建议」给出推荐方案与理由;订正 2.6(AWG 3.0/3.1 九字段,`[2.6.10-r1]` 复核 + 已收录)与 3.4(`check_public` 已改 fail-closed)两处过期状态;1.3 补入已核实的 + sing-box `transport` 权威字段映射,供下一轮直接实施。 + ## [2.6.9-r1] - P2 批次七:界面 / 探测 / 转换 / 权限(L1 / L5 / L6 / L8 / L9 / L23 / L24 / L25 / L26) P2 最后一批,九项分布在界面、探测、转换与文件权限四处。共同点是**问题都不报错**: diff --git a/luci-app-substore/Makefile b/luci-app-substore/Makefile index cf710307f..7bcaf8bdb 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.9 -PKG_RELEASE:=14 +PKG_VERSION:=2.7.0 +PKG_RELEASE:=15 LUCI_DEPENDS:=+luci-lua-runtime +luci-compat @@ -74,6 +74,10 @@ define Package/luci-app-substore/install $(INSTALL_DIR) $(1)/usr/share/luci/menu.d $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-substore.json $(1)/usr/share/luci/menu.d/luci-app-substore.json + # ACL 组:菜单 depends.acl 引用它,未授权的 LuCI 用户看不到本应用 + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-substore.json $(1)/usr/share/rpcd/acl.d/luci-app-substore.json + # 简体中文翻译(运行时 zh-cn 时由 luci.template.parser 自动加载 *.zh-cn.lmo) $(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n po2lmo ./po/zh-cn/substore.po $(1)/usr/lib/lua/luci/i18n/substore.zh-cn.lmo diff --git a/luci-app-substore/README.en.md b/luci-app-substore/README.en.md index f6eaae831..0a8440fa6 100644 --- a/luci-app-substore/README.en.md +++ b/luci-app-substore/README.en.md @@ -11,139 +11,93 @@ group them, then re-emit them in a format your client can consume. ## Features **Subscription management** -- Add / edit / delete / update multiple subscription sources -- Manual update plus per-subscription timed (cron) updates -- Status overview: node count, last update time, error message -- Remaining traffic & remaining duration per subscription, parsed from the - `subscription-userinfo` response header (shown on the edit page only) -- **Subscription proxy**: download the subscription through an - `http://` / `https://` / `socks4` / `socks5` / `socks5h` proxy — useful when the - source is unreachable directly -- **Combination subscription**: merge an arbitrary subset of existing subscriptions - (optionally with keyword include / exclude and dedup rules) into one combination that - has its own name, token and subscription link; combinations are recomputed automatically - when a source updates -- **Local subscription**: no URL needed — paste node text (YAML / URI / JSON / - wg-quick `.conf`) or enter nodes one by one via a form whose fields adapt to the - selected protocol. A paste is detected as **one** format (YAML / URI / JSON / - `.conf`); mixing several formats in one paste is not supported - (vmess / vless / ss / ssr / trojan / hysteria / hysteria2 / tuic / wireguard / socks) +- Add / edit / delete / update multiple subscription sources, with manual and + per-subscription scheduled (cron) updates; an overview shows node counts, last + update time and error messages +- **Batch delete**: tick one or more subscriptions (the header checkbox selects + all) and hit Delete to remove them in one go; with nothing ticked, Delete + removes nothing +- Remaining traffic / expiry per subscription (parsed from the `subscription-userinfo` + response header) +- **Subscription proxy**: fetch through `http` / `https` / `socks4` / `socks5` / + `socks5h`, for sources that cannot be reached directly +- **Subscription client type**: the `User-Agent` sent when fetching, set per + subscription, with Clash Verge / v2rayN / Clash Party / FlClash presets and a + custom value (the raw `User-Agent` header to send verbatim, e.g. + `clash-verge/v2.5.0`; at most 256 chars, no control characters). + Some providers hand out nodes by UA — the same link only yields the + real nodes for its bound client; a wrong UA returns a "client incompatible" + placeholder (which still parses, but is nothing but fake `127.0.0.1:1080` nodes) +- **Combined subscriptions**: merge any subset of existing subscriptions into a new + one with its own name, token and link; it is recomputed when a source updates or + is **deleted** (the dead id is pruned from its sources; deleting every source + reports an error instead of silently becoming a 0-node combo) +- **Local subscriptions**: paste node text directly (one format at a time, + auto-detected) instead of providing a URL, or enter nodes field by field **Input parsing** -- Subscription formats: URI lists, Base64, JSON, Clash YAML, sing-box JSON, - V2Ray / Xray JSON, Surge / Surfboard / Loon / Quantumult X configs, LAN - subscription links, and wg-quick / AmneziaWG `.conf` -- Node protocols: `vmess` / `vless` / `trojan` / `shadowsocks` / `ssr` / `hysteria` / - `hysteria2` / `tuic` / `wireguard` / `socks` (plus `http`, which can be imported from - Clash YAML / JSON configs and exported, but is not offered in the form importer — - it is not served as a proxy node) -- **WireGuard / AmneziaWG**: full field import and export (`private-key` / `public-key` / - `pre-shared-key` / `ip` / `ipv6` / `allowed-ips` / `reserved` / `persistent-keepalive` / - `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 +- Formats: URI list, Base64, JSON, Clash YAML, sing-box JSON, V2Ray / Xray JSON, + Surge / Surfboard / Loon / Quantumult X configs, wg-quick / AmneziaWG `.conf` +- Protocols: `vmess` / `vless` / `trojan` / `shadowsocks` / `ssr` / `hysteria` / + `hysteria2` / `tuic` / `wireguard` / `socks` + (`http` can be imported and exported, but is not offered in the node form) +- **WireGuard / AmneziaWG**: all fields plus the `amnezia-wg-option` block; an + AmneziaWG client `.conf` can be pasted as-is +- **Tolerant parsing**: nodes missing `server`, or with a port outside 1–65535, are + dropped during parsing — otherwise they would be written into a config the client + refuses to load, and one bad node would break the whole subscription -**Node processing** -- Browse nodes, filter by group / protocol, keyword search, sort -- Node grouping: set a node's group inline in the Group column (saved via XHR without - reload), filter via the Group dropdown -- 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 (available on all three forms: - subscription / combination / local subscription): - - keyword include / exclude (comma-separated, multi-keyword) - - 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) +**Node handling** +- Filter by group / protocol, keyword search, sorting; a node's group is editable + directly in the table (saved over XHR) +- Edit / delete a single node, or select several and delete them in bulk +- Per-subscription rules: keyword include / exclude, protocol filter, dedup, and + rename (exact match / regex / placeholder template) -**Network probing** (Nodes page) -- Ping (ICMP latency), TCPing (connect latency), URL test (HTTP latency) -- Parallel probing, success count and average latency +**Network probing** (nodes page) +- Ping (ICMP), TCPing (TCP connect) and URL test (HTTP), run in parallel with a + success count and average latency **Conversion & output** -- Protocol conversion: any node type → any other type -- SSR (`ssr://`) input is re-emitted losslessly to SSR-capable clients only — Mihomo / - Clash.Meta, Stash, Loon, Egern, Shadowrocket — and dropped for the rest (sing-box, - V2Ray/Xray, Surge family), since SSR is not convertible to/from other protocols -- 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]` / - `[Peer]` sections and AmneziaWG obfuscation parameters, importable by AmneziaWG clients - - **sing-box / V2Ray (Xray)**: emits a **complete, runnable config** (`outbounds` plus - routing), not just an `outbounds` fragment - - sing-box: node outbounds + `selector` (manual switch) + `urltest` (auto latency test) + - `direct` / `block`; `route.final` points at the selector, with a built-in private-IP - direct rule - - 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" +- 15 output formats: Plain JSON, Stash, Clash.Meta / Mihomo, Clash (original), + Surfboard, Surge, Surge Mac, Loon, Egern, Shadowrocket, Quantumult X, sing-box, + V2Ray / Xray, V2Ray URI, WireGuard / AmneziaWG `.conf` +- SSR (`ssr://`) can only be emitted to clients that support it (Mihomo, Stash, Loon, + Egern, Shadowrocket); other targets drop it +- **Only content the target client can actually load is emitted**: protocols are + filtered by target capability, array-valued fields use the type the client expects, + and proxy-group member lists drop node names that would break their syntax +- sing-box / V2Ray(Xray) output is a **complete working config** (including routing), + usable as a single-file config **Subscription links** -- Per-subscription random token → public download endpoint - `/substore/download?token=&target=` that Passwall / OpenClash and - other clients can pull directly +- A random per-subscription token backs the public download endpoint + `/substore/download?token=&target=`, so Passwall / OpenClash can + pull it directly +- The format dropdown on the list page stays greyed out until the subscription has + actually parsed some nodes (just added, update failed, or node count 0), so you + cannot generate a subscription link that is bound to be empty -**LuCI web UI & i18n** -- English by default, 简体中文 auto-selected when the runtime language is `zh-cn` +**LuCI interface & i18n** +- English by default, Simplified Chinese when the runtime language is `zh-cn` ## Installation > The version in the package name must match `PKG_VERSION` / `PKG_RELEASE` in the -> [Makefile](Makefile) (currently `2.6.9-r1`). +> [Makefile](Makefile) (currently `2.7.0-r1`). + +**Minimum supported: OpenWrt / ImmortalWrt 23.05** (older releases are out of scope). opkg (OpenWrt / ImmortalWrt 24.10 and earlier): ```bash -opkg install luci-app-substore-2.6.9-r1.ipk +opkg install luci-app-substore-2.7.0-r1.ipk ``` apk (OpenWrt / ImmortalWrt 25.12+): ```bash -apk add --allow-untrusted luci-app-substore-2.6.9-r1.apk +apk add --allow-untrusted luci-app-substore-2.7.0-r1.apk ``` Then open LuCI: **Services → Subscriptions**. @@ -160,6 +114,47 @@ Then open LuCI: **Services → Subscriptions**. 4. **Export** — pick one of the 15 output formats, or copy the subscription link to feed a downstream client (Passwall / OpenClash / …). +> **Rename rules match with Lua patterns, not PCRE.** `|` means "or", but only at +> the **top level**: `(a|b)` is not expanded into "a or b" — it matches literally, +> requiring the name to actually contain `a|b`. Write `a|b` for alternatives, or +> use separate rules. A `|` inside a character class `[...]` is literal too. + +## Known limitations + +Each of these is a **code-audit-confirmed** limitation kept deliberately, not an +oversight; the reasoning for each is in +[docs/LEGACY_ISSUES.md](docs/LEGACY_ISSUES.md) (Chinese). + +- **Mixed-format text import is rejected** — pasted node text may contain only one + format at a time (URI / Base64 / YAML / JSON / WireGuard `.conf`); protocols may + be mixed *within* that format. Mixing formats fails with an explicit error naming + the formats found, instead of silently dropping part of the input. + **Remote subscriptions are not affected**: mixed content there is still resolved + by priority to a single format (unchanged historical behaviour). +- **hysteria v1 exported to sing-box needs `up` / `down` (bandwidth) added by hand** — + the node model carries no bandwidth field, and inventing a default would be a guess. +- **hysteria v1 URIs are only guaranteed round-trip faithful** (this package's export → + this package's import) — the upstream URI spec could not be verified (its docs site + has been 404ing), so unrecognised query parameters are ignored rather than + given an invented meaning. +- **`http` cannot be created by hand in the UI** — it is absent from the authoritative + protocol list, so the form does not offer it; it can still be imported from + Clash / JSON configs and exported normally. +- **AmneziaWG options are a single JSON text box** in the UI (`amnezia-wg-option`) — + the data round-trips correctly, but has to be written as JSON by hand. +- **On the wget backend, size and redirect checks happen *after* the request is sent** — + busybox wget has no equivalent of `--max-filesize` / `--max-redirect`. An oversized + response is **discarded and reported as an error** (it never reaches the parser), and + every redirect hop is **re-checked** with any private/reserved hop rejecting the whole + download; the only difference is that both checks land after the request went out. + **Installing curl avoids this entirely** (the curl path uses `--max-filesize` and + `--max-redirs 0` to stop before sending). +- **On a box with no DNS resolution capability at all, the wget backend refuses to + download domain subscriptions** — pre-flight validation cannot check the target there, + and wget lacks curl's `%{remote_ip}` for a post-connect re-check, so it refuses rather + than allowing an unverifiable target (the error suggests installing curl). Literal IP + targets and boxes that do have a resolver are unaffected. + ## Project layout ``` @@ -177,6 +172,7 @@ Then open LuCI: **Services → Subscriptions**. │ │ │ └── view/substore/*.htm # templates │ │ └── share/ │ │ ├── luci/menu.d/luci-app-substore.json +│ │ ├── rpcd/acl.d/luci-app-substore.json # ACL group (referenced by menu depends.acl) │ │ └── substore/*.lua # core logic (no luci.* dependency) ├── po/zh-cn/substore.po # 简体中文 translations ├── docs/ # design & guides @@ -223,13 +219,29 @@ Target-device verification is required for the LuCI UI and cron behaviour — se ## Security 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. +**fails to resolve is rejected** when a resolver is available, so "unresolvable ⇒ pass" +is not a bypass; every redirect hop is re-checked), 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. + +**On a box with no DNS resolver** (neither nixio nor `nslookup`), domain targets cannot +be validated before the request; the curl backend then re-checks the actual peer address +via `%{remote_ip}` **after connecting** (which also closes the DNS-rebinding TOCTOU +window), while the wget backend has no equivalent and **refuses** instead of allowing it +(see "Known limitations"). + +**Form submission validation**: write actions require a `token` that is present and +non-empty, and — when available — equal to LuCI's `context.authtoken` (the very value +the framework's templates put in `token`); a failure is **reported back** rather than +silently dropped. **Access control**: the `luci-app-substore` ACL group takes effect +through `menu.d`'s `depends.acl` — unauthorised LuCI users do not see the app, and +**direct URL access is refused with 403** (the ucode dispatcher validates the +`depends.acl` accumulated along the request path at dispatch time; verified against +upstream source **and measured on a device**). See +[docs/SECURITY.md](docs/SECURITY.md) for how to grant it. Data on disk: `/etc/substore` is `0700`, and `subscriptions.json` / `nodes/*.json` are `0600` — the former holds subscription URLs and public download tokens, the diff --git a/luci-app-substore/README.md b/luci-app-substore/README.md index 23d46d69c..bb7275fb5 100644 --- a/luci-app-substore/README.md +++ b/luci-app-substore/README.md @@ -13,94 +13,58 @@ ## 功能特性 **订阅管理** -- 新增 / 编辑 / 删除 / 更新多个订阅源 -- 手动更新 + 按订阅的定时(cron)更新 -- 状态总览:节点数、最近更新时间、错误信息 -- 每个订阅的剩余流量 / 剩余时长,从 `subscription-userinfo` 响应头解析(仅在编辑页显示) -- **订阅代理**:通过 `http://` / `https://` / `socks4` / `socks5` / `socks5h` 代理下载订阅, +- 多订阅源的新增 / 编辑 / 删除 / 更新,支持手动更新与按订阅的定时(cron)更新; + 状态总览显示节点数、最近更新时间与错误信息 +- **勾选批量删除**:勾选一条或多条订阅(表头选择框可全选)后点「删除」一次删掉; + 一条都没勾选时不会删除任何东西 +- 每个订阅的剩余流量 / 剩余时长(解析 `subscription-userinfo` 响应头) +- **订阅代理**:经 `http` / `https` / `socks4` / `socks5` / `socks5h` 代理下载订阅, 用于订阅源直连失败时 -- **组合订阅**:勾选任意子集的现有订阅(可叠加关键词包含 / 排除、去重规则)合并成一个组合, - 拥有独立名称、token 与订阅链接;源订阅更新后组合自动重算 -- **本地订阅**:不填 URL,直接粘贴节点文本(YAML / URI / JSON / wg-quick `.conf`)导入, - 一次粘贴按**单一格式**识别(自动判定 YAML / URI / JSON / `.conf`,不支持多格式混排), - 或用「表单导入」按协议动态字段逐条录入节点 - (vmess / vless / ss / ssr / trojan / hysteria / hysteria2 / tuic / wireguard / socks) +- **订阅客户端类型**:按订阅指定下载时发送的 `User-Agent`,内置 Clash Verge / + v2rayN / Clash Party / FlClash 预设,也可自定义(直接填要原样发送的 UA 请求头, + 形如 `clash-verge/v2.5.0`,最长 256 字符、不含控制字符)。部分机场按 UA 下发节点 —— + 同一个链接只有用绑定的客户端才解析得出真实节点,用错会得到「与您使用客户端不兼容」 + 的占位节点(能解析成功,但全是 `127.0.0.1:1080` 的假节点) +- **组合订阅**:把任意子集的现有订阅合并成一个新订阅,拥有独立名称、token 与订阅链接; + 源订阅更新或**被删除**后组合自动重算(删除时死 id 会从「来源」里摘掉, + 来源被删光则报错,而不是静默变成 0 节点) +- **本地订阅**:不填 URL,直接粘贴节点文本导入(一次一种格式,自动判定), + 或用表单按协议动态字段逐条录入节点 **输入解析** - 订阅格式:URI 列表、Base64、JSON、Clash YAML、sing-box JSON、V2Ray / Xray JSON、 - Surge / Surfboard / Loon / Quantumult X 配置、局域网订阅链接、wg-quick / AmneziaWG `.conf` -- 节点协议:`vmess` / `vless` / `trojan` / `shadowsocks` / `ssr` / `hysteria` / `hysteria2` / - `tuic` / `wireguard` / `socks`(及 `http`,可由 Clash YAML / JSON 配置导入并导出, - 但不在表单导入的可选协议内 —— 它不作为代理节点对外提供订阅) -- **WireGuard / AmneziaWG**:导入并导出完整字段(`private-key` / `public-key` / `pre-shared-key` / - `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]` 只跳过它自己、 - 不作废整份文件;纯空白内容按「空订阅」处理,而非报「无法识别的订阅格式」 + Surge / Surfboard / Loon / Quantumult X 配置、wg-quick / AmneziaWG `.conf` +- 节点协议:`vmess` / `vless` / `trojan` / `shadowsocks` / `ssr` / `hysteria` / + `hysteria2` / `tuic` / `wireguard` / `socks` + (`http` 可导入并导出,但不在表单可选协议内) +- **WireGuard / AmneziaWG**:完整字段与 `amnezia-wg-option` 子块, + 可直接粘贴 AmneziaWG 客户端导出的 `.conf` 内容 +- **解析容错**:缺 `server` 或端口不在 1–65535 的残缺节点在解析阶段即丢弃 —— + 否则会被写成客户端无法加载的配置,一个坏节点废掉整个订阅 **节点处理** -- 浏览节点,按分组 / 协议筛选、关键词搜索、排序 -- 节点分组:「分组」列单元格内直接修改单节点分组(XHR 无刷新保存),配合「分组:」下拉筛选 -- 单节点编辑 / 删除(行尾「操作」列);表头复选框全选、行复选框勾选后点「删除」批量删除; - 「刷新」按钮重载列表(保留当前筛选条件) -- 每次更新时生效的按订阅规则(订阅 / 组合 / 本地订阅三种表单均提供): - - 关键词包含 / 排除(逗号分隔,支持多关键词) - - 协议筛选(勾选保留哪些协议,全不勾选 = 不筛选) - - 去重(同一入口的多账号不会被误合并:去重键含各协议各自的凭据) - - 重命名(每行一条:`旧名=新名` 精确匹配、`模式 -> 替换` 正则替换、 - `{server}_{port}_{proto}` 占位符模板) +- 按分组 / 协议筛选、关键词搜索、排序;分组可在表格内直接修改(无刷新保存) +- 单节点编辑 / 删除,勾选后批量删除 +- 按订阅规则:关键词包含 / 排除、协议筛选、去重、重命名(精确匹配 / 正则 / 占位符模板) **网络探测**(节点页) -- Ping(ICMP 延迟)、TCPing(连接延迟)、URL 测试(HTTP 延迟) -- 并行探测,显示成功数与平均延迟 +- Ping(ICMP)、TCPing(TCP 连接)、URL 测试(HTTP),并行探测并显示成功数与平均延迟 **转换与输出** -- 协议转换:任意协议 → 任意协议 -- SSR(`ssr://`)订阅源支持:仅能原样输出到支持它的客户端(Mihomo / Clash.Meta、Stash、 - Loon、Egern、Shadowrocket),对其余目标(sing-box、V2Ray/Xray、Surge 家族)丢弃;SSR - 不能与 vmess/vless 等其它协议互转(协议不兼容) -- 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]` 与 - AmneziaWG 混淆参数,可直接导入 AmneziaWG 客户端 - - **sing-box / V2Ray(Xray)**:输出**完整可用配置**(`outbounds` + 分流),而非仅有 - `outbounds` 的片段 - - sing-box:节点出站 + `selector`(手工切换)+ `urltest`(自动测速)+ `direct` / `block`, - `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 / 表单导入的节点不再丢掉「跳过证书校验」 +- 15 种输出格式:Plain JSON、Stash、Clash.Meta / Mihomo、Clash 原版、Surfboard、Surge、 + Surge Mac、Loon、Egern、Shadowrocket、Quantumult X、sing-box、V2Ray / Xray、 + V2Ray URI、WireGuard / AmneziaWG `.conf` +- SSR(`ssr://`)只能原样输出到支持它的客户端(Mihomo、Stash、Loon、Egern、Shadowrocket), + 其余目标会将其丢弃 +- **只输出目标客户端真正能加载的内容**:按目标能力过滤协议;数组字段按客户端要求的类型 + 输出;策略组成员列表剔除会破坏语法的节点名 +- sing-box / V2Ray(Xray) 输出**完整可用配置**(含分流规则),可直接作为单文件配置启动 **订阅链接** - 每个订阅独立随机 token → 公开下载端点 - `/substore/download?token=&target=`,Passwall / OpenClash 等客户端可直接拉取 + `/substore/download?token=&target=`,Passwall / OpenClash 等可直接拉取 +- 列表页的格式下拉在订阅**解析出节点之前**(刚添加、更新失败、节点数为 0) + 置灰不可选,避免生成必然为空的订阅链接 **LuCI 界面与国际化** - 默认英文,运行时语言为 `zh-cn` 时自动显示简体中文 @@ -108,18 +72,20 @@ ## 安装 > 包名中的版本号必须与 [Makefile](Makefile) 的 `PKG_VERSION` / `PKG_RELEASE` 保持一致 -> (当前 `2.6.9-r1`)。 +> (当前 `2.7.0-r1`)。 + +**最低支持 OpenWrt / ImmortalWrt 23.05**(更早的版本不在支持范围内)。 opkg(OpenWrt / ImmortalWrt 24.10 及更早): ```bash -opkg install luci-app-substore-2.6.9-r1.ipk +opkg install luci-app-substore-2.7.0-r1.ipk ``` apk(OpenWrt / ImmortalWrt 25.12+): ```bash -apk add --allow-untrusted luci-app-substore-2.6.9-r1.apk +apk add --allow-untrusted luci-app-substore-2.7.0-r1.apk ``` 然后在 LuCI 菜单打开:**服务 → 订阅**。 @@ -133,6 +99,37 @@ apk add --allow-untrusted luci-app-substore-2.6.9-r1.apk 可批量删除,行内可编辑 / 删除 / 改分组,「刷新」重载列表。 4. **导出** —— 任选 15 种格式之一,或复制订阅链接供下游客户端(Passwall / OpenClash / …)使用。 +> **重命名规则的匹配语法是 Lua 模式,不是 PCRE**。`|` 表示「或」,但只在**顶层** +> 生效:写成 `(a|b)` 不会展开成「a 或 b」,而是按字面匹配(要求名字里真的出现 +> `a|b`)。多分支直接写 `a|b`,或拆成多条规则。字符类 `[...]` 内的 `|` 同样是字面。 + +## 已知限制 + +以下均为**经代码级审计确认**的限制,是有意保留而非疏漏;每项「为何不改」的依据见 +[docs/LEGACY_ISSUES.md](docs/LEGACY_ISSUES.md)。 + +- **混合格式文本导入会被拒绝** —— 本地粘贴的节点文本一次只能是一种格式 + (URI / Base64 / YAML / JSON / WireGuard `.conf`),同一种格式内可混用协议。 + 混用会**明确报错**并列出检出的格式,而不是静默丢掉一部分节点。 + **远程订阅不受此检查影响**:订阅内容若混用格式,仍按优先级取一种(与历史行为一致)。 +- **hysteria v1 导出到 sing-box 需自行补 `up` / `down`(带宽)** —— 本项目的节点模型 + 不承载带宽字段,凭空填默认值属于猜测,故不输出。 +- **hysteria v1 的 URI 只保证「本包导出 → 本包导入」不失真** —— 上游 URI 规范 + (文档站点持续 404)未能核实,因此认不出的查询参数一律忽略,不臆造其语义。 +- **`http` 协议不能在界面上手工新建** —— 权威协议表不含它,故表单不提供该选项; + 但它可由 Clash / JSON 配置导入,并能正常导出。 +- **AmneziaWG 参数在界面上是一个 JSON 文本框**(`amnezia-wg-option`)—— 数据往返 + 正确,但需手写 JSON。 +- **wget 后端的体积与重定向检查发生在「请求发出之后」** —— busybox wget 没有 + `--max-filesize` / `--max-redirect` 的等价选项。超限响应体会被**丢弃并报错** + (不会进入解析流程),重定向链也会**逐跳复检**、任一跳指向内网即整体拒绝; + 差别仅在于这两项检查晚于请求发出。**安装 curl 可完全避免**(curl 路径用 + `--max-filesize` 与 `--max-redirs 0` 在发出前拦截)。 +- **本机完全没有 DNS 解析能力时,wget 后端会拒绝下载域名订阅** —— 这类设备上预检 + 无法校验目标地址,而 wget 又没有 curl 的 `%{remote_ip}` 可用于「连接后复核」, + 按「校验不了就拒绝」处理(错误信息会提示安装 curl)。字面 IP 目标与有解析手段的 + 设备不受影响。 + ## 目录结构 ``` @@ -150,6 +147,7 @@ apk add --allow-untrusted luci-app-substore-2.6.9-r1.apk │ │ │ └── view/substore/*.htm # 模板 │ │ └── share/ │ │ ├── luci/menu.d/luci-app-substore.json +│ │ ├── rpcd/acl.d/luci-app-substore.json # ACL 组(菜单 depends.acl 引用) │ │ └── substore/*.lua # 核心逻辑(不依赖 luci.*) ├── po/zh-cn/substore.po # 简体中文翻译 ├── docs/ # 设计与指南 @@ -192,12 +190,24 @@ LuCI 界面与 cron 行为仍需在目标设备上验证 —— 见 [docs/TESTIN ## 安全 -SSRF 防护(拒绝内网 / 保留 / 链路本地地址;**DNS 解析失败即拒绝**,不给 -「解析不出来就放行」留绕过口)、协议白名单与端口范围校验(1–65535)、 -响应体大小与超时限制、下载临时文件在每条退出路径上清理(`/tmp` 是 tmpfs)、 -命令注入防护(白名单解析 + shell 引用 + 探测目标拒绝以 `-` 开头的主机名)、 +SSRF 防护(拒绝内网 / 保留 / 链路本地地址;**有解析手段却解析不出即拒绝**,不给 +「解析不出来就放行」留绕过口;重定向链**逐跳复检**)、协议白名单与端口范围校验 +(1–65535)、响应体大小与超时限制、下载临时文件在每条退出路径上清理(`/tmp` 是 +tmpfs)、命令注入防护(白名单解析 + shell 引用 + 探测目标拒绝以 `-` 开头的主机名)、 公开下载端点基于 token 的访问控制、日志不含凭据。 +**无 DNS 解析能力的设备**(nixio 与 nslookup 均不可用)无法在下载前校验域名, +此时 curl 后端改用 `%{remote_ip}` 在**连接建立后**复核实际对端地址(同时关闭 +DNS rebinding 的 TOCTOU 窗口);wget 后端没有等价手段,**直接拒绝**而非放行 +(见「已知限制」)。 + +**表单提交校验**:写操作要求 `token` 存在、非空,并在可取到时与 LuCI 的 +`context.authtoken` 比对(这正是框架模板里 `token` 的取值来源);校验失败会**回显 +原因**,不会静默丢弃。**访问控制**:`luci-app-substore` ACL 组经 `menu.d` 的 +`depends.acl` 生效 —— 未获授权的 LuCI 用户看不到本应用入口,**直接访问 URL 也会 +被拒(403)**(依据:ucode dispatcher 在分发时校验路径上累积的 `depends.acl`, +已从上游源码核实,并**已在设备上实测通过**)。授权方式见 [docs/SECURITY.md](docs/SECURITY.md)。 + 数据落盘权限:`/etc/substore` 目录 `0700`,`subscriptions.json` 与 `nodes/*.json` `0600`(前者含订阅 URL 与公开下载 token,后者含 uuid / 密码 / 私钥)—— `io.open` 按 umask 创建(通常 0644),同机任何用户都能读到,因此写入后显式收紧。 diff --git a/luci-app-substore/docs/ARCHITECTURE.md b/luci-app-substore/docs/ARCHITECTURE.md index d12b411d5..3008a9660 100644 --- a/luci-app-substore/docs/ARCHITECTURE.md +++ b/luci-app-substore/docs/ARCHITECTURE.md @@ -42,8 +42,6 @@ luci-app-substore/ - `core.lua` — 订阅源元数据读写、状态管理、订阅链接生成(token + generate_link) - `parser.lua` — 订阅格式解析(Base64/URI/Clash YAML/JSON) - `node.lua` — 节点模型、筛选、去重、重命名、排序 -- `node_converter.lua` — 协议间字段映射与协议转换 -- `converter.lua` — 通用转换器(字段映射、URL 模板渲染) - `output.lua` — 统一格式分发(FORMAT_ALIASES 别名映射、content-type/扩展名) - `output_clash_meta.lua` — Clash.Meta / Mihomo / Stash YAML - `output_uri.lua` — 分享链接 URI / Shadowrocket / V2Ray URI @@ -64,7 +62,11 @@ LuCI 前端(`/usr/lib/lua/luci/`): **订阅链接下发流程**:每个订阅在创建时生成随机 16 位十六进制 `token`,存入元数据。客户端请求公开端点 `GET /substore/download?token=&target=`(如 `target=ClashMeta`),无需登录态、靠不可猜测的 token 鉴权, -按 `target` 实时转换节点并返回(`node_converter.lua` 负责协议间转换,`output.*` 负责格式生成)。 +按 `target` 实时转换节点并返回(由 `output.*` 按目标格式生成)。 + +> **历史说明**:早期设计里 `node_converter.lua` / `converter.lua` 负责「任意协议 → +> 任意协议」的转换。实际实现中该转换入口从未接入(`output.*` 各模块直接消费统一 +> 节点模型),两个模块只被彼此与单元测试引用,已于 `[2.6.11-r1]` 作为死代码删除。 节点统一内部模型(协议无关): ```lua diff --git a/luci-app-substore/docs/INSTALL.md b/luci-app-substore/docs/INSTALL.md index 49cc91375..9fd4d4326 100644 --- a/luci-app-substore/docs/INSTALL.md +++ b/luci-app-substore/docs/INSTALL.md @@ -3,18 +3,22 @@ > 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.9-r1)。 +> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.7.0-r1)。 + +**最低支持 OpenWrt / ImmortalWrt 23.05**(更早的版本不在支持范围内 —— 23.05 起 +LuCI 使用 ucode dispatcher,ACL 的入口级校验依赖它,见 +[SECURITY.md](SECURITY.md)「访问控制」)。 ## Install (opkg — OpenWrt / ImmortalWrt 24.10 及更早) ```bash -opkg install luci-app-substore-2.6.9-r1.ipk +opkg install luci-app-substore-2.7.0-r1.ipk ``` ## Install (apk — OpenWrt / ImmortalWrt 25.12+) ```bash -apk add --allow-untrusted luci-app-substore-2.6.9-r1.apk +apk add --allow-untrusted luci-app-substore-2.7.0-r1.apk ``` Refresh LuCI: diff --git a/luci-app-substore/docs/LEGACY_ISSUES.md b/luci-app-substore/docs/LEGACY_ISSUES.md index 4c837dc5d..019abb1b5 100644 --- a/luci-app-substore/docs/LEGACY_ISSUES.md +++ b/luci-app-substore/docs/LEGACY_ISSUES.md @@ -1,7 +1,9 @@ -# 遗留缺陷与已知限制汇总(待决定修复方案) +# 遗留缺陷与已知限制汇总 -**决策时点**:P0 + P1 + P2 全部修复工作完成后统一决定。本文件只记录**不在 P2 -范围内**、需要另行决策的项。 +**决策状态**:本文件所列各项**均已决策并处置完毕**(`[2.6.16-r1]` 为最后一批, +见下文「六」)。仍留在这里是因为它们记录了「为什么是这个处置」—— 其中一部分是 +**有意保留的已知限制**,不是待办;另有几项写明「待上游规范可核实后再定」。 +本文件只记录**不在 P2 范围内**、需要另行决策的项。 **当前进度**: @@ -9,7 +11,10 @@ |---|---|---| | 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]`、批次七 `[2.6.9-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]`、批次七 `[2.6.9-r1]`「P2 最后一批」) | +| P3 | 输出层代码级复审(10 项)+ shadowsocks SIP003 插件全链路(1 项) | ✅ 已完成(`[2.6.10-r1]`,见下文「四」) | +| P4 | 遗留项决策后实施(1.1 / 1.3 / 1.4 / 1.5 / 2.5 五项,3.3 补文档) | ✅ 已完成(`[2.6.11-r1]`,见下文「五」) | +| P5 | 决策项实施(wget 路径 SSRF、删除部分失败漏写 cron、混合格式静默丢弃、写入失败被忽略、M28 token、M29 ACL;wget 体积/重定向记文档) | ✅ 已完成(`[2.6.16-r1]`,见下文「六」) | **编写纪律**:只记录经**代码级审计或实测探针**确认的结论,不含推测。原始 70 项 审计表(高危 14 / 中危 30 / 低危 26)未落盘,本文件自本次起作为「未修复项」的 @@ -19,7 +24,7 @@ # 一、本轮(P1 之后)确认的遗留项 -## 1.1 H10 的「无锁」部分 —— `load()` → `save()` 丢更新 +## 1.1 H10 的「无锁」部分 —— `load()` → `save()` 丢更新(**已修复 `[2.6.11-r1]`**) **已修的部分**:H10 的另一半(临时文件名固定为 `path..".tmp"` 被并发写者共用) 已在 P0 批次三修复,改为 `path..".tmp.<8 字节随机 hex>"`。 @@ -51,22 +56,93 @@ ## 1.2 M28 / M29 —— CSRF `post_ok()` 与 ACL -**状态**:**已决定跳过验证**(用户 2026-09-30 指示)。 +**状态**:**已修复(`[2.6.16-r1]`)**。原「跳过验证」的处置已作废 —— 两项依赖的 +LuCI 框架行为本轮**已从上游源码逐条核实**(依据见下),不再是猜测,故按核实结果实施。 -**原因**:二者依赖 LuCI 框架运行时行为(`test_post_security`、ACL 解析),本机没有 -LuCI 运行环境,无法做代码级确认。按「不靠猜」的纪律,未做改动。 +**M28(`post_ok()` 空 token 放行)—— 已修复** -**审计结论(未做运行时验证,置信度有限)**: +原缺陷:`substore.lua` 的 `post_ok()` 只判 `formvalue("token") ~= nil`,**空串也通过**; +且所有 `entry` 均未声明 `post`,框架的 `test_post_security` 从未执行。 -- 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/luci-base`,非推测): -**建议**:在目标设备上以真实 LuCI 环境复核后再决定。 +- `luci/dispatcher.lua` 的 `test_post_security()` 只在 `entry.post` 为真时才被调用; + 本控制器未声明 `post`,因此该函数**确实从未执行** —— 原判断成立。 +- `luci/template.lua` 中 viewns 元表的 `token` 键返回 `disp.context.authtoken`, + 即模板里的 `token` **就是** `luci.dispatcher.context.authtoken`。因此「把提交的 token + 与 `context.authtoken` 比对」正是框架自身的判据,**不会误拒任何合法表单**。 -## 1.3 sing-box 读取侧:transport 整层丢失(实测确认) +**修复内容**:`post_ok()` 现在要求 token **存在且非空**,并在能取到 `authtoken` 时 +要求两者**相等**;取不到 `authtoken`(非标准运行环境)时退回「非空即通过」, +不因取不到值而拒绝全部请求。失败时通过 `post_fail_msg` 把原因回显到列表页, +不再静默失败。同时补齐了原先 9 处**忽略返回值**的调用点(7 处 `back_to_list()`、 +2 处 `back_to_nodes()`),使校验失败**必然**被用户看到。 + +**回归测试**:`tests/controller_robustness_test.lua` 的 M28 段(6 条断言), +覆盖「合法 token 通过 / 空 token 拒绝 / 不匹配拒绝 / create / update / node_save +四类写操作均受检 / 取不到 authtoken 时的回退」。反向验证:修复前 7 条断言失败。 + +**M29(无 ACL)—— 已修复** + +原缺陷:`menu.d` 无 `depends.acl`、仓库内无 rpcd ACL 文件 → 任意已登录 LuCI 用户 +可读写全部订阅(含凭据 URL)。 + +**ACL 结构核实**(上游 `luci-base` 与 `luci-app-commands`,非推测):acl.d 的顶层是 +`{ "<组名>": { "description": …, "read": { "uci": [...] }, "write": { "uci": [...] } } }`; +menu.d 用 `"depends": { "acl": [ "<组名>" ] }` 引用。 + +**修复内容**:新增 `root/usr/share/rpcd/acl.d/luci-app-substore.json` 定义 +`luci-app-substore` 组(读写 `uci: substore`),`menu.d` 两个条目均声明 +`depends.acl` 引用它,`Makefile` 增加对应的安装规则。 + +**执行范围(已从上游源码核实,非推测)**: + +`menu.d` 的 `depends.acl` 是**入口级**门禁,不只是「菜单里看不见」。核实过程: + +- **ucode dispatcher(23.05+,现代目标机上实际运行的那套)**: + `modules/luci-base/ucode/dispatcher.uc` 的 `build_pagetree()` 把 + `/usr/share/luci/menu.d/*.json` **与** Lua 控制器 + (`/usr/lib/lua/luci/controller/*/*.lua`)glob 进**同一棵树**; + `dispatch()` 逐段走这棵树并调用 `ctx_append(ctx, name, node)`,而 + `ctx_append` 里是 `push(ctx.acls, ...(node?.depends?.acl || []))` —— + **路径上每个节点**的 `depends.acl` 都会被累积。随后: + ```js + if (length(resolved.ctx.acls)) { + let perm = check_acl_depends(resolved.ctx.acls, resolved.ctx.authacl?.['access-group']); + if (perm == null) { http.status(403, 'Forbidden'); return; } + } + ``` + 即:缺少该组的用户**直接访问 URL 会拿到 403**。 +- **ACL 沿路径累积**这一点还带来一个有用的推论:把 ACL 挂在父节点 + `admin/services/substore` 上,就已覆盖它的**全部子路由** + (`form` / `nodes` / `delete` / `save` / `node_save` / `update` / `probe` …)。 + 本应用的所有动作都在该节点之下,因此这 18 个 `entry` 一并受保护。 +- **旧版 Lua dispatcher(≤ 22.03)行为不同,但不在支持范围内**:`luasrc/dispatcher.lua` + 里 `menu_json()` 把控制器树与 menu.d 树 merge 后 `apply_tree_acls`,那只影响 + **菜单渲染**;`dispatch()` 用的是控制器树,其 `depends.acl` 只来自 + `entry.acl_depends`。也就是说在 22.03 上仅靠 menu.d 只有「菜单看不见」。 + **本项目最低支持 23.05**(用户 2026-10-02 确认),23.05 起用的是 ucode dispatcher, + 即上面那套入口级 403 语义。因此**无需**为旧版补 `entry.acl_depends` —— 记录在此 + 只为说明「为什么 22.03 不在支持范围内时这个缺口可以不管」,不是待办。 + +**设备实测(已完成并通过,2026-10-02)**:以上结论最初来自**读上游源码**(本机无 +LuCI 运行环境),随后按 `docs/TESTING.md` 第 9 项在目标设备复核,**三项断言全部通过**: + +- 非 root 且不在 `luci-app-substore` 组内的 LuCI 用户:**看不到**本应用菜单入口 +- 把该用户加入组后:入口**出现** +- 该用户**直接访问** `/cgi-bin/luci/admin/services/substore/list`:返回 **403 Forbidden** + +即:入口级 403 的语义在真实设备上成立,与上文读源码得出的推断一致。 + +**回滚方式**:删除 `root/usr/share/rpcd/acl.d/luci-app-substore.json`(及 Makefile 中 +对应的两行安装规则),并移除 `menu.d` 两个条目里的 `depends` 块。 + +**回归测试**:`tests/acl_menu_test.lua`(19 条断言)固化跨文件接线 —— ACL 组定义、 +菜单引用**同一个组名**、菜单路径在控制器中确实注册、Makefile 确实安装这两个文件。 +这条链路上任何一环写错都**不会报错**,只会静默失效(ACL 形同虚设),故必须由测试 +锁住。反向验证:修复前 13 条断言失败。 + +## 1.3 sing-box 读取侧:transport 整层丢失(**已修复 `[2.6.11-r1]`**) **现象**:sing-box 配置中 ws / grpc / h2 的传输参数在导入后**完全丢失**,节点退化 为 tcp 直连。 @@ -96,9 +172,28 @@ 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 声明与实现不符 +**权威字段名已核实(`[2.6.10-r1]`,取自上游 +`sing-box.sagernet.org/configuration/shared/v2ray-transport/`,可直接照此实现, +无需再猜)**: + +| `transport.type` | 本项目的 `net` | 字段映射 | +|---|---|---| +| `ws` | `ws` | `path` → `path`;`headers.Host` → `host` | +| `grpc` | `grpc` | `service_name` → `path` | +| `http` | `http` | `path` → `path`;`host` 是**数组**,取首个 → `host` | +| `httpupgrade` | `http` | `path` → `path`;`host` 是**单个字符串** → `host` | +| `quic` | 无对应 | 本项目模型没有 quic,按未知传输处理(回落 tcp 或丢弃) | + +另:`tls.utls.fingerprint` → `fp`(YAML 侧已读,JSON 侧未读,需对齐); +`tls.enabled` 为 `false` 时不得置 `security`。 + +**修复建议:本轮修复**(`[2.6.10-r1]` 未实施,留待下一轮)。它与 F5 / F6 / F11 +同属「静默丢传输层 → 节点连不上且不报错」,而 ws / grpc 节点在机场的 sing-box +配置里占比很高,影响面比 F5/F6 更大。实施时两侧(JSON 与简易 YAML)必须一起改, +否则同一条订阅走两条导入路径会得到不同的节点。 + +## 1.4 `converter.lua` / `node_converter.lua` 为死代码(**已删除 `[2.6.11-r1]`**) (即审计表 L2) @@ -122,7 +217,7 @@ B. 删除两个模块与测试,修正 README 两处声明。 --- -## 1.5 Quantumult X:节点名含逗号时 `tag=` 字段本身被写坏(**待核实**) +## 1.5 Quantumult X:节点名含逗号时 `tag=` 字段本身被写坏(**已按方案 B 修复 `[2.6.11-r1]`**) **发现于**:P2 批次三修复 L21 时顺带发现。L21 只处理了**成员列表**(Surge 家族 `[Proxy Group]` 与 QX `[policy]`),已修复;本条是同一根因在 QX **定义行**上的 @@ -140,6 +235,13 @@ trojan=1.2.3.4:443, password=p, over-tls=true, tag=A,B `[server_local]` 行内是逗号分隔的 `key=value` 字段序列,名字里的逗号落在 `tag=` 的值里,按该语法会被读成字段分隔符。 +**补充(`[2.6.10-r1]`)**:同一条行语法在**参数值**上的同类问题已修复 —— `to_qx` +现在先把具名字段攒成列表、逐字段判定值里是否含逗号,含逗号的整条丢弃(连同 +`[policy]` 成员),不再出现 `password=pa,ss` 被静默截断成 `password=pa`。 +本条讨论的 `tag=` **名字**部分已于 `[2.6.11-r1]` 按下面的方案 B 一并处理: +`to_qx` 现在对「one_line 之后仍含逗号」的节点整条丢弃(定义行与 `[policy]` 成员 +一起),判定条件与 `names_of` 完全一致,两边不会再对不上。 + **未验证的部分(不猜)**:QX 对这种「最后一个字段值里多出逗号」的行究竟是 报错、忽略多余片段,还是原样接受,**本机没有 Quantumult X 可供实测**。 因此本条按「待核实」记录,未作修改。 @@ -157,6 +259,18 @@ trojan=1.2.3.4:443, password=p, over-tls=true, tag=A,B **注**:Surge 家族的 `[Proxy]` 定义行是 `NAME = type, host, port, …`, 名字在 `=` **左侧**,逗号大概率不影响解析(未实测),故本条**只针对 QX**。 +**推荐方案:B(丢弃)**。理由: + +- A(改名)会引入新的重名风险,而「重名代理」在 Surge / QX 上的行为同样未核实 —— + 等于用一个未核实的问题换掉另一个,不划算。 +- C(维持现状)会让用户拿到一份静默损坏的配置,与本项目「宁可丢节点也不输出 + 损坏行」的既有约定(Surge 家族丢 wireguard / ssr、Clash 原版丢不支持的协议)相悖。 +- B 与 F7 已落地的处置完全一致,实现上只是把 `names_of` 的排除条件也用到定义行上, + 改动小、行为可预期。 + +实施前仍建议先核实 QX 对多余逗号片段是报错还是忽略 —— 若确认是「忽略多余片段、 +`tag` 取第一段」,则 C 也可接受,届时再定。 + --- # 二、P0 时代的遗留项(2.6.0 审计时记录) @@ -164,7 +278,7 @@ trojan=1.2.3.4:443, password=p, over-tls=true, tag=A,B 来源:CHANGELOG `[2.6.0-r1]` 的「已知限制(本轮不修,均有明确原因)」。以下逐条 复核过当前代码,均**仍然成立**。 -## 2.1 M11 混合格式文本导入不支持 +## 2.1 M11 混合格式文本导入不支持(**已按方案 B+ 修复 `[2.6.16-r1]`**) **依据**:`parse_local → parse → detect` 只识别**一种**格式,其余部分被静默丢弃 (实测:「URI + WG conf」只剩 WG 节点;「URI + JSON」只剩 URI 节点)。 @@ -175,8 +289,35 @@ trojan=1.2.3.4:443, password=p, over-tls=true, tag=A,B **候选方案**:A. 设计分段架构后实现(工作量大,属 minor 版本特性); B. 在 README / UI 明确「一次只支持一种格式」(README 已如实说明,但 UI 未提示)。 +**已实施方案 B+(拒绝而非静默丢弃)**:新增 `M.detect_all(content)` 收集文本中 +**全部**出现的格式,本地文本导入(`parse_local` 文本模式)在多于一种时**明确报错** +并列出两种格式名,而不是交给 `detect()` 挑一种、悄悄丢掉其余。这比 B 更进一步: +B 只是提示,用户仍会拿到一份少了一半节点的配置;现在是**导入直接失败**,不存在 +「以为成功」的中间态。 + +**实施中修正的两处误判**(若不修,会把合法配置拒之门外,属新 bug): + +- Surge / Clash 的 INI 段头 `[Proxy]` 等会被朴素的 `^%s*%[` 规则误判为 JSON 数组, + 于是一份合法 Surge 配置被判成 `surge + json` 而拒绝。已加 `is_ini_section_head()` + 排除段头(段头内是标识符,不是引号字符串)。 +- 「URI + JSON」这个方向起初漏检(原判据只看首个非空字符)。已补 + `has_json_object_line()`(`^%s*{%s*$` 或 `^%s*{%s*"`)做行级判定,两个方向都能识别。 + +**刻意限定范围(如实记录)**:守卫**只作用于本地文本导入**。远程订阅走 +`M.detect` / `M.parse`,**未改动** —— 改动它会让既有远程订阅的解析行为变化, +风险大于收益。因此**远程订阅内容若混用格式,仍然是静默丢弃**,与修复前一致。 + +**回归测试**:`tests/parser_mixed_format_test.lua`(70 条断言)—— A 组确认单一格式 +(含带 `proxy-provider` 的 Clash YAML、带 `url` 规则的 sing-box JSON、YAML 流式映射、 +`[Interface]` / `[Proxy]`)**不会被误判**;B 组确认 7 种混用组合都明确报错且错误信息 +含两种格式名;C 组确认远程路径 `M.parse` 行为未变。反向验证:修复前探针显示 +「URI + WG conf」得到 `nodes=1, err=nil`(静默丢弃)。 + ## 2.2 sing-box 的 hysteria(v1) 出站还要求 `up` / `down`(带宽) +**状态:确认为已知限制(`[2.6.16-r1]` 决策,方案 A)** —— 不改代码,写入用户文档 +(README「已知限制」)。 + **依据**:`output_singbox.lua:127` 有明确注释说明;本项目的节点模型不承载该字段, 凭空填默认值属于猜测,故不输出。 @@ -185,16 +326,30 @@ B. 在 README / UI 明确「一次只支持一种格式」(README 已如实说 **候选方案**:A. 保持现状(文档说明);B. 在节点模型中增加 `up`/`down` 字段并贯通 解析 / 表单 / 输出(属新功能)。 +**决策理由**:选 A。B 需要先定字段名与单位(bps 还是 Mbps、是否成对、缺省值取多少), +这些都是**未经核实的规格问题**,按「不猜」纪律不能凭推测落码;且它属于新功能, +按版本约定应进 minor 版本,不适合混在本轮的缺陷修复里。 + ## 2.3 hysteria(v1) 的上游 URI 规范未能核实 +**状态:确认为已知限制(`[2.6.16-r1]` 决策,方案 A)** —— 不改代码,写入用户文档 +(README「已知限制」)。 + **依据**:上游文档站点持续 404,无法取得权威定义。`parse_hysteria` 只保证解析本 项目 `output_uri` 自身生成的链接形态(回环已验证),认不出的查询参数一律忽略而不 报错,不臆造参数语义。 **候选方案**:待上游文档可访问后核实并补全;或维持「回环保证」的现状。 +**决策理由**:选「维持现状」。规范拿不到就是拿不到 —— 此时任何「补全」都是编造 +参数语义,正是「不猜」纪律要禁止的。已实测的回环保证(本包导出 → 本包导入不失真) +是本项目唯一能负责的边界,如实写进文档即可。 + ## 2.4 `http` 不加入任何 UI 协议列表 +**状态:确认为已知限制(`[2.6.16-r1]` 决策,方案 A)** —— 不改代码,写入用户文档 +(README「已知限制」)。 + **依据**:规格 §25 的权威协议表恰好是 10 个协议、不含 `http`。`node.lua` 的 `M.PROTOS` 实测为 10 项(vmess / vless / trojan / shadowsocks / ssr / hysteria2 / tuic / hysteria / wireguard / socks),确实不含 `http`。它可由 Clash YAML / JSON @@ -203,7 +358,11 @@ tuic / hysteria / wireguard / socks),确实不含 `http`。它可由 Clash Y **候选方案**:A. 维持现状(`http` 属「可导入可导出但不可手录」);B. 加入 UI 列表 (需先确认规格 §25 是否有意排除)。 -## 2.5 `parser_yaml.lua` 为死代码 +**决策理由**:选 A。规格 §25 是否有意排除 `http` **未核实**,在核实之前把它加进 +UI 就是替规格做决定;而现状并无功能损失(能导入、能导出),只是不能手工新建, +写进文档说明即可。 + +## 2.5 `parser_yaml.lua` 为死代码(**已删除 `[2.6.11-r1]`**) **依据**:`parser.lua:6` `require` 了它,但全文对 `parser_yaml.` 的调用次数实测为 **0**。其中留有同样未归一的 `socks5` 映射。 @@ -224,8 +383,12 @@ tuic / hysteria / wireguard / socks),确实不含 `http`。它可由 Clash Y **影响**:同一节点经 `.conf` 导入与经 JSON 导入,得到的字段集**不一致**。 -**候选方案**:A. 核实 AmneziaWG 上游对这 9 个键的权威语义后补入白名单(不猜语义); -B. 保持白名单策略,在文档说明差异。 +**状态:已修复(`[2.6.10-r1]` 复核)**。`parser.lua` 的 `.conf` 键映射表已收录全部 +9 个 v3.0 / 3.1 键(`headerprotectionkey` → `header-protection-key` … +`disablecookies` → `disable-cookies`),并补入了 v1.5 的 `s3/s4/i1..i5/j1..j3/itime`。 +其中 `random-trailers` / `disable-cookies` 是布尔字段,按 amneziawg-tools 的 +`parse_bool`(只认 `on`/`off` 或十进制数)解析,非法值丢弃而不是原样透传 —— +留着会让 mihomo 解析该字段时报错。**本条无需再决策。** --- @@ -235,17 +398,36 @@ B. 保持白名单策略,在文档说明差异。 ## 3.1 LuCI 里 `amnezia-wg-option` 仍是单个 JSON 文本框 +**状态:确认为已知限制(`[2.6.16-r1]` 决策)** —— 不改代码,写入用户文档 +(README「已知限制」)。 + **依据**:`node.lua` 的 `PROTO_FIELDS.wireguard` 中 `amnezia-wg-option` 是**单个** 字段(实测该数组共 13 项,`amnezia-wg-option` 占其一),因此前端渲染成一个文本框, 需用户手写 JSON。数据本身能正确往返,但录入体验不佳。 **候选方案**:逐字段化(属新功能,按版本约定应进入 minor 版本)。 +**决策理由**:数据往返正确、无功能损失,仅是录入体验;逐字段化属新功能, +不适合混在缺陷修复批次里。写进文档说明录入方式即可。 + ## 3.2 wget 后端的下载体积上限是**下载后**判断 -**依据**:busybox wget 没有「下载前限流」的选项。响应体虽被丢弃,但请求已经发出。 +**状态:确认为工具限制(`[2.6.16-r1]` 决策)** —— 无法在代码层消除,写入用户文档 +(README「已知限制」)与本文。 -**候选方案**:改用 curl(若有)或维持现状并在文档说明。 +**依据**:busybox wget 没有「下载前限流」的选项(已核对 busybox 1.37 `--help`, +不存在 `--max-filesize` 之类的等价物)。响应体虽被丢弃,但请求已经发出。 + +**实际影响有限的原因**:`fetch_wget` 在体积超限时会**丢弃响应体并报错** +(`http.lua`:`size > max` → `os.remove(tmp)` + 返回错误),因此超限内容不会进入 +解析流程,也不会落盘留存。剩下的差异只是「流量已经消耗」——这属于工具能力问题。 + +**候选方案**:改用 curl(若有,curl 路径有 `--max-filesize`,是**下载中**截断)或 +维持现状并在文档说明。 + +**决策理由**:不改代码。curl 后端本就已经用 `--max-filesize` 做了下载中限制; +wget 后端受 busybox 能力所限做不到,自实现 HTTP 客户端属大改动、收益与风险不成 +比例。如实写进文档。 ## 3.3 改名规则不支持括号内的「或」`(a|b)` @@ -267,15 +449,195 @@ end 即无 `nixio` 时任意主机名放行。另存在解析与下载之间的 TOCTOU 窗口。 -**候选方案**:A. 改为 fail-closed(可能误伤无 DNS 环境);B. 维持现状并文档说明; -C. 引入 `nixio` 作为必需依赖(与 1.1 的锁方案可一并考虑)。 +**状态:已修复(`[2.6.8-r1]`),但该修复本身引入了回归,已于 `[2.6.12-r1]` 重做。** + +`[2.6.8-r1]` 把「解析不出 IP」一律改成 fail-closed,前提是「本包依赖 +luci-lua-runtime → 硬依赖 luci-lib-nixio → 解析失败只意味着真的解析不了」。 +**这个前提在用户设备上不成立**:`resolve()` 当时只有 `nixio` 一条路, +nixio 取不到时恒返回 `nil`,于是所有域名订阅都被拒,报「无法解析目标主机名」 +(用户实测 2.6.7-r1 正常,2.6.8-r1 起四个订阅全部失效)。根因是把 +「本机没有解析手段」与「这个域名解析不出来」合并成了同一个 `nil`。 + +`[2.6.12-r1]` 的做法: +- `resolve()` 多级回退 `nixio` → busybox `nslookup`,并返回 `ips, have_resolver`; +- 有解析手段却解析不出来 → 仍 fail-closed(本条要堵的绕过口继续堵死); +- 完全没有解析手段 → 放行,但返回 `unverified = true`; +- 下载层用 curl 的 `%{remote_ip}` 在**连接建立后**复核实际对端地址, + 并把 `unverified` 且拿不到对端 IP 视为拒绝。 + +TOCTOU 窗口(解析与下载之间)也由此关闭:`%{remote_ip}` 就是真正连上的那个 IP, +免疫「预检时解析到公网、连接时解析到内网」的 DNS rebinding。 + +**`[2.6.16-r1]` 补完:wget 后端此前仍是 fail-open。** + +`[2.6.12-r1]` 的「连接后复核」只对 **curl** 后端成立 —— 它靠 `%{remote_ip}` 拿到 +真实对端地址。**wget 后端没有任何等价物**:busybox wget 拿不到对端 IP, +`-S` 日志里的重定向链也只能**事后**看(拦不住已经发出去的请求)。于是在 +「本机没有解析手段」这种设备上,wget 路径的「预检放行」之后**不存在任何一处校验** +—— 等于完全没有 SSRF 防护,而这条路径此前是**静默**的。 + +现已按与 `verify_peer_ip` 相同的处置收敛:**校验不了就拒绝**。 +`fetch_wget` 在 `opts.unverified` 为真时直接返回错误 +("本机无 DNS 解析能力,wget 后端无法校验目标地址,已拒绝下载(安装 curl 后重试)"), +不再尝试下载。 + +**影响面(如实记录)**:仅影响「本机无任何 DNS 解析手段(nixio 不可用且无 nslookup) +**且** 目标为域名 **且** 下载后端为 wget」的设备 —— 这类设备上的域名订阅会失败, +提示安装 curl。字面 IP 目标不受影响(`check_public` 对 IP 提前返回,不产生 +`unverified`);有解析手段的设备也不受影响。**这是有意的取舍**:本包依赖 +`luci-lua-runtime`,正常安装的设备上 `nslookup` 可用,属于罕见组合;而放行的代价是 +一个**完全无防护**的 SSRF 入口。 + +**回归测试**:`tests/dns_fallback_test.lua` 用例 H(无解析器 + wget → 拒绝;桩会写入 +`evilbody`,确保断言不是因「内容为空」而误过)与 H2(对照组:有解析器 → wget 正常 +下载)。反向验证:修复前 H 有 3 条断言失败。 + +**本条已了结,无需再决策。** ## 3.5 wget 路径的重定向校验是**事后**的 -**依据**:busybox wget 无 `--max-redirect`;响应体虽被丢弃但请求已经发出,且无法 -限制下载体积。 +**状态:确认为工具限制(`[2.6.16-r1]` 决策)** —— 无法在代码层消除,写入用户文档 +(README「已知限制」)与本文。 -**候选方案**:与 3.2 一并处理(改用 curl 或自实现 HTTP 客户端)。 +**依据**:busybox wget 无 `--max-redirect`(已核对 busybox 1.37 `--help`);响应体虽 +被丢弃但请求已经发出,且无法限制下载体积。 + +**已在代码层做到的部分(`[2.6.16-r1]` 复核)**:`M.validate_redirect_chain(url, log)` +会从 `-S` 日志里按出现顺序提取整条重定向链的 `Location`,对**每一跳**重新做 +`check_public`,任一跳指向内网/保留地址即整体拒绝并丢弃响应体。因此 +「重定向到内网」**不会**被放行 —— 缺的只是「在发出下一跳请求**之前**拦住它」。 + +**与 3.4 的区别**:3.4 是「根本没有校验」(已修,改为拒绝);本条是「校验存在但 +发生在请求之后」。后者不会让内网内容进入解析流程,只是多发出了一跳请求。 + +**候选方案**:与 3.2 一并处理(改用 curl 或自实现 HTTP 客户端)。curl 路径用 +`--max-redirs 0` 自行逐跳跟随,每跳都在**发出前**校验,不受此限制。 + +**决策理由**:不改代码。curl 后端已是「发出前校验」;wget 后端受 busybox 能力所限, +自实现 HTTP 客户端属大改动。如实写进文档。 + +--- + +# 四、P3:输出层代码级复审(`[2.6.10-r1]` 已修复) + +对 8 个输出模块与 `output.lua` 做了逐行复审,按「生成的配置能否被目标客户端加载」 +这一条标准筛出 11 项缺陷。**全部已修复、已补回归测试、并已对 `HEAD` 反向验证** +(`tests/output_layer_fixes_test.lua` 30 条断言、`tests/ss_plugin_test.lua` 25 条 +断言在修复前失败,修复后全绿)。逐项记录如下。 + +| 编号 | 位置 | 缺陷 | 后果 | +|---|---|---|---| +| F1 | `output_clash_meta` | `ws-opts:` 写在 `if node.path` 内,只有 host 的 ws 节点输出悬空的 `headers:` | YAML 非法 / Host 被忽略,ws 握手被拒 | +| F2 | `output_v2ray` | 空传输层编码成 `[]` 而非 `{}` | Xray `UnmarshalTypeError`,拒绝启动 | +| F3 | `output_clash_meta` | 节点名与其它节点 / 策略组名 / 保留名冲突时不消解 | mihomo `proxies` 重名,拒绝加载整份配置 | +| F4 | `output_clash_meta` | `esc_yaml` 未引用以 `%` `!` `-` 开头的标量 | 非法 YAML,mihomo 解析失败 | +| F5 | `output_formats` | QX 的 vless 分支不输出 obfs / tls 参数 | 客户端按明文 tcp 连 ws 端口,必然失败且不报错 | +| F6 | `output_formats` | Surge 家族的 vless 分支不输出 ws 传输层 | 同上 | +| F7 | `output_formats` | 参数值里的逗号把凭据静默截断(`password=pa,ss` → `password=pa`) | 静默发错凭据 | +| F8 | `output_uri` | IPv6 字面量地址未加方括号 | authority 无法解析 | +| F9 | `output_clash_meta` | `amnezia-wg-option` 子键未转义 | 键名里的 `:` / 引号写坏 YAML 映射 | +| F10 | `output.lua` | `content_type_for` / `extension_for` 对空 target 落空(`""` 在 Lua 里是真值) | 正文与响应头 / 文件名后缀不一致 | +| F11 | 全链路 | shadowsocks 的 SIP003 `plugin` 被三个输出模块静默丢弃;表单保存时被清空 | 带 obfs / v2ray-plugin 的节点导出后连不上;界面上编辑一次即永久丢失插件配置 | + +**F11 的处置依据**(均核对上游源码 / 文档,非推测): + +- **SIP002**:`SS-URI = "ss://" userinfo "@" host ":" port [ "/" ] [ "?" plugin ] [ "#" tag ]`, + 插件参数整体做百分号编码。 +- **sing-box**:`shadowsocks` 出站只有 `plugin`(字符串,官方文档明确 + *"Only two are supported: obfs-local and v2ray-plugin"*)与 `plugin_opts` + (SIP003 原始参数串,原样透传)。其余插件名会被拒绝,故按白名单过滤。 +- **mihomo**:`plugin-opts` 是**映射**而非字符串,且名称与参数名都要翻译 + (`obfs-local`/`simple-obfs` → `obfs`,参数 `obfs` → `mode`、`obfs-host` → `host`)。 + `adapter/outbound/shadowsocks.go` 对这两类插件是**强校验**的:obfs 的 mode 不在 + `{tls,http}` 里报 `"ss %s obfs mode error"`,v2ray-plugin 的 mode 不是 `websocket` + 同样报错 —— 都会让整个 outbound 构造失败,进而**拒绝加载整份配置**。因此参数 + 不全时宁可整个不输出插件,也不能输出一个必然被拒绝的组合。 + +**顺带修正的既有测试**:`tests/output_formats_test.lua` 原断言 +`tuic alpn array joined` 期望 `alpn=h3,h2`。多值 alpn 在 Surge 家族的行语法里 +表达不了(逗号分隔的 `key=value`,无转义),F7 的通用逗号检查会因此丢掉整个节点。 +处置:alpn 是**协商提示**而非凭据,缺省时客户端用服务端给出的列表 —— 所以只省略 +该参数、保留节点。断言已按此契约更新。 + +--- + +# 五、其余遗留项的修复建议(按推荐优先级排序) + +**实施记录(`[2.6.11-r1]`)**:下表中 1.1 / 1.3 / 1.4 / 1.5 / 2.5 五项已按推荐方案 +实施完毕,3.3 按方案 A 补了用户文档说明。每项都补了自包含回归测试,并对 `HEAD` +做了反向验证(新断言在修复前失败、修复后全绿): + +| 项 | 回归测试 | HEAD 上失败断言数 | +|---|---|---| +| 1.1 `mkdir` 锁 | `tests/list_lock_test.lua`(39 条) | 18 | +| 1.3 sing-box `transport` | `tests/singbox_transport_test.lua`(28 条) | 18 | +| 1.5 QX `tag=` 逗号 | `tests/qx_tag_comma_test.lua`(24 条) | 9 | + +1.4 / 2.5 是删除死代码,无新增断言;1.4 顺带修复了 `tests/p2_batch7_test.lua` +中对已删模块的依赖(改为直接验证 `util.uuid`)。 + +实施中修正了两处**推荐方案本身**的疏漏,记录如下: + +- **1.1**:`with_list_lock` 最初把锁目录写成常量 `M.LOCK_DIR`。测试普遍在 + `require` 之后改写 `core.DATA_DIR`,常量不会跟着变 —— 于是测试会去锁真实的 + `/etc/substore`。改为由 `M.DATA_DIR` 现算。另一处:全新安装时 `DATA_DIR` 尚不存在, + `mkdir /.lock` 失败,而失败在 `lock_acquire` 眼里等同于「他人持锁」, + 症状是第一次保存订阅就报「正被另一个进程修改」;已在取锁前先 `ensure_dirs()`。 +- **1.1**:`M.merge` 是**只读**的(只读各订阅节点、过滤排序后返回数组), + 包进锁后一旦取锁失败会返回 `nil` 顶掉原本的数组,把「拿不到锁」变成调用方眼里的 + 「没有数据」—— 比不加锁更糟。已移出包装列表。 + +| 项 | 推荐方案 | 理由 | 前置条件 | +|---|---|---|---| +| 1.1 H10 无锁 | ✅ **C**:`mkdir` 原子锁 + 陈旧锁回收 | 纯 Lua、无新依赖,本机**可测**(桩掉 `os.execute` / `os.time`);崩溃残留锁由锁目录 mtime 自愈 | 已定阈值 `util.LOCK_STALE = 60` 秒 | +| 1.3 sing-box 读取侧 transport 丢失 | ✅ **已修复**(补 `transport` 解析) | 与 F5/F6/F11 同类:静默丢传输层 → 节点连不上;影响面更大 | 字段映射**已核实**(见 1.3 正文) | +| 1.4 死代码 + README 声明不符 | ✅ **已删除**死代码(并已随 `[2.6.10-r1]` 精简 README 声明) | 删去了「协议转换:任意协议 → 任意协议」的过度声明 | 已确认无任何 `require` | +| 1.5 QX `tag=` 逗号 | ✅ **B**:整体丢弃 | 见上文 | 已实施;QX 实际行为仍未实测(无 QX 环境),按「宁可丢节点也不输出损坏行」处理 | +| 2.1 混合格式文本导入 | ✅ **B+**:本地导入时明确报错(`[2.6.16-r1]`) | 见 2.1 正文;远程订阅路径刻意不动 | 无 | +| 2.2 hysteria v1 `up`/`down` | ✅ **A**:确认为已知限制 + 文档(`[2.6.16-r1]`) | 补字段属新功能,且单位/缺省值规格未核实 | 无 | +| 2.3 hysteria v1 URI 规范 | ✅ **维持现状** + 文档(`[2.6.16-r1]`) | 上游规范无法核实,按「不猜」原则不动 | 找到权威规范后再定 | +| 2.4 `http` 不进 UI | ✅ **A**:确认为已知限制 + 文档(`[2.6.16-r1]`) | 规格 §25 是否有意排除未核实,现状无功能损失 | 无 | +| 2.5 `parser_yaml.lua` 死代码 | ✅ **已删除** | 死代码会被后续审计反复重新评估,成本高于收益 | 已确认只被 `parser.lua` 一行 `require` 引用、且从未使用 | +| 3.1 AWG 逐字段化 UI | ✅ **确认为已知限制** + 文档(`[2.6.16-r1]`) | 数据往返已正确,仅录入体验 | 无 | +| 3.2 / 3.5 wget 体积与重定向 | ✅ **确认为工具限制** + 文档(`[2.6.16-r1]`) | busybox wget 无对应选项;curl 路径已无此问题 | 无 | +| 3.3 改名规则 `(a\|b)` | ✅ **A**:维持现状 + 文档说明 | Lua 模式无 alternation;自实现展开要处理嵌套与字符类,收益低 | 已在 README.md / README.en.md 的「使用方法」补说明(顶层 `\|` 才是「或」,`(...)` 内按字面) | + +--- + +# 六、本轮实施记录(`[2.6.16-r1]`) + +本轮按「处置优先级 1–8」实施,逐项记录如下。**每项都补了自包含回归测试,并对 +`HEAD` 做了反向验证**(新断言在修复前失败、修复后全绿)—— 反向验证是必须的: +只证明「修复后测试通过」无法排除「这条断言本来就不会失败」。 + +| 优先级 | 项 | 位置 | 回归测试 | HEAD 上失败断言数 | +|---|---|---|---|---| +| 1 | wget 后端 SSRF 缺口(见 3.4) | `http.lua` `fetch_wget` | `tests/dns_fallback_test.lua` 用例 H / H2 | 3 | +| 2 | 删除部分失败时漏写 cron | `controller/admin/substore.lua` `action_delete` | `tests/controller_robustness_test.lua` | 1 | +| 3 | 混合格式静默丢弃(见 2.1) | `parser.lua` `detect_all` + `parse_local` | `tests/parser_mixed_format_test.lua`(70 条) | 探针:`nodes=1, err=nil` | +| 4 | 写入失败被忽略 | `core.lua` `M.remove` / `M.ensure_token` | `tests/data_integrity_test.lua` | 5 | +| 5 | `post_ok()` 空 token 放行(见 1.2 M28) | `controller/admin/substore.lua` | `tests/controller_robustness_test.lua` M28 段 | 7 | +| 6 | 无 ACL(见 1.2 M29) | `rpcd/acl.d` + `menu.d` + `Makefile` | `tests/acl_menu_test.lua`(19 条) | 13 | +| 7 | A4–A7 记文档(2.2 / 2.3 / 2.4 / 3.1) | README / 本文件 | —(无代码改动) | — | +| 8 | wget 体积与重定向记文档(3.2 / 3.5) | README / 本文件 | —(无代码改动) | — | + +**优先级 2 的详细依据**(该缺陷不在本文件原有清单内,是实施前代码级审计新发现的): + +`action_delete` 原先只在**全部删除成功**时调用 `core.write_cron()`,部分失败时 +直接 `return back_to_list(...)` 跳过了它。后果:被删掉的订阅的 cron 行仍留在 +crontab 里,`substore-cron.sh` 会拿着已不存在的 id 反复执行,每次都以非 0 退出 +(脚本末尾有 FAILED 判断),在日志里刷失败、并让监控误报。现改为 +**只要有订阅真的被删掉(`removed > 0`)就重写 cron**,与成功/失败分支无关。 + +**优先级 4 的详细依据**(同样是本轮审计新发现): + +`core.lua` 的 `save()` 返回 `util.atomic_write(...)` 的结果 —— 失败时是 +`false, err`,而 `M.remove` / `M.ensure_token` 此前**丢弃了返回值**:写盘失败时 +`M.remove` 仍报成功(订阅文件已删、索引没更新 → 索引指向不存在的订阅), +`M.ensure_token` 会把一个**没有落盘**的 token 返回给调用方(页面显示 token, +实际不存在)。现改为透传失败与原因。回归测试用 `util.atomic_write` 猴补丁注入 +写失败,断言:`remove` 报失败**且保留** nodes 文件、`ensure_token` 返回 `nil, err`、 +失败恢复后能正常写入。 --- diff --git a/luci-app-substore/docs/PLAN.md b/luci-app-substore/docs/PLAN.md index 0b4f93a1b..2c6d6d7e6 100644 --- a/luci-app-substore/docs/PLAN.md +++ b/luci-app-substore/docs/PLAN.md @@ -50,7 +50,9 @@ - `output_singbox.lua`:sing-box JSON - `output_v2ray.lua`:V2Ray/Xray JSON - `output_formats.lua`:Surge / Surfboard / SurgeMac / Loon / QX / Egern / Plain JSON -- `node_converter.lua` + `converter.lua`:协议间转换、URL 模板渲染 +- ~~`node_converter.lua` + `converter.lua`:协议间转换、URL 模板渲染~~ + (**未落地**:转换入口从未接入,两模块只被彼此与测试引用,已于 `[2.6.11-r1]` + 作为死代码删除。本文档保留当时的计划原貌,不代表当前实现。) - `parser_surge.lua`:Surge / Loon / QX 客户端配置导入 - `parser.lua` 接线:sing-box / V2Ray / Clash JSON / Clash YAML / Surge / QX 输入源 - `core.generate_link(token, target)` + 每订阅随机 token diff --git a/luci-app-substore/docs/SECURITY.md b/luci-app-substore/docs/SECURITY.md index 500773ca5..8ee2c74b3 100644 --- a/luci-app-substore/docs/SECURITY.md +++ b/luci-app-substore/docs/SECURITY.md @@ -4,11 +4,61 @@ - `http.lua` 下载前解析主机名,拒绝 localhost/内网/链路本地/保留地址 - DNS 解析后二次校验,处理重定向 - 仅允许 http/https 协议;端口限定 1–65535 -- **解析失败即拒绝(fail-closed)**:主机名解析不出 IP 时不放行。 +- **解析失败即拒绝(fail-closed)**:**有解析手段**却解析不出 IP 时不放行。 检查的强度不能低于被检查者 —— `127.0.0.1.nip.io` 这类「公网可解析到内网」的域名, 若在本机解析失败的那一刻被放行,curl 自己仍会把它解析到 127.0.0.1 并连上去。 - 代价为零:本包依赖 `luci-lua-runtime`,后者硬依赖 `luci-lib-nixio`, - 所以解析失败只意味着「真的解析不了」,此时下载本来也会失败 +- **解析手段多级回退**:`nixio.getaddrinfo` → busybox `nslookup`。 + `[2.6.8-r1]` 曾假设「本包依赖 luci-lua-runtime,后者硬依赖 luci-lib-nixio, + 所以解析失败只意味着真的解析不了」——**该假设在部分设备上不成立**, + 导致所有域名订阅报「无法解析目标主机名」(`[2.6.12-r1]` 修复)。 + 现在把「本机没有解析手段」与「这个域名解析不出来」严格区分: + 前者放行并标记 `unverified`,后者仍 fail-closed。 +- **连接时对端校验**:`fetch_curl` 用 `-w` 取 `%{remote_ip}`,在**连接建立后** + 复核真正连上的 IP;重定向的每一跳(含第一跳)都复核。 + 这既兜住上面 `unverified` 的放行路径,也消除了「预检解析」与 + 「下载时解析」之间的 TOCTOU 窗口(DNS rebinding)。 + 走代理时跳过 —— `%{remote_ip}` 那时是代理地址。 + `unverified` 且拿不到对端 IP 时拒绝:「无法校验」不等于「放行」。 +- **wget 后端在 `unverified` 时直接拒绝**(`[2.6.16-r1]`)。此前该后端只有预检: + busybox wget 拿不到对端 IP,`-S` 日志里的重定向链也只能事后看 —— 于是 + 「本机无解析手段」的设备上,预检放行之后**不存在任何一处校验**,等于完全没有 + SSRF 防护,且这条路径是静默的。现按与 `verify_peer_ip` 相同的原则收敛: + 校验不了就拒绝,错误信息提示安装 curl。字面 IP 目标不受影响 + (`check_public` 对 IP 提前返回,不产生 `unverified`)。 +- **重定向链逐跳复检**:`validate_redirect_chain` 从 wget `-S` 日志按顺序取出每一跳 + 的 `Location`,逐跳 `check_public`,任一跳不安全即整体拒绝并丢弃响应体。 + 限制:这是**事后**校验(busybox wget 无 `--max-redirect`),请求已经发出; + curl 路径用 `--max-redirs 0` 自行逐跳跟随,每跳都在**发出前**校验。 + +## 访问控制 +- **ACL 组**(`[2.6.16-r1]`):`root/usr/share/rpcd/acl.d/luci-app-substore.json` + 定义 `luci-app-substore` 组(读写 `uci: substore`),`menu.d` 两个条目均声明 + `depends.acl` 引用它 → 未获授权的 LuCI 用户看不到本应用入口。 + 在此之前本应用**没有任何 ACL**,任意已登录 LuCI 用户都能读写全部订阅(含凭据 URL)。 +- **授权方式**:把用户加入该 ACL 组,例如在 `/etc/config/rpcd` 中为该用户的 + `read` / `write` 列表加上 `luci-app-substore`(与其它 LuCI 应用一致); + 仅 root 可见时无需任何配置。 +- **改完必须刷新 LuCI 索引缓存**:菜单树与 ACL 都被缓存在 `/tmp/luci-indexcache*` + (缓存文件名含 menu.d 文件列表的哈希)。新增/修改 menu.d 或 acl.d 后若不刷新, + 改动**不会生效**且不报错。包的 `postinst` 已自动执行 + `rm -f /tmp/luci-indexcache && /etc/init.d/luci reload`; + 手工拷贝文件时需自己执行这两条。 +- **执行范围(已从上游源码核实)**:这是**入口级**门禁,不只是「菜单里看不见」。 + ucode dispatcher(23.05+,现代目标机实际运行的那套)的 `build_pagetree()` 把 + menu.d 与 Lua 控制器装进**同一棵树**,`dispatch()` 逐段 `ctx_append` 累积 + 路径上每个节点的 `depends.acl`,随后 ACL 不足即 `http.status(403)` —— + **直接访问 URL 会拿到 403**。又因 ACL 沿路径累积,挂在父节点 + `admin/services/substore` 上的 ACL 已覆盖其下**全部 18 个** `entry` + (form / nodes / delete / save / update / probe …)。 + 旧版 Lua dispatcher(≤ 22.03)行为不同:menu.d 的 `depends.acl` 只影响菜单渲染, + 入口级需另补 `entry.acl_depends` —— 但**本项目最低支持 23.05**,不在支持范围内, + 故无需处理(见 LEGACY_ISSUES 1.2)。 + **已在设备上实测通过**(2026-10-02,按 TESTING.md 第 9 项):未授权用户看不到 + 入口、加入组后入口出现、**直接访问 URL 返回 403 Forbidden**。 +- **表单 token**:写操作要求 `token` 存在、非空,并在可取到时与 + `luci.dispatcher.context.authtoken` 比对(该值正是模板中 `token` 的来源), + 校验失败**回显原因**。所有 `entry` 均未声明 `post`,因此框架的 + `test_post_security` **不会**执行 —— 上述检查是本应用自己的那一层。 ## 资源限制 - 订阅响应体最大 10MB,可配置 @@ -25,6 +75,12 @@ - 订阅名/文件名白名单校验,禁止 `..`、`/` - 解析时限制结构/大小/嵌套深度,防 YAML/JSON 资源耗尽 - URL 参数解码后二次校验 +- **订阅客户端类型(User-Agent)**:`http.validate_user_agent` 拒绝控制字符与超过 + 256 字符的值。UA 来自表单,最终以 `-A ` / `-U ` 进入 shell 命令 —— + `util.shq` 的单引号挡得住注入,但挡不住 UA 里的**换行**:curl 会把它当成额外的 + 请求头拼进去(header injection),日志里也会被换行截断、伪造出额外行。 + 与代理同理,非法 UA **明确失败而非静默忽略**(§12):静默忽略会让用户以为 UA + 已生效,实际拿到的仍是占位节点 - 探测目标主机名拒绝以 `-` 开头:`probe.lua` 的命令形如 `ping -c 1 -W 2 `, 而 `--help` / `-c` 会被 busybox 的 getopt 当成**选项**。`util.shq` 的单引号由 shell 剥掉,getopt 看到的仍是 `-x`,引号挡不住这一层,必须在取值时就拒绝 @@ -44,9 +100,25 @@ script data escaped 状态,其后的 `` 不再结束脚本块,整页被吞进脚本 ## 安全测试 -- 已验证 SSRF 私网拒绝 -- Base64/JSON 解析异常处理 -- 超大响应体截断测试 + +均为自包含 Lua 5.1 测试,`lua5.1 tests/.lua` 即可运行(无需设备): + +- `tests/dns_fallback_test.lua` —— SSRF 私网拒绝、解析手段回退、 + 「有解析手段却解析不出 → 拒绝」、「无解析手段 → 标记 `unverified`」、 + curl 的 `%{remote_ip}` 连接后复核,以及 **wget 后端在 `unverified` 时拒绝** + (用例 H,含对照组 H2:有解析器时 wget 正常下载) +- `tests/parser_mixed_format_test.lua` —— 混合格式导入的拒绝与单一格式的不误判 +- `tests/acl_menu_test.lua` —— ACL 组定义、菜单 `depends.acl` 引用同一组名、 + Makefile 确实安装这两个文件(接线上任何一环写错都只会**静默失效**) +- `tests/controller_robustness_test.lua` —— 表单 token 校验(合法 / 空 / 不匹配 / + 取不到 authtoken 的回退)与删除部分失败时的 cron 重写 +- `tests/data_integrity_test.lua` —— 注入 `atomic_write` 写失败,断言失败被上报 + 而非静默吞掉 +- Base64/JSON 解析异常处理、超大响应体截断测试见 `tests/` 下同名用例 + +**仍未在设备上验证的部分**(如实记录):LuCI 模板渲染、cron 落盘行为均需在目标 +设备上复核 —— 见 [TESTING.md](TESTING.md)。(ACL 的实际拦截效果已于 2026-10-02 +在设备上实测通过,见上文「访问控制」。) 参见 docs/ARCHITECTURE.md 第 5 节安全设计。 diff --git a/luci-app-substore/docs/TESTING.md b/luci-app-substore/docs/TESTING.md index ab3596b95..e539f6d35 100644 --- a/luci-app-substore/docs/TESTING.md +++ b/luci-app-substore/docs/TESTING.md @@ -6,7 +6,6 @@ | 测试文件 | 覆盖内容 | |---------|---------| | `run_tests.lua` | util(base64/json/url/hostport)、node、parser 基础 | -| `converter_*_test.lua` | 协议转换、URL 模板渲染 | | `node_*_test.lua` | 节点模型扩展、分组、重命名 | | `output_clash_meta_test.lua` | Clash.Meta / Mihomo YAML 生成 | | `output_formats_test.lua` | 15 种目标格式统一分发 | @@ -17,18 +16,45 @@ | `parser_json_config_test.lua` | sing-box / V2Ray / Clash JSON 解析 | | `parser_input_test.lua` | 多客户端配置导入(sing-box/V2Ray/Surge/QX) | | `parser_local_link_test.lua` | 局域网订阅链接检测与解析 | +| `singbox_transport_test.lua` | sing-box `transport` 对象(ws / grpc / http / httpupgrade)读取 | +| `list_lock_test.lua` | 订阅列表 `mkdir` 互斥锁、陈旧锁回收、可重入 | +| `qx_tag_comma_test.lua` | QX 节点名含逗号时的整条丢弃 | +| `dns_fallback_test.lua` | DNS 解析回退(nixio → nslookup)与连接时对端地址校验;**wget 后端在无法校验时拒绝** | +| `parser_mixed_format_test.lua` | 混合格式文本导入:明确报错(7 种组合)+ 单一格式不误判(Surge `[Proxy]` / Clash YAML / sing-box JSON / YAML 流式映射)+ 远程 `M.parse` 行为未变 | +| `acl_menu_test.lua` | 菜单 ACL 接线:ACL 组定义、`depends.acl` 引用同一组名、Makefile 确实安装 acl.d / menu.d | +| `data_integrity_test.lua` | 写入失败被上报而非吞掉(`M.remove` / `M.ensure_token` 注入 `atomic_write` 失败) | +| `subscriptions_format_gate_test.lua` | 订阅列表页格式下拉的启用条件(真实渲染模板后断言) | +| `subscriptions_bulk_delete_test.lua` | 订阅列表页勾选批量删除:选择框列 / 全选 / 删除按钮 / 空选不删(真实渲染模板后断言) | +| `user_agent_test.lua` | 订阅客户端类型(User-Agent):取值校验、`-A`/`-U` 进入命令行、重定向每一跳带 UA、预设解析、core 透传、控制器接线 | +| `core_combo_test.lua` | 组合订阅:合并/重算、来源校验、**删除源订阅后组合立刻重算**(节点/下载链接/来源剪除/来源删光的报错/无关组合不受影响) | 运行全部:`for f in tests/*.lua; do lua5.1 "$f" || exit 1; done` ## 集成测试 1. 编译安装到目标设备 2. 添加订阅 URL,手动更新,验证节点数 +2.1 订阅列表勾选批量删除:表头全选 / 单条勾选、一条都没勾选时点「删除」不删除任何东西、 + 多条一次删除、删除后 `/etc/cron.d/substore` 同步刷新 +2.2 组合订阅随源删除重算:建 A+B 组合 → 删除 A → 组合节点数立刻只剩 B 的、 + 「来源」列不再出现裸 id、组合下载链接不再含 A 的节点;把来源删光时组合报错而非静默 0 节点 +2.3 混合格式文本导入:粘贴「URI 行 + WireGuard `.conf`」的混合文本 → **明确报错**并 + 列出两种格式,而不是只导入一半;再分别单独导入两种格式,均应成功 +2.4 部分删除失败时 cron 同步:构造一条删除会失败的订阅(如手工改坏 nodes 文件名), + 与一条正常订阅一起勾选删除 → 至少一条被删掉时 `/etc/cron.d/substore` **必须**被重写 3. 节点浏览:筛选、排序 4. 输出生成:15 种格式下拉均可生成 5. 订阅链接:复制 `/substore/download?token=...&target=ClashMeta` 到 Passwall/OpenClash 验证可拉取 6. 输入源:导入 Clash YAML / sing-box JSON / V2Ray JSON / Surge / QX 配置验证解析 7. 定时更新:修改 Settings cron,验证 `/etc/cron.d/substore` 生成 8. 规则应用:设置协议过滤、关键词,验证节点列表/输出受影响 +9. **ACL** —— ✅ **已于 2026-10-02 在设备上实测通过**:新建一个非 root 的 LuCI 用户 + (不在 `luci-app-substore` 组内),登录后**看不到**本应用入口;把该用户加入组后 + 入口**出现**;该用户**直接访问 URL** + (`/cgi-bin/luci/admin/services/substore/list`)返回 **403 Forbidden** + (依据:ucode dispatcher 在 `dispatch()` 里对路径上累积的 `depends.acl` 做校验, + 不足即 403 —— 已从上游源码核实,并已由本次设备实测确认) +10. **表单 token**:提交任一写操作(保存订阅 / 删除 / 节点保存)应正常生效; + 手工构造一个缺 `token` 或 token 不匹配的 POST,应看到**错误提示**而不是静默无反应 ## 安全测试 - SSRF:尝试内网 URL,被拒绝 diff --git a/luci-app-substore/po/zh-cn/substore.po b/luci-app-substore/po/zh-cn/substore.po index bfd475f2c..b29e24cd7 100644 --- a/luci-app-substore/po/zh-cn/substore.po +++ b/luci-app-substore/po/zh-cn/substore.po @@ -85,6 +85,13 @@ msgstr "暂无订阅" msgid "Delete this subscription?" msgstr "确定删除该订阅?" +# 订阅页:勾选批量删除 +msgid "Delete selected subscriptions?" +msgstr "确定删除选中的订阅?" + +msgid "Please select subscriptions first" +msgstr "请先勾选订阅" + # 订阅编辑表单 msgid "Subscription URL" msgstr "订阅 URL" @@ -180,8 +187,8 @@ msgstr "本地" msgid "Add node" msgstr "添加节点" -msgid "Paste YAML/URI/JSON node list here. Mixed protocols supported." -msgstr "在此粘贴 YAML/URI/JSON 节点列表,支持混合协议。" +msgid "Paste a node list here — one format at a time (URI / Base64 / YAML / JSON / WireGuard .conf); protocols may be mixed within that format." +msgstr "在此粘贴节点列表 —— 一次只支持一种格式(URI / Base64 / YAML / JSON / WireGuard .conf);同一种格式内可混用协议。" msgid "Add nodes via form below. Each node will be saved as JSON and parsed on save." msgstr "在下方表单中添加节点,保存时将序列化为 JSON 并解析。" @@ -297,6 +304,10 @@ msgstr "操作失败" msgid "Pick a format to generate the subscription link; you can switch formats at any time" msgstr "选择格式后生成订阅链接,格式可随时切换" +# 订阅页:未解析出节点时格式下拉被禁用,说明原因 +msgid "Update and parse nodes before choosing a format" +msgstr "更新并解析出节点后才能选择格式" + # 节点表头 / 节点编辑表单里的字段标签 msgid "Type" msgstr "类型" \ No newline at end of file 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 dff35a70a..afeee0eb0 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 @@ -65,10 +65,38 @@ local function fv(http, key) return tostring(v) end +-- post_ok 失败的原因,供调用方回显(§18:失败必须让用户看见)。 +-- 每个请求是一个独立的 Lua 进程,不存在跨请求残留;同一进程内多次调用时 +-- post_ok 成功会把它清空,因此不会把上一次的失败带进这一次。 +local post_fail_msg = nil + +-- POST 请求的 token 校验。 +-- +-- 上游 LuCI 渲染表单时,模板变量 `token` 就是 luci.dispatcher.context.authtoken +-- (modules/luci-lua-runtime/luasrc/template.lua 的 viewns 元表: +-- elseif key == "token" then return disp.context.authtoken), +-- 所以「提交的 token == authtoken」正是框架自己那套判定, +-- 不可能误拒合法表单 —— 合法表单提交的就是 authtoken。 +-- +-- 此前的判定是 `token ~= nil`:`token=`(空串)也能通过,等于没有校验。 +-- 取不到 authtoken 时(老版本 LuCI / 非标准上下文)退回原判定, +-- 不能因为拿不到框架内部字段就把所有表单都拒掉。 local function post_ok() - -- 拒绝缺失 CSRF token 的 POST local http = require("luci.http") - return fv(http, "token") ~= nil + local tok = fv(http, "token") + if tok == nil then + post_fail_msg = "请求校验失败(缺少 token),请刷新页面后重试" + return false + end + local ok, authtoken = pcall(function() + return require("luci.dispatcher").context.authtoken + end) + if ok and type(authtoken) == "string" and authtoken ~= "" and tok ~= authtoken then + post_fail_msg = "请求校验失败(token 不匹配),请刷新页面后重试" + return false + end + post_fail_msg = nil + return true end -- read cron fields from form, validate cron expression, return cron_enable cron_time @@ -90,6 +118,20 @@ local function read_cron_fields() return cron_enable, cron_time end +-- read subscription client type (User-Agent) from form, return ua string, err +-- +-- 部分机场按 User-Agent 决定返回真实节点还是占位内容。表单提交的是 +-- 「预设 key(ua_preset)+ 自定义文本(ua_custom)」,这里解析成最终要存的 UA。 +-- 非法值必须回显(与规则校验同理):静默存空会让用户以为设置生效了, +-- 实际更新出来的仍是占位节点。 +local function read_ua_fields() + local http = require("luci.http") + local core = require("substore.core") + local ua, err = core.resolve_user_agent(fv(http, "ua_preset"), fv(http, "ua_custom")) + if not ua then return nil, err end + return ua +end + -- read subscription level rules from form, return rules table, err -- -- 第二个返回值是**用户可见**的校验错误。目前只有重命名规则的正则需要校验: @@ -135,8 +177,10 @@ function action_create() -- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」 local rules, rules_err = read_rules_fields() if not rules then return back_to_list(rules_err or "规则无效") end + local user_agent, ua_err = read_ua_fields() + if user_agent == nil then return back_to_list(ua_err or "订阅客户端类型无效") end local id, err = core.add(name, url, { - proxy_enable = proxy_enable, proxy = proxy, + proxy_enable = proxy_enable, proxy = proxy, user_agent = user_agent, cron_enable = cron_enable, cron_time = cron_time, rules_enable = rules.rules_enable, proto_filter = rules.proto_filter, keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude, @@ -145,7 +189,7 @@ function action_create() if not id then return back_to_list(err or "创建订阅失败") end core.write_cron() end - back_to_list() + back_to_list(post_fail_msg) end function action_save() @@ -166,8 +210,11 @@ function action_save() -- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」 local rules, rules_err = read_rules_fields() if not rules then return back_to_list(rules_err or "规则无效") end + local user_agent, ua_err = read_ua_fields() + if user_agent == nil then return back_to_list(ua_err or "订阅客户端类型无效") end local ok, err = core.save_meta(id, { name = name, url = url, proxy_enable = proxy_enable, proxy = proxy, + user_agent = user_agent, cron_enable = cron_enable, cron_time = cron_time, rules_enable = rules.rules_enable, proto_filter = rules.proto_filter, keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude, @@ -176,7 +223,7 @@ function action_save() if not ok then return back_to_list(err or "保存失败") end core.write_cron() end - back_to_list() + back_to_list(post_fail_msg) end function action_local_create() @@ -201,7 +248,7 @@ function action_local_create() }) if not id then return back_to_list(err or "创建本地订阅失败") end end - back_to_list() + back_to_list(post_fail_msg) end function action_local_save() @@ -233,7 +280,7 @@ function action_local_save() local sok, serr = core.sync(id) if not sok then return back_to_list(serr or "解析订阅内容失败") end end - back_to_list() + back_to_list(post_fail_msg) end function action_delete() @@ -243,11 +290,41 @@ function action_delete() -- §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() + -- + -- id 支持单个(行内删除按钮)或逗号分隔多个(勾选批量删除), + -- 与节点页 action_node_delete 的 idx 同一套约定。订阅 id 是 "s%08x", + -- 不含逗号,切分不会切坏。 + local ids = {} + for s in tostring(fv(http, "id") or ""):gmatch("[^,%s]+") do + ids[#ids + 1] = s + end + if #ids == 0 then return back_to_list("未指定要删除的订阅") end + local removed, first_err = 0, nil + for _, id in ipairs(ids) do + local ok, err = core.remove(id) + if ok then + removed = removed + 1 + elseif not first_err then + first_err = err + end + end + -- 部分失败也必须说:勾了 5 个只删掉 3 个却显示「成功」, + -- 用户不会再回头管剩下那 2 个。 + -- + -- 只要**有订阅真的被删掉**就要重写 cron —— 部分失败的分支也不例外: + -- 被删掉的订阅的 cron 行若留着,substore-cron.sh 会拿着已不存在的 id + -- 反复执行,每次都以非 0 退出(见脚本末尾的 FAILED 判断), + -- 在日志里刷失败、并让监控误报。 + if removed > 0 then core.write_cron() end + if removed < #ids then + if removed == 0 then + return back_to_list(first_err or "删除失败:订阅不存在") + end + return back_to_list(string.format("已删除 %d 个,另有 %d 个删除失败", + removed, #ids - removed)) + end end - back_to_list() + back_to_list(post_fail_msg) end -- 节点页返回链接:保留当前筛选参数 @@ -300,7 +377,7 @@ function action_node_save() end core.refresh_combos(id) end - back_to_nodes(http) + back_to_nodes(http, post_fail_msg) end -- 节点删除:idx 支持单个(行内删除按钮)或逗号分隔多个(勾选批量删除) @@ -340,7 +417,7 @@ function action_node_delete() core.save_meta(id, { node_count = #nodes }) core.refresh_combos(id) end - back_to_nodes(http) + back_to_nodes(http, post_fail_msg) end -- 单节点分组快速设置(XHR,JSON 响应) @@ -366,6 +443,9 @@ function action_node_set_group() http.write(util.json_encode({ ok = false, err = "write failed" })) return end + -- 与 node_save / node_delete 一致:group 是组合订阅筛选与重命名规则的输入, + -- 改了不同步组合的话,组合的下载链接会一直吐旧分组的数据。 + core.refresh_combos(id) http.write(util.json_encode({ ok = true })) end @@ -404,7 +484,7 @@ function action_combo_save() end if not nid then return back_to_list(err or "保存组合订阅失败") end end - back_to_list() + back_to_list(post_fail_msg) end function action_update() @@ -431,7 +511,7 @@ function action_update() core.save_meta(id, { error = tostring(err or "更新失败"), last_update = os.time() }) end end - back_to_list() + back_to_list(post_fail_msg) end -- node probe endpoint: POST id + mode ping tcping url + proto + keyword, return JSON 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 a531c82d8..a5432c400 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 @@ -21,6 +21,10 @@ 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 + -- 订阅客户端类型(User-Agent)。部分机场按 UA 决定返回真实节点还是占位内容: + -- 同一个订单链接只有用绑定的客户端 UA 才拿得到真节点。预设见 core.UA_PRESETS。 + local ua_preset = core.ua_preset_of(it and it.user_agent) + local ua_custom = (ua_preset == "custom") and (it and it.user_agent or "") or "" local util = require("substore.util") -- 剩余流量 / 剩余时长(仅编辑页且有 subscription-userinfo 数据时显示,首页不显示) local traffic_str = nil @@ -61,6 +65,29 @@ " style="width:60%"/>
通过代理下载订阅(留空则不使用),支持 http:// 或 socks5:// 地址,如 socks5://192.168.5.1:1080
+
+ + +
+ 部分机场按客户端类型下发节点:同一链接只有选对类型才解析得出真实节点,选错会得到「与您使用客户端不兼容」的占位节点。不确定就保持「默认」。
+ 「自定义」用于预设里没有的客户端:填要原样发送的 User-Agent 请求头,格式与预设里的值同形,即 + 客户端名/版本号,例如 clash-verge/v2.5.0、v2rayN/7.22.0、 + mihomo.party/v2.0.0 (clash.meta)。
+ 可以含空格、括号、分号、下划线等可见字符(整串原样发送,不解析、不改写);不能含换行等控制字符, + 最长 256 字符。版本号填机场要求的最低可用版本,具体填什么以机场或客户端说明为准。 + 选「自定义」但留空 = 不发送 User-Agent(等同「默认」)。 +
+
+
"> + + +
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 c33397aec..a9874230a 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 @@ -55,7 +55,7 @@
"> -
<%:Paste YAML/URI/JSON node list here. Mixed protocols supported.%>
+
<%:Paste a node list here — one format at a time (URI / Base64 / YAML / JSON / WireGuard .conf); protocols may be mixed within that format.%>
">
<%:Add nodes via form below. Each node will be saved as JSON and parsed on save.%>
@@ -146,6 +146,8 @@ var FIELD_LABELS = { "obfs": "Obfs", "obfs-param": "Obfs Param", "obfs-password": "Obfs Password", + // SIP003 插件串,形如 obfs-local;obfs=http;obfs-host=www.baidu.com + "plugin": "Plugin (SIP003)", "protocol": "Protocol", "protocol-param": "Protocol Param", "udp": "UDP", diff --git a/luci-app-substore/root/usr/lib/lua/luci/view/substore/node_edit.htm b/luci-app-substore/root/usr/lib/lua/luci/view/substore/node_edit.htm index 103caf9f3..273ec7981 100644 --- a/luci-app-substore/root/usr/lib/lua/luci/view/substore/node_edit.htm +++ b/luci-app-substore/root/usr/lib/lua/luci/view/substore/node_edit.htm @@ -66,6 +66,8 @@ var FIELD_LABELS = { "obfs": "Obfs", "obfs-param": "Obfs Param", "obfs-password": "Obfs Password", + // SIP003 插件串,形如 obfs-local;obfs=http;obfs-host=www.baidu.com + "plugin": "Plugin (SIP003)", "protocol": "Protocol", "protocol-param": "Protocol Param", "udp": "UDP", diff --git a/luci-app-substore/root/usr/lib/lua/luci/view/substore/subscriptions.htm b/luci-app-substore/root/usr/lib/lua/luci/view/substore/subscriptions.htm index 1b1ac5f4a..98d18a7f7 100644 --- a/luci-app-substore/root/usr/lib/lua/luci/view/substore/subscriptions.htm +++ b/luci-app-substore/root/usr/lib/lua/luci/view/substore/subscriptions.htm @@ -22,10 +22,20 @@ <%:Add combination%> + +
+ + + + @@ -59,9 +69,17 @@ status_text = luci.i18n.translate("OK") end local sub_token = core.ensure_token(it.id) - local sub_link_base = prefix .. "?token=" .. sub_token .. "&target=" + -- ensure_token 在「token 写不进列表文件」时返回 nil:此时链接必然无效, + -- 用空 token 拼出来,让下载端报「无效的订阅 token」, + -- 而不是在模板里对 nil 做字符串拼接直接 500。 + local sub_link_base = prefix .. "?token=" .. (sub_token or "") .. "&target=" + -- 还没解析出节点时禁用格式下拉:刚添加还没点「更新」、更新失败、 + -- 或解析结果为空的订阅,转出来的内容必然是空的。让用户能选, + -- 等于引导他生成一个无效的订阅链接(选了也只是得到一个空结果)。 + local fmt_disabled = (tonumber(it.node_count) or 0) <= 0 %> + - + <% end @@ -153,5 +179,20 @@ function substore_setlink(sel, id) { inp.value = base + encodeURIComponent(sel.value); wrap.style.display = 'block'; } +function substore_toggle_all(el) { + var boxes = document.querySelectorAll('.sub-check'); + for (var i = 0; i < boxes.length; i++) boxes[i].checked = el.checked; +} +function substore_delete_selected() { + var boxes = document.querySelectorAll('.sub-check:checked'); + // 一个都没勾选时什么都不删(否则「删除」会变成「清空全部」)。 + if (boxes.length === 0) { alert('<%:Please select subscriptions first%>'); return; } + if (!confirm('<%=luci.util.pcdata(luci.i18n.translate('Delete selected subscriptions?'))%>')) return; + var ids = []; + for (var i = 0; i < boxes.length; i++) ids.push(boxes[i].value); + var form = document.getElementById('batch_delete_form'); + form.elements['id'].value = ids.join(','); + form.submit(); +} <%+footer%> \ No newline at end of file diff --git a/luci-app-substore/root/usr/share/luci/menu.d/luci-app-substore.json b/luci-app-substore/root/usr/share/luci/menu.d/luci-app-substore.json index 108f73baf..af34331f5 100644 --- a/luci-app-substore/root/usr/share/luci/menu.d/luci-app-substore.json +++ b/luci-app-substore/root/usr/share/luci/menu.d/luci-app-substore.json @@ -5,6 +5,9 @@ "action": { "type": "view", "path": "admin/services/substore/list" + }, + "depends": { + "acl": [ "luci-app-substore" ] } }, "admin/services/substore/list": { @@ -13,6 +16,9 @@ "action": { "type": "view", "path": "admin/services/substore/list" + }, + "depends": { + "acl": [ "luci-app-substore" ] } } } diff --git a/luci-app-substore/root/usr/share/rpcd/acl.d/luci-app-substore.json b/luci-app-substore/root/usr/share/rpcd/acl.d/luci-app-substore.json index 0964a240c..980497f63 100644 --- a/luci-app-substore/root/usr/share/rpcd/acl.d/luci-app-substore.json +++ b/luci-app-substore/root/usr/share/rpcd/acl.d/luci-app-substore.json @@ -1,6 +1,6 @@ { "luci-app-substore": { - "description": "Grant UCI access for luci-app-substore", + "description": "Grant access to the Subscriptions (luci-app-substore) application", "read": { "uci": [ "substore" ] }, diff --git a/luci-app-substore/root/usr/share/substore/converter.lua b/luci-app-substore/root/usr/share/substore/converter.lua deleted file mode 100644 index d07c715f1..000000000 --- a/luci-app-substore/root/usr/share/substore/converter.lua +++ /dev/null @@ -1,339 +0,0 @@ --- converter.lua — 主转换引擎(纯 Lua) --- luci-app-substore - -local M = {} - -local node = require("substore.node") -local node_converter = require("substore.node_converter") -local output = require("substore.output") -local util = require("substore.util") - --- 随机种子 -math.randomseed(os.time()) - --- 验证节点完整性 -function M.validate_nodes(nodes) - if type(nodes) ~= "table" then - return false, { "nodes must be a table" } - end - local errors = {} - for i, n in ipairs(nodes) do - if type(n) ~= "table" then - errors[#errors + 1] = string.format("node %d is not a table", i) - else - if not n.proto or n.proto == "" then - errors[#errors + 1] = string.format("node %d missing proto", i) - end - if not n.server or n.server == "" then - errors[#errors + 1] = string.format("node %d missing server", i) - end - if n.port == nil then - errors[#errors + 1] = string.format("node %d missing port", i) - else - local p = tonumber(n.port) - if not p or p <= 0 then - errors[#errors + 1] = string.format("node %d invalid port", i) - end - end - end - end - if #errors > 0 then - return false, errors - end - return true -end - --- 协议转换(单节点) -function M.convert_protocol(node, target_proto) - if type(node) ~= "table" or not node.proto or not target_proto then - return nil - end - if node.proto == target_proto then - local copy = {} - for k, v in pairs(node) do copy[k] = v end - return copy - end - - local src = node.proto:lower() - local tgt = target_proto:lower() - if src == "ss" then src = "shadowsocks" end - if tgt == "ss" then tgt = "shadowsocks" end - - -- 禁止涉及 shadowsocks 的转换(测试要求) - if src == "shadowsocks" or tgt == "shadowsocks" then - return nil - end - - -- 允许的转换对 - local allowed = { - vmess = { vless = true, trojan = true }, - vless = { vmess = true, trojan = true }, - trojan = { vmess = true, vless = true }, - hysteria2 = { ["sing-box"] = true, clash = true }, - tuic = { ["sing-box"] = true, clash = true }, - wireguard = { ["sing-box"] = true, clash = true }, - } - local allowed_map = allowed[src] - if not allowed_map or not allowed_map[tgt] then - return nil - end - - local mapping = node_converter.get_field_mapping(src, tgt) - if not mapping then - return nil - end - - local new_node = {} - -- 先按映射复制 - for src_field, tgt_field in pairs(mapping) do - if src_field == "proto" then - -- skip, set later - elseif tgt_field then - if node[src_field] ~= nil then - new_node[tgt_field] = node[src_field] - end - end - end - - -- 保留未在映射中明确排除的字段 - for k, v in pairs(node) do - if k ~= "proto" then - local mapped = mapping[k] - if mapped == nil then - -- 未映射的字段保留原名 - if new_node[k] == nil then - new_node[k] = v - end - end - end - end - - new_node.proto = tgt - - -- 保留 name - if not new_node.name and node.name then - new_node.name = node.name - elseif not new_node.name then - new_node.name = (node.server or "") .. ":" .. tostring(node.port or "") - end - - -- 保留 server / port - if not new_node.server and node.server then - new_node.server = node.server - end - if not new_node.port and node.port then - new_node.port = node.port - end - - -- trojan -> vmess/vless 生成新 uuid,避免直接复用密码。 - -- 必须是真的 UUID:客户端的 uuid 字段会按 16 字节解析,格式不对时多数客户端 - -- 直接拒绝该节点。原来写的是 base64(seed) 截前 36 位 —— base64 只产出 24 个 - -- 字符(`sub(1,36)` 是空操作),里面还可能带 `+` `/` `=`,不是合法 UUID。 - if src == "trojan" and (tgt == "vmess" or tgt == "vless") then - new_node.uuid = util.uuid() - end - - -- 丢弃源协议专属字段(不带入转换后的协议) - local drop = { - vmess = { alterId = true, cipher = true }, - } - if drop[src] then - for f in pairs(drop[src]) do - new_node[f] = nil - end - end - - return new_node -end - --- 主转换:节点列表 -> 目标格式(clash/json/base64) -function M.convert(nodes, target_format, options) - options = options or {} - if type(nodes) ~= "table" then - return nil, "nodes must be table" - end - - -- 可选:协议统一转换 - local processed = nodes - if options.proto then - local out = {} - for _, n in ipairs(nodes) do - local cn = M.convert_protocol(n, options.proto) - if cn then - out[#out + 1] = cn - end - end - processed = out - end - - -- 可选:流水线处理 - if options.pipeline then - processed = M.process_pipeline(processed, options.pipeline) - end - - local fmt = (target_format or "clash"):lower() - local content, err = output.generate(processed, fmt) - return content, err -end - --- 多步骤流水线:filter -> rename -> group -> convert -function M.process_pipeline(nodes, pipeline) - if type(nodes) ~= "table" then - return nodes - end - pipeline = pipeline or {} - - -- 深浅拷贝 - local current = {} - for _, n in ipairs(nodes) do - local cp = {} - for k, v in pairs(n) do cp[k] = v end - current[#current + 1] = cp - end - - for _, step in ipairs(pipeline) do - if type(step) ~= "table" then - -- skip - else - local t = step.type - if t == "filter" then - current = node.filter(current, step.opts or {}) - elseif t == "rename" then - if step.opts then - if step.opts.prefix then - for _, n in ipairs(current) do - if n.name then - n.name = step.opts.prefix .. n.name - end - end - end - if step.opts.map then - for _, n in ipairs(current) do - if n.name and step.opts.map[n.name] then - n.name = step.opts.map[n.name] - end - end - end - end - elseif t == "group" then - if step.opts and step.opts.field then - local field = step.opts.field - local prefix = step.opts.prefix or "group_" - for _, n in ipairs(current) do - local val = n[field] or "" - n.group = prefix .. tostring(val) - end - end - elseif t == "convert" then - if step.opts and step.opts.proto then - local out = {} - for _, n in ipairs(current) do - local cn = M.convert_protocol(n, step.opts.proto) - if cn then - out[#out + 1] = cn - end - end - current = out - end - end - end - end - - return current -end - --- 应用模板到节点(简单占位符替换) -function M.apply_template(nodes, template) - if type(nodes) ~= "table" then - return nodes - end - template = template or "" - local out = {} - for _, n in ipairs(nodes) do - local cp = {} - for k, v in pairs(n) do cp[k] = v end - if cp.name and template ~= "" then - -- 替换值一律按**字面**处理:gsub 的替换串里 `%` 有语义 - -- (`%1` 是捕获引用,`%%` 才是字面百分号,裸 `%` 会被吞掉或注入 NUL)。 - -- 节点名来自订阅内容,必须原样落地。 - local new_name = template - new_name = new_name:gsub("{{name}}", util.gsub_literal(cp.name)) - new_name = new_name:gsub("{{proto}}", util.gsub_literal(cp.proto)) - new_name = new_name:gsub("{{server}}", util.gsub_literal(cp.server)) - new_name = new_name:gsub("{{port}}", util.gsub_literal(cp.port)) - cp.name = new_name - end - out[#out + 1] = cp - end - return out -end - --- ---------- 模板渲染引擎 ---------- - --- 协议 URL 模板常量 -M.URL_TEMPLATES = { - vmess = "vmess://{uuid}@{server}:{port}#{name}", - vless = "vless://{uuid}@{server}:{port}#{name}", - trojan = "trojan://{password}@{server}:{port}#{name}", - ss = "ss://{method}:{password}@{server}:{port}#{name}", -} - --- 读取节点字段并归一化为字符串;缺失或空返回 nil -local function field_str(node, field) - local v = node[field] - if v == nil then return nil end - if type(v) == "boolean" then return tostring(v) end - local s = tostring(v) - if s == "" then return nil end - return s -end - --- 渲染单个 {} 表达式: --- {field} 变量替换 --- {field|default} 缺失时取默认值 --- {upper:field} 转大写 --- {lower:field} 转小写 --- {if:field?真值:假值} 条件渲染 -local function render_expr(expr, node) - expr = expr or "" - -- 条件渲染(需最先判断,否则被 "if:..." 与函数分支误匹配) - local field, tv, fv = expr:match("^if:([^?]+)%?(.-):(.*)$") - if field then - return (field_str(node, field) ~= nil) and tv or fv - end - -- 函数调用 - local fn, fname = expr:match("^([%a]+):(.+)$") - if fn == "upper" or fn == "lower" then - local v = field_str(node, fname) or "" - if fn == "upper" then return v:upper() else return v:lower() end - end - -- 默认值 - local fname2, dflt = expr:match("^([^|]+)|(.*)$") - if fname2 then - local v = field_str(node, fname2) - return (v ~= nil) and v or dflt - end - -- 普通变量 - return field_str(node, expr) or "" -end - --- 渲染单个模板字符串到节点 -function M.render_template(template, node) - if type(template) ~= "string" then return "" end - node = node or {} - return (template:gsub("{([^{}]*)}", function(expr) - return render_expr(expr, node) - end)) -end - --- 批量渲染:节点列表 -> 渲染结果数组 -function M.render_url_template(nodes, template) - local out = {} - if type(nodes) ~= "table" then return out end - for _, n in ipairs(nodes) do - out[#out + 1] = M.render_template(template, n) - end - return out -end - -return M diff --git a/luci-app-substore/root/usr/share/substore/core.lua b/luci-app-substore/root/usr/share/substore/core.lua index 4a2f2a6b4..1690c6b3b 100644 --- a/luci-app-substore/root/usr/share/substore/core.lua +++ b/luci-app-substore/root/usr/share/substore/core.lua @@ -7,11 +7,16 @@ local parser = require("substore.parser") local M = {} -M.version = "2.6.9" +M.version = "2.7.0" M.DATA_DIR = "/etc/substore" M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json" M.NODES_DIR = M.DATA_DIR .. "/nodes" M.CRON_FILE = "/etc/cron.d/substore" +-- 列表文件的互斥锁超时(秒)。锁目录固定为 DATA_DIR 下的 .lock, +-- 由 with_list_lock 现算 —— 不做成常量:那样任何改了 DATA_DIR 的调用方 +-- (测试、或将来支持自定义数据目录)都必须记得同步改它,忘了就会去锁真实 +-- 的 /etc/substore,症状是写入全部报「正被另一个进程修改」。 +M.LOCK_STALE = util.LOCK_STALE M.MAX_SIZE = 10 * 1024 * 1024 -- 10MB M.TIMEOUT = 20 @@ -24,6 +29,56 @@ M.TIMEOUT = 20 M.RULE_PROTOS = { "vmess", "vless", "trojan", "shadowsocks", "ssr", "hysteria2", "tuic", "hysteria", "wireguard", "socks" } +-- 「订阅客户端类型」预设。部分机场(如 Allblue 加速器)按 User-Agent 区分客户端: +-- 同一个订单链接,只有用**该订单绑定的客户端**的 UA 去请求才返回真实节点, +-- 其它 UA 拿到的是「与您使用客户端不兼容」的占位内容 —— 表现为订阅能解析成功, +-- 但只有 7 个指向 127.0.0.1:1080 的假节点。 +-- +-- 各 UA 字符串取自客户端源码,非猜测: +-- clash-verge clash-verge-rev src-tauri/src/utils/network.rs `clash-verge/v{版本}` +-- v2rayn v2rayN ServiceLib/Common/Utils.cs `{AppName}/{版本}`(无 v 前缀) +-- clash-party Clash Party src/main/config/profile.ts `mihomo.party/v{版本} (clash.meta)` +-- flclash FlClash lib/common/package.dart 三段空格分隔,含 Platform/ +-- 版本号取用户给出的**最低可用版本**;机场若提高门槛,用户可在「自定义」里改。 +-- 顺序即表单下拉的显示顺序,不要用 pairs 遍历。 +M.UA_PRESETS = { + { key = "clash-verge", label = "Clash Verge", ua = "clash-verge/v2.5.0" }, + { key = "v2rayn", label = "v2rayN", ua = "v2rayN/7.22.0" }, + { key = "clash-party", label = "Clash Party", ua = "mihomo.party/v2.0.0 (clash.meta)" }, + { key = "flclash", label = "FlClash", ua = "FlClash/v0.8.93 clash-verge Platform/linux" }, +} + +local UA_BY_KEY = {} +for _, p in ipairs(M.UA_PRESETS) do UA_BY_KEY[p.key] = p.ua end + +-- 把表单的 (预设 key, 自定义文本) 解析成最终要发送的 UA 字符串。 +-- 返回 "" 表示不设置(沿用下载工具自带的 UA),nil + err 表示输入非法。 +function M.resolve_user_agent(preset, custom) + preset = util.trim(preset or "") + if preset == "" then return "" end + local ua + if preset == "custom" then + ua = util.trim(custom or "") + -- 选了「自定义」却留空 = 明确要求不设置 UA,不是错误。 + if ua == "" then return "" end + else + ua = UA_BY_KEY[preset] + if not ua then return nil, "未知的订阅客户端类型: " .. preset end + end + return http.validate_user_agent(ua) +end + +-- 反向:把已存的 UA 字符串映射回预设 key(编辑页回显用)。 +-- 不在预设里的一律落到 "custom",由调用方把原值填进自定义输入框。 +function M.ua_preset_of(ua) + local s = util.trim(ua or "") + if s == "" then return "" end + for _, p in ipairs(M.UA_PRESETS) do + if p.ua == s then return p.key end + end + return "custom" +end + local function id_is_valid(id) return type(id) == "string" and id ~= "" and id:match("^[A-Za-z0-9_%-]+$") ~= nil end @@ -88,6 +143,41 @@ local function save(seq, items) return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }), "600") end +-- 把「读整表 → 改 → 写整表」串行化。 +-- +-- 没有锁时,LuCI 页面保存订阅、cron 定时更新、组合订阅自动重算三者同时发生, +-- 后写者会整表覆盖先写者 —— 用户新增的订阅静默消失,且没有任何报错。 +-- +-- **可重入**:save_combo 内部会调 save_meta,两者都要保护。若第二次调用再去 +-- 抢锁,会把自己挡在门外(mkdir 已被本进程建过)并返回「正被占用」, +-- 于是嵌套的调用必定失败。所以本进程已持锁时只加计数、不再取锁。 +-- 跨进程互斥仍由 util.lock_acquire 的 mkdir 保证。 +local lock_depth = 0 +local function with_list_lock(f) + -- 锁目录是 DATA_DIR 的子目录,父目录不存在时 mkdir 直接失败, + -- 而失败在 lock_acquire 眼里等同于「别人正持有」—— 全新安装上第一次 + -- 保存订阅就会报「正被另一个进程修改」。所以先把目录建出来。 + M.ensure_dirs() + if lock_depth > 0 then + lock_depth = lock_depth + 1 + local a, b = f() + lock_depth = lock_depth - 1 + return a, b + end + local lock_dir = M.DATA_DIR .. "/.lock" + if not util.lock_acquire(lock_dir, { stale = M.LOCK_STALE }) then + return nil, "订阅列表正被另一个进程修改,请稍后重试" + end + lock_depth = 1 + local a, b = f() + lock_depth = 0 + util.lock_release(lock_dir) + return a, b +end +-- 注:f 抛异常时不会走到释放,锁会留在盘上。这是有意不捕获的 —— 用 pcall +-- 包住会把异常改成返回值,调用方(控制器 / cron)看到的错误形态就变了。 +-- 残留锁由 util.lock_acquire 的陈旧回收兜底:超过 LOCK_STALE 秒后自动可回收。 + -- 返回 arr, err。err 非空表示列表文件已损坏。 -- 整体无法解析时 arr 为空;只有部分条目非法时 arr 仍包含能用的条目 -- (坏条目已被 load 过滤掉,否则下面的 pairs(meta) 会抛异常)。 @@ -134,6 +224,7 @@ function M.add(name, url, opts) token = util.rnd_hex(16), proxy_enable = (opts.proxy_enable == true or opts.proxy_enable == "1") and "1" or "0", proxy = util.trim(opts.proxy or ""), + user_agent = util.trim(opts.user_agent or ""), cron_enable = (opts.cron_enable == true or opts.cron_enable == "1") and cron_time ~= "", cron_time = cron_time, rules_enable = (opts.rules_enable == true or opts.rules_enable == "1") and true or false, @@ -164,7 +255,7 @@ function M.add_local(name, raw_content, local_mode, opts) name = name, url = "", enabled = true, node_count = 0, last_update = nil, error = "", format = "", token = util.rnd_hex(16), - proxy_enable = "0", proxy = "", + proxy_enable = "0", proxy = "", user_agent = "", cron_enable = false, cron_time = "", rules_enable = (opts.rules_enable == true or opts.rules_enable == "1") and true or false, proto_filter = util.trim(opts.proto_filter or ""), @@ -190,8 +281,14 @@ function M.ensure_token(id) local meta = items[id] if not meta then return nil end if not meta.token or meta.token == "" then - meta.token = util.rnd_hex(16) - save(seq, items) + local tok = util.rnd_hex(16) + meta.token = tok + -- 没落盘的 token 不能交出去:id_by_token 是从磁盘读的,交出去等于给用户 + -- 一个必然报「无效的订阅 token」的链接;而且内存表用完即弃, + -- 下一次调用会再生成一个**不同**的 token,链接还会跳来跳去。 + -- 失败就返回 nil,由调用方按「拿不到 token」处理。 + if not save(seq, items) then return nil, "写入失败" end + return tok end return meta.token end @@ -249,8 +346,46 @@ function M.remove(id) if lerr then return false, lerr end if not items[id] then return false end items[id] = nil - save(seq, items) + -- 引用这个订阅的组合:把死 id 从 sources 里摘掉,同时记下要重算的组合。 + -- + -- 必须**在同一趟里**收集,不能摘完再调 M.refresh_combos(id):后者是按 + -- 「sources 里包含 src_id」来筛组合的,死 id 一旦摘掉就一个都匹配不到, + -- 物化节点会一直是旧的 —— 看着改了,其实没修。 + -- + -- 摘掉死 id 而不是留着:留着的话列表页「来源」列会显示 s00000003 这种裸 id + -- (模板用 name_by_id[sid] or sid 兜底),而且当组合的来源被删光时, + -- combo_refresh 看到 #srcs > 0,不会给出「请选择至少一个订阅」, + -- 组合会静默变成 0 节点。与列表同一次写盘落盘,不留下中间状态。 + local affected = {} + for cid, c in pairs(items) do + local srcs = type(c.sources) == "table" and c.sources or nil + if srcs then + local kept, hit = {}, false + for _, s in ipairs(srcs) do + if s == id then hit = true else kept[#kept + 1] = s end + end + if hit then + c.sources = kept + affected[#affected + 1] = cid + end + end + end + -- 写盘失败必须中止。`items[id] = nil` 只改了内存里的表,磁盘上这条订阅还在: + -- 继续往下走会删掉它的节点文件,于是订阅「列表里还在、点进去却空了」, + -- 而调用方拿到 true,以为删成功了。 + -- 组合的 sources 剪除同理:没落盘的改动不重算(重算只会从磁盘读回旧状态)。 + local ok, serr = save(seq, items) + if not ok then return false, serr or "写入失败" end os.remove(M.nodes_file(id)) + -- 组合的物化节点在 nodes/.json,只有 combo_refresh 会重写它。 + -- 不在这里重算的话,组合的下载链接会继续吐已删订阅的节点,一直等到别的源 + -- 更新(M.sync 里那次 refresh_combos)才被动纠正。 + -- + -- 位置与 M.sync 一致:都在写入口内部调用,由 save_meta 进入临界区 + -- (with_list_lock 可重入,见文件末尾的说明)。 + for _, cid in ipairs(affected) do + M.combo_refresh(cid) + end return true end @@ -347,7 +482,19 @@ function M.sync(id) -- 日志不记录代理凭据(§39) if proxy ~= "" then log("Using proxy " .. http.redact_proxy(proxy)) end - local content, headers, err = http.download(meta.url, { max_size = M.MAX_SIZE, timeout = M.TIMEOUT, proxy = proxy }) + -- 订阅客户端类型(User-Agent):部分机场按 UA 决定返回真实节点还是占位内容。 + -- 与代理一样,**非法值必须明确失败**:静默忽略会让用户以为 UA 已生效, + -- 而实际拿到的是占位节点(§12 禁止 silent fallback)。 + local ua, uaerr = http.validate_user_agent(meta.user_agent) + if not ua then + log("User-Agent invalid: " .. tostring(uaerr)) + M.save_meta(id, { error = "User-Agent 无效: " .. tostring(uaerr), last_update = os.time() }) + return nil, "User-Agent 无效: " .. tostring(uaerr) + end + if ua ~= "" then log("Using User-Agent " .. ua) end + + local content, headers, err = http.download(meta.url, { max_size = M.MAX_SIZE, timeout = M.TIMEOUT, + proxy = proxy, user_agent = ua }) if not content then -- 下载工具的报错可能回显含凭据的 URL,写日志与入库前先抹掉(§39) local safe_err = http.scrub_credentials(err or "下载失败") @@ -415,6 +562,9 @@ local FORM_KEYS = { headerType = true, path = true, host = true, sni = true, tls = true, ["skip-cert-verify"] = true, skip_cert_verify = true, security = true, flow = true, ["obfs-password"] = true, obfs_password = true, + -- SIP003 插件串(shadowsocks)。必须在表里:merge_form_node 只清 FORM_KEYS 里 + -- 的键,缺了这一项,用户在表单里清空插件输入框也删不掉旧值。 + plugin = true, ["private-key"] = true, private_key = true, ["peer-public-key"] = true, peer_public_key = true, ["public-key"] = true, public_key = true, ["pre-shared-key"] = true, preshared_key = true, ip = true, ipv6 = true, ["allowed-ips"] = true, allowed_ips = true, @@ -568,7 +718,7 @@ function M.add_combo(name, sources, opts) name = name, url = "", enabled = true, combo = true, sources = srcs, node_count = 0, last_update = nil, error = "", format = "", token = util.rnd_hex(16), - proxy_enable = "0", proxy = "", + proxy_enable = "0", proxy = "", user_agent = "", cron_enable = false, cron_time = "", rules_enable = (opts.rules_enable == true or opts.rules_enable == "1") and true or false, proto_filter = util.trim(opts.proto_filter or ""), @@ -638,9 +788,16 @@ end -- 校验 cron 表达式:5 个字段,每个为数字或 *,防 cron 文件命令注入 function M.cron_time_valid(ct) if type(ct) ~= "string" then return false end + -- 控制字符一律拒绝。原先用 `%S+` 取词,它把换行也当分隔符:`"1\n 3 * * *"` + -- 同样切成 5 个合法词元并通过校验,然后被 write_cron 原样写进 + -- /etc/cron.d/substore —— 那一行会断成两行,前一行 `1` 不是合法的 + -- crontab 条目,cron 每次 reload 都报语法错。分隔符必须是单个空格: + -- 下面改用整串匹配,制表符 / 多空格 / 换行都落不进来。 + if ct:find("%c") then return false end local fields = {} for f in ct:gmatch("%S+") do fields[#fields + 1] = f end if #fields ~= 5 then return false end + if not ct:match("^%S+ %S+ %S+ %S+ %S+$") then return false end for _, f in ipairs(fields) do if f ~= "*" and not f:match("^%d+$") then return false end end @@ -664,4 +821,31 @@ function M.write_cron() return util.atomic_write(M.CRON_FILE, table.concat(lines, "\n") .. "\n") end +-- ---------- 写入口的列表锁 ---------- +-- +-- 逐个函数改名再包一层,而不是在每个函数体里手写 acquire/release: +-- 后者一旦有人在中途 return(`if lerr then return nil, lerr end` 这种)就会漏掉 +-- 释放,而漏释放的症状是「过一会儿自己好了」(陈旧回收),最难查。 +-- 包在最外层则无论函数从哪条路径返回都会释放。 +-- +-- 只包**写**入口。M.list / M.get / M.read_nodes / M.merge 是只读的(merge 只 +-- 读各订阅的节点、过滤排序后返回数组,从不写列表),加锁会让每次页面刷新都去 +-- 抢锁,白白增加失败面 —— 而且失败时它们会返回 nil 而不是原本的数组/表, +-- 把「拿不到锁」变成调用方眼里的「没有数据」,比不加锁更糟。 +-- M.sync / M.combo_refresh / M.refresh_combos 自己不写列表(写列表的是它们内部 +-- 调用的 M.save_meta),因此也不在此列 —— 它们经由被包住的 save_meta 进入临界区。 +for _, name in ipairs({ + "add", "add_local", "ensure_token", "save_meta", + "remove", "add_combo", "save_combo", +}) do + local inner = M[name] + M[name] = function(...) + -- Lua 5.1 不允许在内层函数里直接用外层函数的 `...`,先收进表再展开。 + -- 这些入口的参数都是位置参数且非 nil(可选参数一律排在最后), + -- 不存在中间空洞把 unpack 截断的情况。 + local args = { ... } + return with_list_lock(function() return inner(unpack(args)) end) + end +end + return M \ No newline at end of file diff --git a/luci-app-substore/root/usr/share/substore/http.lua b/luci-app-substore/root/usr/share/substore/http.lua index 1f0cf7438..58acfa1c1 100644 --- a/luci-app-substore/root/usr/share/substore/http.lua +++ b/luci-app-substore/root/usr/share/substore/http.lua @@ -130,13 +130,15 @@ function M.parse_url(url) if scheme ~= "http" and scheme ~= "https" then return nil, "仅支持 http/https" end -- 主机部分到第一个 / ? # 为止(RFC 3986)。只剥 "/" 是不够的: -- "http://127.0.0.1?a=1" 会剩下 "127.0.0.1?a=1",它既不是合法主机名也不是 - -- 数值型 IPv4,DNS 解析必然失败,而解析失败是放行的(fail-open), - -- 于是绕过 SSRF 检查;curl 实际连的是 127.0.0.1(路由器上的 LuCI 就是 :80)。 + -- 数值型 IPv4,DNS 必然解析不出来,能不能拦住就全看本机有没有解析能力 + -- (见 check_public 的 fail-open 分支)—— 不能指望;curl 实际连的是 + -- 127.0.0.1(路由器上的 LuCI 就是 :80)。 local hostport = rest:match("^[^/%?#]*") or "" -- 剥掉 userinfo(user:pass@):RFC 3986 里 userinfo 到**最后一个** @ 为止, -- @ 之后才是主机。不剥的话 "http://evil@127.0.0.1/" 会把 "evil@127.0.0.1" - -- 当成主机名交给 check_public,DNS 解析失败即放行(fail-open), - -- 而 curl 实际连的是 127.0.0.1 —— 实测可复现。 + -- 当成主机名交给 check_public:它不是合法域名,本机有解析能力时会被 + -- 判为「无法解析」而拒绝,但本机没有解析能力时是放行的 —— 不能指望 + -- 那一层兜住。而 curl 实际连的是 127.0.0.1 —— 实测可复现。 local at = hostport:find("@", 1, true) while at do hostport = hostport:sub(at + 1) @@ -168,6 +170,23 @@ end -- 校验并规范化代理地址:支持 http/https/socks4/socks5/socks5h,可含 user:pass@ -- 空串 → ""(未使用);合法 → 规范化地址;非法 → nil, err +-- 部分机场按 User-Agent 区分客户端:同一个订单链接,只有用「该订单绑定的客户端」 +-- 的 UA 去请求才返回真实节点,其余 UA 得到一段占位内容(见 README「订阅客户端类型」)。 +-- 这里只做「能不能安全地放进命令行」的校验,不判断 UA 是否符合某家客户端。 +-- +-- 拒绝控制字符:UA 最终会以 `-A ` 的形式进入 shell 命令,util.shq 的单引号 +-- 能挡住注入,但挡不住 UA 里的换行 —— curl 会把它当成**响应头之外的**新请求头 +-- 拼进去(header injection),且日志里也会被换行截断、伪造出额外行。 +-- 长度上限 256:真实 UA 都在 100 字符内,超长的只可能是构造出来的。 +function M.validate_user_agent(ua) + if ua == nil then return "" end + local s = util.trim(tostring(ua)) + if s == "" then return "" end + if #s > 256 then return nil, "User-Agent 过长(最多 256 字符)" end + if s:find("%c") then return nil, "User-Agent 不能包含控制字符" end + return s +end + function M.parse_proxy(p) local s = util.trim(p or "") if s == "" then return "" end @@ -222,20 +241,71 @@ function M.parse_proxy(p) return r end --- 解析主机 → IP 列表;无法解析时返回 nil -local function resolve(host) - local nixio = util.try_require("nixio") - if not nixio then return nil end - local ok, addrs = pcall(function() return nixio.getaddrinfo(host) end) - if not ok or type(addrs) ~= "table" then return nil end - local ips = {} - for _, a in ipairs(addrs) do - if a and a.addr then ips[#ips + 1] = a.addr end +-- ---------- 主机名解析 ---------- +-- 解析手段按代价逐级回退:nixio(进程内,不 fork)→ busybox nslookup(fork 一次)。 +local function have(cmd) + return os.execute("command -v " .. cmd .. " >/dev/null 2>&1") == 0 +end + +-- busybox nslookup 的输出形如: +-- Server: 192.168.1.1 +-- Address: 192.168.1.1:53 +-- +-- Name: example.com +-- Address: 93.184.216.34 +-- Address: 2606:2800:220:1:248:1893:25c8:1946 +-- 只取 "Name:" 之后的 "Address:" 行:前面那两行是 **DNS 服务器自己**的地址 +-- (路由器上通常就是 192.168.x.x),当成解析结果会让每个域名都被判成内网而全部误拒。 +-- 解析失败(NXDOMAIN / 超时)时 busybox 只往 stderr 写错误,stdout 里没有 Name: 段, +-- ips 为空 → 返回 nil,与「解析失败」语义一致。 +local function resolve_via_nslookup(host) + local p = io.popen("nslookup " .. util.shq(host) .. " 2>/dev/null") + if not p then return nil end + local out = p:read("*a") or "" + p:close() + local ips, in_answer = {}, false + for line in out:gmatch("[^\r\n]+") do + if line:match("^%s*Name%s*:") then + in_answer = true + elseif in_answer then + local a = line:match("^%s*Address%s*:%s*(%S+)") + if a then + a = a:match("^([^%%]+)") or a -- 去掉 IPv6 的 %zone 后缀 + if a ~= "" then ips[#ips + 1] = a end + end + end end if #ips == 0 then return nil end return ips end +-- 解析主机 → IP 列表。返回 ips, have_resolver +-- ips 解析结果;解析不出来为 nil +-- have_resolver 本机是否存在**可用**的解析手段 +-- 第二个返回值必须与第一个分开:「解析失败」和「本机根本没有解析能力」是两回事, +-- 处置方式完全相反(见 check_public),合并成一个 nil 会得出错误结论。 +local function resolve(host) + local have_resolver = false + local nixio = util.try_require("nixio") + if nixio then + have_resolver = true + local ok, addrs = pcall(function() return nixio.getaddrinfo(host) end) + if ok and type(addrs) == "table" then + local ips = {} + for _, a in ipairs(addrs) do + if a and a.addr then ips[#ips + 1] = a.addr end + end + if #ips > 0 then return ips, true end + end + end + if have("nslookup") then + have_resolver = true + local ips = resolve_via_nslookup(host) + if ips then return ips, true end + end + return nil, have_resolver +end + -- 回环别名(/etc/hosts 常见写法),比较前会先去掉尾部点 local LOOPBACK_NAMES = { ["localhost"] = true, @@ -249,7 +319,9 @@ local LOOPBACK_NAMES = { ["ip6-localnet"] = true, } --- SSRF 预检:拒绝 localhost / 私网 / 保留地址。返回 ok, reason +-- SSRF 预检:拒绝 localhost / 私网 / 保留地址。返回 ok, reason, unverified +-- unverified 为 true 表示「本机没有任何 DNS 解析手段,这次放行没有经过解析校验」。 +-- 调用方(下载层)据此在连接建立后强制复核对端地址,见 verify_peer_ip。 function M.check_public(host) host = util.trim(host or ""):lower() if host == "" then return false, "空主机名" end @@ -280,21 +352,31 @@ function M.check_public(host) if LOOPBACK_NAMES[host] then return false, "目标为 localhost" end - local ips = resolve(host) + local ips, have_resolver = resolve(host) if not ips then - -- 解析不出 IP 一律拒绝(fail-closed)。 + if have_resolver then + -- 本机有解析手段却解析不出来 → 域名确实不存在(NXDOMAIN)。 + -- 拒绝(fail-closed)。 + -- + -- 不能在这里放行:`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名, + -- 只要本机这一刻解析不出来(解析器临时故障、超时),就绕过了上面 + -- 全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1 并连上去。 + -- 检查的强度不能低于被检查者。 + return false, "无法解析目标主机名" + end + -- 本机连一个解析手段都没有(nixio 不可用,且没有 nslookup)。 -- - -- 此前这里放行,理由是「无 DNS 解析能力时尽力而为」—— 但那是一个 - -- SSRF 绕过口:`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名, - -- 只要本机这一刻解析不出来(nixio 缺失、解析器临时故障、超时), - -- 就绕过上面全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1 - -- 并连上去。检查的强度不能低于被检查者。 + -- 这种情形**不能**按「解析失败」处理:[2.6.8-r1] 把两者合并成了同一个 + -- nil,于是这种设备上**所有**域名订阅都被拒,报「无法解析目标主机名」—— + -- 而 curl/wget 自带 libc 解析器,照样能解析并下载,2.6.7-r1 实测正常。 + -- 「本机没装 luci-lib-nixio」不等于「这个域名解析不出来」,两者被混为一谈 + -- 就是那次回归的根因。 -- - -- 代价为零:本包依赖 luci-lua-runtime,后者硬依赖 luci-lib-nixio, - -- 所以 resolve() 返回 nil 只意味着「解析真的失败了」—— 那种情况下 - -- curl 同样解析不了,下载本来就会失败,只是错误信息会变成 - -- 「连接失败」这种指错方向的说法。 - return false, "无法解析目标主机名" + -- 放行,改由下载层在**连接建立之后**用 curl 回报的 %{remote_ip} 复核 + -- 实际对端地址(见 verify_peer_ip)。那才是真正连上去的那个 IP, + -- 比预检更贴近事实,顺带免疫「预检时解析到公网、连接时解析到内网」的 + -- DNS rebinding。 + return true, "本机无 DNS 解析能力,改由连接时校验对端地址", true end for _, ip in ipairs(ips) do if not is_private(ip) then return true end @@ -303,10 +385,6 @@ function M.check_public(host) end -- ---------- 下载 ---------- -local function have(cmd) - return os.execute("command -v " .. cmd .. " >/dev/null 2>&1") == 0 -end - local function detect_tool() if have("curl") then return "curl" end if have("wget") then return "wget" end @@ -385,12 +463,46 @@ function M.scrub_credentials(text) return (tostring(text or ""):gsub("//([^%s/@:]*)%:([^%s/@]*)@", "//***@")) end +-- 连接建立后复核**实际**对端地址,返回 ok, err。 +-- +-- check_public 是预检,它解析的是「此刻」的 DNS;curl 稍后会自己再解析一次, +-- 两次结果未必相同(DNS rebinding、TTL 过期、多 A 记录轮询、本机无解析手段时的 +-- 直接放行)。curl 的 %{remote_ip} 回报的是真正建立连接的那个 IP —— +-- 用它复核,SSRF 防护才不依赖「预检那一刻的 DNS 恰好和连接时一致」。 +-- +-- 走代理时 %{remote_ip} 是代理的地址(多半就在内网),必须跳过:代理是用户 +-- 自己配置的,不属于 SSRF 防护要拦的目标。 +local function verify_peer_ip(ip, proxy, unverified) + if proxy and proxy ~= "" then return true end + if not ip or ip == "" then + -- curl 没回报对端地址(连接没建立,或 curl < 7.29 不支持该变量)。 + if unverified then + -- 预检时本机就没有解析能力(check_public 放行了),现在连对端地址 + -- 也拿不到 —— 这个请求从头到尾没有任何一处校验过目标, + -- 必须拒绝,否则「无法校验」就等于「放行」。 + return false, "无法校验目标地址(本机无 DNS 解析能力,且未取得对端 IP)" + end + -- 有解析能力时预检已经查过一轮,这里无从复核不额外拒绝。 + return true + end + if is_private(ip) then + return false, "目标实际连接到内网/保留地址 (" .. ip .. ")" + end + return true +end + local function fetch_curl(url, parsed, opts) local max, t = opts.max_size, opts.timeout local proxy_arg = "" if opts.proxy and opts.proxy ~= "" then proxy_arg = " -x " .. util.shq(opts.proxy) end + -- 订阅客户端类型(User-Agent)。空 = 不传,curl 用自带的 curl/x.y.z。 + -- 必须在**每一跳**都带上:重定向后的目标同样按 UA 决定返回什么内容。 + local ua_arg = "" + if opts.user_agent and opts.user_agent ~= "" then + ua_arg = " -A " .. util.shq(opts.user_agent) + end -- 临时文件名带随机标记:固定路径会让两个并发下载(如两个订阅的 cron 同时触发, -- 或手动更新撞上 cron)互相覆盖,A 订阅存下 B 的内容且都不报错。 local tag = util.rnd_hex(8) @@ -409,13 +521,17 @@ local function fetch_curl(url, parsed, opts) os.remove(tmp); os.remove(hdr); os.remove(errf) end cleanup() + -- -w 同时取 http_code 与 remote_ip:后者是真正建立连接的对端地址, + -- 用来在拿到响应体之前复核目标(见 verify_peer_ip)。 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)) + "curl -sS -o %s --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %s -w \"%%{http_code} %%{remote_ip}\"%s%s %s 2>%s", + util.shq(tmp), t, math.min(t, 10), max, util.shq(hdr), proxy_arg, ua_arg, util.shq(cur), util.shq(errf)) local p = io.popen(cmd) - local code = p and p:read("*a") or "" + local raw = util.trim(p and p:read("*a") or "") if p then p:close() end - code = util.trim(code) + -- 正常是 "200 93.184.216.34";连接没建立时 curl 只回 "000"(没有 IP)。 + local code, peer = raw:match("^(%d+)%s*(%S*)$") + if not code then code = raw end if code == "" or code == "000" then local msg = util.trim(util.read_file(errf) or "下载失败") cleanup() @@ -425,6 +541,11 @@ local function fetch_curl(url, parsed, opts) -- 或一段 HTML)被当成订阅内容存下去:已存的节点被清空、node_count 归 0, -- 而 error 仍是空字符串,列表页看不出任何异常。下面的重定向分支也因此永远不会执行。 if code:match("^2%d%d$") then + local pok, perr = verify_peer_ip(peer, opts.proxy, opts.unverified) + if not pok then + cleanup() + return nil, perr + end local size = util.file_size(tmp) if size > max then cleanup() @@ -437,6 +558,14 @@ local function fetch_curl(url, parsed, opts) return content, headers end if code:match("^3%d%d$") then + -- 重定向的**发起方**也要复核:否则一个公网域名可以先 302 到 + -- 127.0.0.1 并在第一跳就连上内网服务(Location 检查只能拦住 + -- 第二跳的目标,拦不住第一跳本身)。 + local pok, perr = verify_peer_ip(peer, opts.proxy, opts.unverified) + if not pok then + cleanup() + return nil, perr + end local loc = location_from_headers(hdr) if not loc then cleanup() @@ -478,17 +607,31 @@ function M.wget_proxy_env(proxy) end local function fetch_wget(url, parsed, opts) + -- 本机没有 DNS 解析能力时,check_public 只能 fail-open 放行,把校验推迟到 + -- 「连接建立后复核对端地址」。curl 后端有 %{remote_ip} 可用(见 verify_peer_ip), + -- wget 后端**没有任何等价物**:拿不到对端地址,busybox wget 也没有可用的 + -- 重定向拦截(重定向链只能事后从 -S 日志里看,拦不住已经发出去的请求)。 + -- 于是在这条路径上,「预检放行」之后不存在任何一处校验 —— 等于没有 SSRF 防护。 + -- 与 verify_peer_ip 的处置保持一致:校验不了就拒绝,而不是放行。 + if opts.unverified then + return nil, "本机无 DNS 解析能力,wget 后端无法校验目标地址,已拒绝下载(安装 curl 后重试)" + end local max, t = opts.max_size, opts.timeout local proxy_env, perr = M.wget_proxy_env(opts.proxy) if not proxy_env then return nil, perr end + -- busybox wget 用 -U 设置 User-Agent(已核对 busybox 1.37 的 --help)。 + local ua_arg = "" + if opts.user_agent and opts.user_agent ~= "" then + ua_arg = " -U " .. util.shq(opts.user_agent) + end local tag = util.rnd_hex(8) local tmp = "/tmp/substore_dl_wget_" .. tag .. ".tmp" local log = tmp .. ".log" os.remove(tmp); os.remove(log) -- -S 打印响应头(含整条重定向链),据此复检 SSRF; -- 日志与响应体分流:体写 tmp,链写 log - local cmd = proxy_env .. string.format("wget -S -q -T %d -O %s %s >%s 2>&1", - t, util.shq(tmp), util.shq(url), util.shq(log)) + local cmd = proxy_env .. string.format("wget -S -q -T %d%s -O %s %s >%s 2>&1", + t, ua_arg, util.shq(tmp), util.shq(url), util.shq(log)) local rc = os.execute(cmd) -- 退出码必须看:wget 失败时可能已经写下半截响应体,只判断 size == 0 -- 会把残缺内容当成下载成功。 @@ -521,17 +664,21 @@ end -- 下载订阅内容。成功返回 body, headers, nil;失败返回 nil, nil, err -- opts.proxy 为可选代理地址(scheme://host:port),应由调用方用 parse_proxy 校验 +-- opts.user_agent 为可选的订阅客户端 User-Agent,应由调用方用 validate_user_agent 校验 function M.download(url, opts) opts = opts or {} local max_size = opts.max_size or M.DEFAULT_MAX_SIZE local timeout = opts.timeout or M.DEFAULT_TIMEOUT local parsed = M.parse_url(url) if not parsed then return nil, nil, "无效 URL" end - local ok, reason = M.check_public(parsed.host) + local ua, uaerr = M.validate_user_agent(opts.user_agent) + if not ua then return nil, nil, uaerr end + local ok, reason, unverified = M.check_public(parsed.host) if not ok then return nil, nil, reason end local tool = detect_tool() if not tool then return nil, nil, "无可用下载工具 (curl/wget)" end - local body, headers = fetch(tool, url, parsed, { max_size = max_size, timeout = timeout, proxy = opts.proxy }) + local body, headers = fetch(tool, url, parsed, { max_size = max_size, timeout = timeout, proxy = opts.proxy, + user_agent = ua, unverified = unverified }) if not body then return nil, nil, headers end return body, headers or {}, nil end diff --git a/luci-app-substore/root/usr/share/substore/node.lua b/luci-app-substore/root/usr/share/substore/node.lua index 76d11b94f..4e9c10ff2 100644 --- a/luci-app-substore/root/usr/share/substore/node.lua +++ b/luci-app-substore/root/usr/share/substore/node.lua @@ -32,7 +32,10 @@ M.PROTO_FIELDS = { vmess = { "server", "port", "uuid", "alterId", "cipher", "net", "headerType", "path", "host", "sni", "security", "udp", "skip-cert-verify" }, vless = { "server", "port", "uuid", "security", "flow", "net", "headerType", "path", "host", "sni", "udp", "skip-cert-verify" }, trojan = { "server", "port", "password", "sni", "net", "headerType", "path", "host", "udp", "skip-cert-verify" }, - shadowsocks = { "server", "port", "password", "method", "headerType", "udp" }, + -- plugin:SIP003 插件串(`obfs-local;obfs=http;obfs-host=x`)。不进这个清单 + -- 会有两个后果:表单不渲染它,且 core.merge_form_node 会在保存时把原值清掉 + -- —— 用户在界面上改一下带插件的 ss 节点,插件配置就永久消失。 + shadowsocks = { "server", "port", "password", "method", "headerType", "udp", "plugin" }, hysteria2 = { "server", "port", "password", "sni", "obfs", "obfs-password", "skip-cert-verify" }, -- hysteria(v1):obfs 是普通字符串(不是 hysteria2 的 salamander),因此没有 obfs-password。 -- 字段集合以各输出模块实际消费的键为准(output_clash_meta / output_singbox / output_uri)。 diff --git a/luci-app-substore/root/usr/share/substore/node_converter.lua b/luci-app-substore/root/usr/share/substore/node_converter.lua deleted file mode 100644 index 73dbe1d2a..000000000 --- a/luci-app-substore/root/usr/share/substore/node_converter.lua +++ /dev/null @@ -1,315 +0,0 @@ --- node_converter.lua — 协议字段映射表(纯 Lua) --- luci-app-substore - -local M = {} - --- 协议字段映射配置 --- 格式:source_proto -> target_proto -> { source_field = target_field, ... } -M.FIELD_MAPPINGS = { - vmess = { - vless = { - server = "server", - port = "port", - uuid = "uuid", - network = "network", - security = "security", - sni = "sni", - fingerprint = "fingerprint", - path = "path", - host = "host", - name = "name", - }, - trojan = { - server = "server", - port = "port", - uuid = "password", - network = "network", - security = "security", - sni = "sni", - fingerprint = "fingerprint", - path = "path", - host = "host", - name = "name", - }, - shadowsocks = { - server = "server", - port = "port", - uuid = "password", - network = "network", - name = "name", - }, - ["sing-box"] = { - server = "server", - port = "port", - uuid = "uuid", - network = "network", - security = "security", - sni = "sni", - name = "name", - }, - clash = { - server = "server", - port = "port", - uuid = "uuid", - network = "network", - security = "security", - sni = "sni", - name = "name", - }, - }, - vless = { - vmess = { - server = "server", - port = "port", - uuid = "uuid", - security = "security", - sni = "sni", - fingerprint = "fingerprint", - network = "network", - path = "path", - host = "host", - name = "name", - }, - trojan = { - server = "server", - port = "port", - uuid = "password", - security = "security", - sni = "sni", - fingerprint = "fingerprint", - network = "network", - path = "path", - host = "host", - name = "name", - }, - shadowsocks = { - server = "server", - port = "port", - uuid = "password", - network = "network", - name = "name", - }, - }, - trojan = { - vmess = { - server = "server", - port = "port", - password = "uuid", - security = "security", - sni = "sni", - fingerprint = "fingerprint", - network = "network", - path = "path", - host = "host", - name = "name", - }, - vless = { - server = "server", - port = "port", - password = "uuid", - security = "security", - sni = "sni", - fingerprint = "fingerprint", - network = "network", - path = "path", - host = "host", - name = "name", - }, - shadowsocks = { - server = "server", - port = "port", - password = "password", - network = "network", - name = "name", - }, - }, - shadowsocks = { - vmess = { - server = "server", - port = "port", - password = "uuid", - network = "network", - name = "name", - }, - vless = { - server = "server", - port = "port", - password = "uuid", - network = "network", - name = "name", - }, - trojan = { - server = "server", - port = "port", - password = "password", - network = "network", - name = "name", - }, - }, - hysteria2 = { - ["sing-box"] = { - server = "server", - port = "port", - password = "password", - name = "name", - }, - clash = { - server = "server", - port = "port", - password = "password", - name = "name", - }, - }, - tuic = { - ["sing-box"] = { - server = "server", - port = "port", - uuid = "uuid", - password = "password", - name = "name", - }, - clash = { - server = "server", - port = "port", - uuid = "uuid", - password = "password", - name = "name", - }, - }, - wireguard = { - ["sing-box"] = { - server = "server", - port = "port", - private_key = "private_key", - public_key = "public_key", - name = "name", - }, - clash = { - server = "server", - port = "port", - private_key = "private_key", - public_key = "public_key", - name = "name", - }, - }, -} - --- 目标协议默认字段 -M.DEFAULTS = { - shadowsocks = { - method = "aes-256-gcm", - }, -} - --- 获取字段映射 -function M.get_field_mapping(source_proto, target_proto) - if not source_proto or not target_proto then - return nil - end - -- 标准化协议名称 - source_proto = source_proto:lower() - target_proto = target_proto:lower() - - -- 处理别名 - if source_proto == "ss" then source_proto = "shadowsocks" end - if target_proto == "ss" then target_proto = "shadowsocks" end - - local source_map = M.FIELD_MAPPINGS[source_proto] - if not source_map then - return nil - end - - local mapping = source_map[target_proto] - return mapping -end - --- 转换节点协议 -function M.convert(node, target_proto) - if type(node) ~= "table" then - return nil - end - - local source_proto = node.proto - if not source_proto then - return nil - end - - -- 标准化协议名称 - source_proto = source_proto:lower() - target_proto = target_proto:lower() - - if source_proto == "ss" then source_proto = "shadowsocks" end - if target_proto == "ss" then target_proto = "shadowsocks" end - - -- 如果目标协议与源协议相同,直接返回副本 - if source_proto == target_proto then - local copy = {} - for k, v in pairs(node) do - copy[k] = v - end - return copy - end - - local mapping = M.get_field_mapping(source_proto, target_proto) - if not mapping then - return nil - end - - -- 创建新节点 - local new_node = {} - - -- 根据映射转换字段 - for source_field, target_field in pairs(mapping) do - if node[source_field] ~= nil then - new_node[target_field] = node[source_field] - end - end - - -- 设置协议 - new_node.proto = target_proto - - -- 应用默认字段 - local defaults = M.DEFAULTS[target_proto] - if defaults then - for k, v in pairs(defaults) do - if new_node[k] == nil then - new_node[k] = v - end - end - end - - -- 保留 name,如果不存在则生成 - if not new_node.name and node.name then - new_node.name = node.name - elseif not new_node.name then - new_node.name = (node.server or "") .. ":" .. tostring(node.port or "") - end - - -- 保留 server 和 port(如果映射中没有) - if not new_node.server and node.server then - new_node.server = node.server - end - if not new_node.port and node.port then - new_node.port = node.port - end - - return new_node -end - --- 批量转换 -function M.convert_batch(nodes, target_proto) - if type(nodes) ~= "table" then - return {} - end - - local result = {} - for _, node in ipairs(nodes) do - local converted = M.convert(node, target_proto) - if converted then - result[#result + 1] = converted - end - end - return result -end - -return M diff --git a/luci-app-substore/root/usr/share/substore/output.lua b/luci-app-substore/root/usr/share/substore/output.lua index eaaf6f826..90cebb784 100644 --- a/luci-app-substore/root/usr/share/substore/output.lua +++ b/luci-app-substore/root/usr/share/substore/output.lua @@ -73,9 +73,21 @@ local CONTENT_TYPES = { -- 未指定 target 时的默认格式(保持历史行为:Clash.Meta) local DEFAULT_FORMAT = "clashmeta" +-- 归一 target:空串 / 纯空白等同于「未指定」。 +-- `?target=` 传进来的是 "",而 "" 在 Lua 里是**真值**,`format or DEFAULT_FORMAT` +-- 兜不住它。M.generate 一直在做这件事,但 content_type_for / extension_for 漏了: +-- 同一个请求里正文按 clashmeta 生成,而这两个函数拿 "" 去查表全部落空 —— +-- Content-Type 变成 nil(响应头缺失),文件名后缀退回兜底的 .txt。下游按后缀 +-- 判断格式的客户端会把 YAML 当成纯文本,解析失败。 +local function normalize_format(format) + if type(format) ~= "string" then return DEFAULT_FORMAT end + format = format:match("^%s*(.-)%s*$") or "" + if format == "" then return DEFAULT_FORMAT end + return format +end + function M.content_type_for(format) - format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT - if type(format) ~= "string" then format = DEFAULT_FORMAT end + format = normalize_format(format) local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")] if not norm then norm = M.FORMAT_ALIASES[format:lower()] end return CONTENT_TYPES[norm] @@ -101,8 +113,7 @@ local FILENAME_EXT = { } function M.extension_for(format) - format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT - if type(format) ~= "string" then format = DEFAULT_FORMAT end + format = normalize_format(format) local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")] if not norm then norm = M.FORMAT_ALIASES[format:lower()] end return FILENAME_EXT[norm] or "txt" @@ -133,9 +144,7 @@ function M.generate(nodes, format, options) -- `?target=` 会传进来 "",而 "" 在 Lua 里是**真值**,所以 `format or DEFAULT_FORMAT` -- 兜不住它,会一路落到 "unsupported format: "(冒号后面什么都没有)—— -- 用户拿到的是一个说不出原因的错误页。控制器只在 nil 时兜底,覆盖不到空串。 - if type(format) ~= "string" then format = "" end - format = format:match("^%s*(.-)%s*$") or "" - if format == "" then format = DEFAULT_FORMAT end + format = normalize_format(format) local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")] if not norm then norm = M.FORMAT_ALIASES[format:lower()] end if not norm then return nil, "unsupported format: " .. tostring(format) end 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 938f6afd5..120892132 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 @@ -1,6 +1,8 @@ -- output_clash_meta.lua — Clash.Meta / Mihomo 格式输出(纯 Lua) -- luci-app-substore +local util = require("substore.util") + local M = {} local function esc_yaml(s) @@ -10,8 +12,17 @@ local function esc_yaml(s) -- 字符(\n \r \t 等)时必须加引号 —— 未加引号的换行/回车会直接破坏文档结构。 local need_quote = s:find("%c") ~= nil -- 注意:这里必须用 Lua 模式(不能传 plain=true),否则整串被当作字面量、永不匹配 - or s:find("[ :#{}%[%],&*?|>'\"%@`]") ~= nil + -- `%` `!` 与开头的 `-` 也是 YAML 的 c-indicator,不能作为 plain scalar 的首字符: + -- `password: %foo` → "found character '%' that cannot start any token", + -- `name: !x` → 被当成标签("could not determine a constructor for the tag '!x'"), + -- 裸 `-` → 被当成块序列条目("sequence entries are not allowed here")。 + -- 三者都会让客户端拒绝**整份**配置。节点名以 `%`/`!` 开头并不罕见(机场命名 + -- 很随意),密码里出现 `%` 更是常见,所以必须一起判。 + or s:find("[ :#{}%[%],&*?|>'\"%@`!%%]") ~= nil or s:match("^[-?]*:") ~= nil + -- 首字符是 `-`(含单独的 "-")时同样要加引号:`^[-?]*:` 只覆盖 + -- 「`-` 后跟冒号」的写法,覆盖不到裸 `-` 与 `-foo`。 + or s:match("^%-") ~= nil -- 转义只在**加引号时**做:未加引号的 plain scalar 里反斜杠就是字面反斜杠, -- 提前翻倍会让回读得到两个反斜杠(`pa\ss` → 输出 `pa\\ss` → 读回 `pa\\ss`, -- 密码/路径直接错)。\ 本身不在上面的 need_quote 触发集里,所以含反斜杠的 @@ -83,10 +94,44 @@ local function get_clash_type(proto) return PROTOCOL_TYPE_MAP[proto] or proto end -local function format_node(node) +-- SIP003 插件 → mihomo 的 plugin / plugin-opts。 +-- +-- 名称与参数名都和 SIP003 原文不同,需要逐项翻译: +-- obfs-local / simple-obfs / obfs → obfs, 参数 obfs → mode、obfs-host → host +-- v2ray-plugin → v2ray-plugin, 参数 mode → mode、host → host、 +-- path → path、tls → tls +-- +-- mihomo 对这两类插件的参数是**强校验**的(adapter/outbound/shadowsocks.go): +-- obfs 的 mode 不在 {tls,http} 里报 "ss %s obfs mode error",v2ray-plugin 的 +-- mode 不是 websocket 同样报错,两者都会让这个 outbound 构造失败,进而拒绝 +-- 加载**整份**配置 —— 一个坏节点废掉整个订阅。所以参数不全时宁可整个不输出 +-- 插件,也不能输出一个必然被拒绝的组合。 +-- 未知插件名直接忽略:mihomo 的 plugin 分支没有收尾 else,未知名字本来就静默跳过。 +local function ss_plugin_mihomo(node) + local name, _, opts = util.parse_sip003_plugin(node.plugin) + if not name then return nil end + if name == "obfs-local" or name == "simple-obfs" or name == "obfs" then + local mode = opts.obfs or opts.mode + if mode ~= "tls" and mode ~= "http" then return nil end + local out = { { "mode", mode } } + local host = opts["obfs-host"] or opts.host + if host then out[#out + 1] = { "host", host } end + return "obfs", out + elseif name == "v2ray-plugin" then + if opts.mode ~= "websocket" then return nil end + local out = { { "mode", "websocket" } } + if opts.host then out[#out + 1] = { "host", opts.host } end + if opts.path then out[#out + 1] = { "path", opts.path } end + if opts.tls == true or opts.tls == "true" then out[#out + 1] = { "tls", "true" } end + return "v2ray-plugin", out + end + return nil +end + +local function format_node(node, name) local lines = {} local ctype = get_clash_type(node.proto) - lines[#lines + 1] = " - name: " .. esc_yaml(node.name or "") + lines[#lines + 1] = " - name: " .. esc_yaml(name) lines[#lines + 1] = " type: " .. esc_yaml(ctype) lines[#lines + 1] = " server: " .. esc_yaml(node.server or "") -- 端口必须是数字:`port: abc` / `port: 443/tcp` 这类非数字值会让 mihomo 拒绝 @@ -105,6 +150,18 @@ local function format_node(node) lines[#lines + 1] = " cipher: " .. esc_yaml(node.method) end end + -- SIP003 插件。ss 节点带 obfs / v2ray-plugin 时,服务端只接受带插件的 + -- 握手;丢掉这一项导出的配置会以明文 SS 去连,必然失败且客户端不报错。 + if ctype == "ss" and node.plugin then + local plugin_name, plugin_opts = ss_plugin_mihomo(node) + if plugin_name then + lines[#lines + 1] = " plugin: " .. esc_yaml(plugin_name) + lines[#lines + 1] = " plugin-opts:" + for _, kv in ipairs(plugin_opts) do + lines[#lines + 1] = " " .. esc_yaml(kv[1]) .. ": " .. esc_yaml(kv[2]) + end + end + end if node.cipher then if ctype == "vmess" or ctype == "vless" then lines[#lines + 1] = " cipher: " .. esc_yaml(node.cipher) @@ -115,13 +172,22 @@ local function format_node(node) lines[#lines + 1] = " network: " .. esc_yaml(net) -- ws 特殊处理 if net == "ws" then - if node.path then + -- ws-opts 是 path 与 headers 的**共同**父键:只有 host 没有 path 时 + -- 也必须先把 ws-opts 写出来。原来 ws-opts 行放在 `if node.path` 里面, + -- 于是「有 host、无 path」的节点会输出一个缩进 6 空格的 headers, + -- 而它的父键根本不存在 —— YAML 直接报 + -- "mapping values are not allowed here",客户端拒绝整份配置。 + -- 这条路径是真实可达的:Clash YAML 的 ws-opts.headers.Host 与 + -- ws-opts.path 是各自独立读取的,表单里也允许只填 host。 + if node.path or node.host then lines[#lines + 1] = " ws-opts:" - lines[#lines + 1] = " path: " .. esc_yaml(node.path) - end - if node.host then - lines[#lines + 1] = " headers:" - lines[#lines + 1] = " Host: " .. esc_yaml(node.host) + if node.path then + lines[#lines + 1] = " path: " .. esc_yaml(node.path) + end + if node.host then + lines[#lines + 1] = " headers:" + lines[#lines + 1] = " Host: " .. esc_yaml(node.host) + end end elseif net == "grpc" then -- 服务名写在 grpc-opts 里。只写 network: grpc 的话客户端用默认服务名 @@ -291,7 +357,11 @@ local function format_node(node) for k in pairs(node["amnezia-wg-option"]) do keys[#keys + 1] = k end table.sort(keys) for _, k in ipairs(keys) do - lines[#lines + 1] = " " .. k .. ": " .. esc_yaml(node["amnezia-wg-option"][k]) + -- 键名同样要过 esc_yaml。子表是从 Clash YAML / sing-box JSON / + -- wireguard:// 原样拷进来的,键名由订阅内容决定,不是我们写死的: + -- 键里带换行会在第 0 列插进一行,带 `: ` 会写出 `x: 1: 2` 这种 + -- 映射值错误 —— 两者都让客户端拒绝整份配置。 + lines[#lines + 1] = " " .. esc_yaml(k) .. ": " .. esc_yaml(node["amnezia-wg-option"][k]) end end end @@ -318,26 +388,23 @@ local function format_node(node) return table.concat(lines, "\n") end -local function generate_proxies(nodes) +local function generate_proxies(nodes, tags) if not nodes or #nodes == 0 then return "proxies: []" end local out = {} out[#out + 1] = "proxies:" - for _, node in ipairs(nodes) do - out[#out + 1] = format_node(node) + for i, node in ipairs(nodes) do + out[#out + 1] = format_node(node, tags[i]) end return table.concat(out, "\n") end -local function generate_groups(nodes, options) +local function generate_groups(nodes, tags, options) local out = {} out[#out + 1] = "proxy-groups:" - local names = {} - for _, n in ipairs(nodes) do - names[#names + 1] = n.name or "" - end + local names = tags local group_name = (options and options.name) or "Proxy" @@ -386,13 +453,29 @@ end function M.generate(nodes, options) options = options or {} + nodes = nodes or {} local parts = {} + -- 重名节点必须改名后再输出。mihomo 的 parseProxies 遇到重复的 proxy 名会 + -- 直接返回 "proxy %s is the duplicate name" 拒绝**整份**配置;节点重名在 + -- 订阅里很常见(node.dedup 默认关闭,而且它按 proto+server+port+身份去重, + -- 同名不同服务器根本不会被去掉)。生成组名也要一起占位,节点名撞上组名 + -- 同样是 "proxy group %s: the duplicate name"。 + -- 与 sing-box / Xray 输出共用 util.unique_tags,两边的改名规则保持一致。 + local group_name = (options and options.name) or "Proxy" + local tags = util.unique_tags(nodes, { + [group_name] = true, + ["URL-Test"] = true, + ["Load-Balance"] = true, + ["DIRECT"] = true, + ["REJECT"] = true, + }) + -- 生成 proxies - parts[#parts + 1] = generate_proxies(nodes or {}) + parts[#parts + 1] = generate_proxies(nodes, tags) -- 生成 proxy-groups - parts[#parts + 1] = generate_groups(nodes or {}, options) + parts[#parts + 1] = generate_groups(nodes, tags, options) return table.concat(parts, "\n\n") end 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 4784c97d9..45d34cd02 100644 --- a/luci-app-substore/root/usr/share/substore/output_formats.lua +++ b/luci-app-substore/root/usr/share/substore/output_formats.lua @@ -20,6 +20,17 @@ function M.surge_line(n) local e = {} local tls = (n.security and n.security ~= "none") or (n.tls and n.tls ~= false and n.tls ~= "none") + -- ws 传输参数。vmess 与 vless 都要输出:这两个协议在 Surge 家族里共用同一套 + -- 参数名,此前只有 vmess 分支写了,vless + ws 的节点导出后丢掉整个传输层, + -- 客户端按 tcp 去连一个只开了 ws 的端口,握手必然失败且不报错。 + local function add_ws() + if n.net == "ws" then + e[#e + 1] = "ws=true" + if n.path then e[#e + 1] = "ws-path=" .. n.path end + if n.host then e[#e + 1] = "ws-headers=Host:" .. n.host end + end + end + if proto == "shadowsocks" then e[#e + 1] = "encrypt-method=" .. (n.method or n.cipher or "aes-256-gcm") e[#e + 1] = "password=" .. (n.password or "") @@ -27,16 +38,13 @@ function M.surge_line(n) e[#e + 1] = "username=" .. (n.uuid or "") if tls then e[#e + 1] = "tls=true" end if n.sni then e[#e + 1] = "sni=" .. n.sni end - if n.net == "ws" then - e[#e + 1] = "ws=true" - if n.path then e[#e + 1] = "ws-path=" .. n.path end - if n.host then e[#e + 1] = "ws-headers=Host:" .. n.host end - end + add_ws() elseif proto == "vless" then e[#e + 1] = "username=" .. (n.uuid or "") if tls then e[#e + 1] = "tls=true" end if n.sni then e[#e + 1] = "sni=" .. n.sni end if n.flow then e[#e + 1] = "flow=" .. n.flow end + add_ws() elseif proto == "trojan" then e[#e + 1] = "password=" .. (n.password or "") e[#e + 1] = "tls=true" @@ -62,7 +70,14 @@ function M.surge_line(n) -- 直接拼接会 "attempt to concatenate a table value" 让整次导出失败 local alpn = n.alpn if type(alpn) == "table" then alpn = table.concat(alpn, ",") end - if alpn ~= "" then e[#e + 1] = "alpn=" .. alpn end + -- 多值 alpn 在这里表达不了:Surge 家族的行是逗号分隔的 key=value, + -- 没有引号 / 转义,`alpn=h3,h2` 会被读成 `alpn=h3` 加一个悬空字段。 + -- 但 alpn 只是协商提示,缺省时客户端会用服务端给出的列表, + -- 不像密码那样一旦被截断就静默发错凭据 —— 所以这里丢掉该参数、 + -- 保留节点,而不是像下面的通用逗号检查那样整条丢弃。 + if alpn ~= "" and not alpn:find(",", 1, true) then + e[#e + 1] = "alpn=" .. alpn + end end elseif proto == "socks5" or proto == "socks" or proto == "http" then -- socks5 / http 在 Surge 家族里都用 username= / password= 具名参数 @@ -77,6 +92,17 @@ function M.surge_line(n) if n["skip-cert-verify"] then e[#e + 1] = "skip-cert-verify=1" end if n.udp then e[#e + 1] = "udp-relay=true" end + -- 参数值里的逗号无法用这些格式表达,整条丢弃(返回 nil,由调用方剔除)。 + -- [Proxy] 行与 QX 的 [server_local] 行都是逗号分隔的 `key=value` 序列, + -- 语法里没有引号 / 转义机制:`password=pa,ss` 会被读成 `password=pa` 加上 + -- 一个悬空的 `ss` 字段 —— 凭据被静默截断,用户不会收到任何提示。 + -- 节点名里的逗号已由 names_of 单独处理(不进成员列表),但名字在 `=` 左侧, + -- 不影响本行解析;值在右侧,必须在这里挡掉。 + for _, kv in ipairs(e) do + local v = kv:match("^[^=]*=(.*)$") + if v and v:find(",", 1, true) then return nil end + end + local line = (n.name or "") .. " = " .. head if #e > 0 then line = line .. ", " .. table.concat(e, ", ") end -- 节点名与各参数值都来自订阅(不可信):含换行会截断本行并伪造出一条新的代理行 @@ -124,12 +150,20 @@ local function surge_config(nodes, group_name, supports_ssr) list = kept local out = {} out[#out + 1] = "[Proxy]" + -- surge_line 对「值里含逗号」这类无法表达的节点返回 nil,这些节点必须 + -- 同时从成员列表里剔除 —— 否则 [Proxy Group] 会引用一个不存在的代理, + -- Surge 直接拒绝加载整份配置。 + local rendered = {} for _, n in ipairs(list) do - out[#out + 1] = M.surge_line(n) + local line = M.surge_line(n) + if line then + out[#out + 1] = line + rendered[#rendered + 1] = n + end end out[#out + 1] = "" out[#out + 1] = "[Proxy Group]" - local names = names_of(list) + local names = names_of(rendered) local select = group_name .. " = select" for _, nm in ipairs(names) do select = select .. ", " .. nm end select = select .. ", DIRECT" @@ -184,6 +218,62 @@ function M.to_clash(nodes, options) return clash_meta.generate(kept, options) end +-- QX 的 ws / tls 具名参数。vmess 与 vless 在 QX 里共用同一套参数名, +-- 两个分支必须写出完全一致的集合 —— 此前 vless 一个都没写,vless + ws + tls +-- 的节点导出成明文 tcp 条目,客户端按 tcp 去连只开了 ws 的端口,必然失败且不报错。 +local function qx_transport(n, f) + if n.net == "ws" then + f[#f + 1] = "obfs=ws" + if n.path then f[#f + 1] = "obfs-uri=" .. n.path end + if n.host then f[#f + 1] = "obfs-host=" .. n.host end + end +end + +local function qx_tls(n, f) + if n.sni then f[#f + 1] = "tls-host=" .. n.sni end + if n.security and n.security ~= "none" then f[#f + 1] = "tls-verification=true" end +end + +-- 单条 [server_local] 定义行。返回 nil 表示该节点无法用 QX 的行语法表达, +-- 由调用方整条剔除(定义行与 [policy] 成员一并去掉)。 +-- +-- 具名字段先攒成列表再拼接,而不是边拼字符串边追加:值里含逗号的条目在逗号 +-- 分隔语法里表达不了(`password=pa,ss` 会被读成 `password=pa` 加一个悬空字段, +-- 凭据被静默截断),必须能逐字段判定后整条丢弃。若先拼成整行再回头用模式去切, +-- 行内本来就有的 `, ` 结构分隔符与值里的逗号无法区分 —— 会把每条合法行都误判成非法。 +local function qx_server_line(n, tag) + local proto = props(n) + local host = (n.server or "") .. ":" .. tostring(n.port or 0) + local f = {} + if proto == "shadowsocks" then + f[#f + 1] = "method=" .. (n.method or n.cipher or "aes-256-gcm") + f[#f + 1] = "password=" .. (n.password or "") + elseif proto == "vmess" then + f[#f + 1] = "method=none" + f[#f + 1] = "password=" .. (n.uuid or "") + qx_transport(n, f) + qx_tls(n, f) + elseif proto == "vless" then + f[#f + 1] = "method=none" + f[#f + 1] = "password=" .. (n.uuid or "") + qx_transport(n, f) + qx_tls(n, f) + elseif proto == "trojan" then + f[#f + 1] = "password=" .. (n.password or "") + f[#f + 1] = "over-tls=true" + if n.sni then f[#f + 1] = "tls-host=" .. n.sni end + end + if #f == 0 then return nil end + for _, kv in ipairs(f) do + local v = kv:match("^[^=]*=(.*)$") + if v and v:find(",", 1, true) then return nil end + end + -- 节点名 / sni / path / host 全部来自订阅(不可信),含换行会截断本行并 + -- 伪造出一条新的 server_local 行。tag 排在最后。 + return util.one_line( + proto .. "=" .. host .. ", " .. table.concat(f, ", ") .. ", tag=" .. tag) +end + -- Quantumult X function M.to_qx(nodes, options) options = options or {} @@ -194,42 +284,22 @@ function M.to_qx(nodes, options) local emitted = {} out[#out + 1] = "[server_local]" for _, n in ipairs(nodes or {}) do - local proto = props(n) - local host = (n.server or "") .. ":" .. tostring(n.port or 0) - local tag = n.name or host - local before = #out - if proto == "shadowsocks" then - out[#out + 1] = string.format( - "shadowsocks=%s, method=%s, password=%s, tag=%s", - host, n.method or n.cipher or "aes-256-gcm", n.password or "", tag) - elseif proto == "vmess" then - out[#out + 1] = string.format( - "vmess=%s, method=none, password=%s, tag=%s", - host, n.uuid or "", tag) - if n.net == "ws" then - local esc = out[#out] - esc = esc .. ", obfs=ws" - if n.path then esc = esc .. ", obfs-uri=" .. n.path end - if n.host then esc = esc .. ", obfs-host=" .. n.host end - out[#out] = esc + local tag = util.one_line(n.name or ((n.server or "") .. ":" .. tostring(n.port or 0))) + -- 名字里的逗号落在 `tag=` 的值上,而本行是逗号分隔的 `key=value` 序列, + -- 语法里没有引号 / 转义:`tag=A,B` 会被读成 `tag=A` 加一个悬空字段。 + -- QX 对这种行的实际处置未经核实(本机没有 QX 可实测),但无论它是报错 + -- 还是静默忽略,用户拿到的都不是他填的那个名字 —— 所以整条丢弃,与 + -- Surge 家族丢 wireguard / ssr、Clash 原版丢不支持协议同一约定,也与 + -- 下面 [policy] 成员列表的排除条件(names_of)保持一致。 + -- 注意判定用 one_line 之后的名字:names_of 也是先 one_line 再比对, + -- 两边必须用同一个字符串,否则定义行与成员列表会对不上。 + if not tag:find(",", 1, true) then + local line = qx_server_line(n, tag) + if line then + out[#out + 1] = line + emitted[#emitted + 1] = n end - if n.sni then out[#out] = out[#out] .. ", tls-host=" .. n.sni end - if n.security and n.security ~= "none" then out[#out] = out[#out] .. ", tls-verification=true" end - elseif proto == "vless" then - out[#out + 1] = string.format( - "vless=%s, method=none, password=%s, tag=%s", - host, n.uuid or "", tag) - elseif proto == "trojan" then - out[#out + 1] = string.format( - "trojan=%s, password=%s, over-tls=true, tag=%s", - host, n.password or "", tag) - if n.sni then out[#out] = out[#out] .. ", tls-host=" .. n.sni end end - -- 节点名 / 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]" 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 bbab1c296..766d1f25a 100644 --- a/luci-app-substore/root/usr/share/substore/output_singbox.lua +++ b/luci-app-substore/root/usr/share/substore/output_singbox.lua @@ -145,6 +145,19 @@ function M.to_outbound(n, tag) elseif stype == "shadowsocks" then o.method = n.method or n.cipher or "aes-256-gcm" o.password = n.password or "" + -- SIP003 插件。sing-box 的 shadowsocks 出站只有 plugin(字符串)与 + -- plugin_opts(字符串)两个字段,plugin_opts 就是 SIP003 的原始参数串 + -- (`obfs=http;obfs-host=x`),不做任何翻译 —— 与 mihomo 的映射式 + -- plugin-opts 正好相反。 + -- 官方文档明确「Only two are supported: obfs-local and v2ray-plugin」, + -- 其余名字(kcptun / shadow-tls / restls / gost-plugin …)sing-box + -- 认不出来,会拒绝加载整份配置,所以按白名单过滤。 + -- 丢掉这一项时服务端只接受带插件的握手,导出的配置必然连不上且不报错。 + local pname, popts = util.parse_sip003_plugin(n.plugin) + if pname == "obfs-local" or pname == "v2ray-plugin" then + o.plugin = pname + if popts and popts ~= "" then o.plugin_opts = popts end + end elseif stype == "hysteria2" then o.password = n.password or "" -- 混淆(salamander):hysteria2 的 obfs 是 { type, password } 对象 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 6e873351f..be3fa0089 100644 --- a/luci-app-substore/root/usr/share/substore/output_uri.lua +++ b/luci-app-substore/root/usr/share/substore/output_uri.lua @@ -38,9 +38,21 @@ local function insecure_flag(n) end -- 生成单节点分享链接;无法生成时返回 nil +-- 分享链接里的 server 字面量。IPv6 必须加方括号:RFC 3986 的 authority 是 +-- `host:port`,而裸 IPv6 本身就含冒号,`fd00::1:443` 无法判断哪一段是端口。 +-- 本项目自己的解析器(util.split_hostport)就拆不开,重新导入得到 0 个节点; +-- 规范的客户端同样会拒绝或拆错。output_wireguard_conf 的 Endpoint 一直是这么做的。 +local function host_literal(server) + server = tostring(server or "") + if server:find(":", 1, true) and server:sub(1, 1) ~= "[" then + return "[" .. server .. "]" + end + return server +end + function M.to_share_uri(n) if type(n) ~= "table" or not n.server or not n.port then return nil end - local server = n.server + local server = host_literal(n.server) local port = tonumber(n.port) or 0 local name = url_encode(n.name or (server .. ":" .. tostring(port))) local proto = (n.proto or ""):lower() @@ -51,7 +63,15 @@ function M.to_share_uri(n) local method = n.method or n.cipher or "aes-256-gcm" local password = n.password or "" local userinfo = util.base64_encode(method .. ":" .. password) - return "ss://" .. userinfo .. "@" .. server .. ":" .. tostring(port) .. "#" .. name + -- SIP002:SS-URI = "ss://" userinfo "@" host ":" port [ "/" ] [ "?" plugin ] [ "#" tag ] + -- 插件参数整体做一次百分号编码(`;` 与 `=` 在 query 里必须转义)。 + -- 不回写这一项时,带插件的节点导出后再导入就永久丢失插件配置。 + local query = "" + if type(n.plugin) == "string" and n.plugin ~= "" then + query = "/?plugin=" .. url_encode(n.plugin) + end + return "ss://" .. userinfo .. "@" .. server .. ":" .. tostring(port) + .. query .. "#" .. name end if proto == "vmess" then 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 db09267da..c69251905 100644 --- a/luci-app-substore/root/usr/share/substore/output_v2ray.lua +++ b/luci-app-substore/root/usr/share/substore/output_v2ray.lua @@ -12,19 +12,30 @@ local function build_stream_settings(n) ss.network = net -- 传输层 + -- 空表必须用 util.JSON_EMPTY_OBJECT(编码成 {})而不是裸 {}:json_encode 的 + -- is_array 会把空表判成数组编成 [],而 Xray 用标准库 json.Unmarshal 解析配置, + -- wsSettings / grpcSettings / httpSettings 在它那边是结构体指针,把数组解进 + -- 结构体会直接 UnmarshalTypeError,Xray 拒绝启动。触发条件很普通:grpc 节点 + -- 没填服务名、ws 节点既没 path 也没 host。 if net == "ws" then - local ws = {} - if n.path then ws.path = n.path end - if n.host then ws.headers = { Host = n.host } end + local ws = util.JSON_EMPTY_OBJECT + if n.path or n.host then + ws = {} + if n.path then ws.path = n.path end + if n.host then ws.headers = { Host = n.host } end + end ss.wsSettings = ws elseif net == "grpc" then - local g = {} - if n.path then g.serviceName = n.path end + local g = util.JSON_EMPTY_OBJECT + if n.path then g = { serviceName = n.path } end ss.grpcSettings = g elseif net == "http" or net == "h2" then - local h = {} - if n.host then h.host = { n.host } end - if n.path then h.path = n.path end + local h = util.JSON_EMPTY_OBJECT + if n.host or n.path then + h = {} + if n.host then h.host = { n.host } end + if n.path then h.path = n.path end + end ss.httpSettings = h elseif net == "kcp" then ss.kcpSettings = { header = { type = n.headerType or "none" } } diff --git a/luci-app-substore/root/usr/share/substore/parser.lua b/luci-app-substore/root/usr/share/substore/parser.lua index 5b4be2270..30de0fb2d 100644 --- a/luci-app-substore/root/usr/share/substore/parser.lua +++ b/luci-app-substore/root/usr/share/substore/parser.lua @@ -3,7 +3,6 @@ local util = require("substore.util") local node = require("substore.node") -local parser_yaml = require("substore.parser_yaml") local parser_clash_yaml = require("substore.parser_clash_yaml") local parser_json_config = require("substore.parser_json_config") local parser_surge = require("substore.parser_surge") @@ -97,6 +96,98 @@ function M.detect(content) return "unknown" end +-- 「混合格式」提示里各格式的显示名 +local FORMAT_LABELS = { + uri = "URI 链接", + json = "JSON", + yaml = "Clash YAML", + ["wireguard-conf"] = "WireGuard .conf", + surge = "Surge/Loon 配置", +} + +-- 行首锚定,数出「整行就是一条节点链接」的行数。 +-- +-- 不能用 detect() 里那条宽松的 `content:find("://")`:Clash YAML 的 +-- `url: https://…`、sing-box JSON 的 `"url": "https://…"` 都含 "://", +-- 拿它当 URI 判据的话,一份**完全正常**、只是带了个订阅地址的配置 +-- 会被判成「YAML + URI 混合」而拒绝导入 —— 误判比漏报更糟。 +-- 锚定行首之后,只有「一行就是一条链接」才算数,那正是 URI 订阅的形态。 +local function count_uri_lines(content) + local n = 0 + for line in content:gmatch("[^\r\n]+") do + local scheme = line:match("^%s*(%a[%w]*):/") + if scheme and SUPPORTED[scheme:lower()] then n = n + 1 end + end + return n +end + +-- INI 段头:`[Interface]` / `[Peer]` / `[Proxy]` / `[server_local]` +-- 它们同样以 "[" 开头,但**不是** JSON 数组。 +-- detect() 靠判断次序避开了这一点(surge 与 .conf 都排在 "[" 之前,先命中就返回), +-- 而 detect_all 是「全部收集」,次序挡不住,必须显式排除 —— +-- 否则一份完全正常的 Surge 配置会被判成「Surge + JSON 混合」而拒绝导入。 +local function is_ini_section_head(line) + return line:match("^%s*%[%a[%w_%-%s]*%]%s*$") ~= nil +end + +-- 行首的 JSON 对象起始:`{` 单独一行,或 `{"key"…`。 +-- 对象的第一个键必然是带引号的字符串,因此 YAML 的流式映射 `{path: /x}` +-- (未加引号的键)不会被误判成 JSON。 +local function has_json_object_line(content) + for line in content:gmatch("[^\r\n]+") do + if line:match("^%s*{%s*$") or line:match('^%s*{%s*"') then return true end + end + return false +end + +-- 行首的 JSON 数组起始,排除 INI 段头 +local function has_json_array_line(content) + for line in content:gmatch("[^\r\n]+") do + if line:match("^%s*%[") and not is_ini_section_head(line) then return true end + end + return false +end + +-- 内容**同时**命中的全部格式,按 detect() 的优先级排列。 +-- +-- 只服务于「混合格式」提示(见 M.parse_local 的文本模式):detect() 只返回 +-- 优先级最高的那一个,其余格式的内容会被**静默丢弃** —— 粘进「URI + WG conf」, +-- 只导入到 WG 节点;粘进「URI + JSON」,只导入到 URI 节点。而 parse 返回的是 +-- 合法表,同步报成功,用户以为整份都导进来了。 +-- +-- 判据必须从严:这个返回值会被用来**拒绝导入**,误判会把一份正常配置挡在门外。 +-- 因此一律用「整份文档级标记 + 行首锚定」,宁可漏报,不可误报。 +function M.detect_all(content) + content = util.trim(content or "") + content = content:gsub("^\239\187\191", "") + local out = {} + if content == "" then return out end + local lower = content:lower() + + -- .conf 的双条件与 detect() 一致 + local wg_conf = lower:match("%[interface%]") and lower:match("privatekey%s*=") + if wg_conf then out[#out + 1] = "wireguard-conf" end + + if content:match("^[%s]*proxies:") or content:match("^[%s]*outbounds:") then + out[#out + 1] = "yaml" + else + for line in content:gmatch("[^\r\n]+") do + if line:match("^%s*proxies:%s*$") or line:match("^%s*outbounds:%s*$") then + out[#out + 1] = "yaml" + break + end + end + end + if parser_surge.is_config(content) then out[#out + 1] = "surge" end + if count_uri_lines(content) > 0 then out[#out + 1] = "uri" end + -- JSON 排在最后:它的判据最宽(一份 JSON 配置里也可能出现 URI 行), + -- 放前面不影响结果,但排最后读起来与 detect() 的优先级一致。 + if (has_json_object_line(content) or has_json_array_line(content)) and not wg_conf then + out[#out + 1] = "json" + end + return out +end + -- ---------- 协议解析 ---------- local function parse_ss(body) -- ss:// 兼容多种变体:base64(method:password@host:port)、method:password@host:port、 @@ -1087,6 +1178,42 @@ local function parse_yaml_content(content) end end end + -- sing-box 的 transport 同样是嵌套 map:{type, path, headers.Host, + -- service_name, host}。字段名对照 parser_json_config.parse_singbox_json + -- (本项目读取 sing-box 出站的权威实现),不另立一套。 + -- 不展开的话 net 只会取到 n.net / n.network —— sing-box 出站里这两个 + -- 字段都不存在,ws / grpc 节点会全部按 tcp 导入,客户端拿明文 tcp 去连 + -- 只开了 ws 的端口,握手必然失败且不报错。 + local transport_map = type(n.transport) == "table" and n.transport or nil + local t_net, t_path, t_host + if transport_map then + local tt = transport_map.type + if tt == "ws" then + t_net = "ws" + t_path = transport_map.path + if type(transport_map.headers) == "table" then + t_host = transport_map.headers.Host or transport_map.headers.host + end + elseif tt == "grpc" then + t_net = "grpc" + t_path = transport_map.service_name + elseif tt == "http" then + t_net = "http" + t_path = transport_map.path + -- http 的 host 是数组,取首个;httpupgrade 的是单个字符串 + if type(transport_map.host) == "table" then + t_host = transport_map.host[1] + elseif type(transport_map.host) == "string" and transport_map.host ~= "" then + t_host = transport_map.host + end + elseif tt == "httpupgrade" then + t_net = "http" + t_path = transport_map.path + if type(transport_map.host) == "string" and transport_map.host ~= "" then + t_host = transport_map.host + end + end + end -- 注意不能用 `(cond) and nil or x` 写法:Lua 的 and/or 在 cond 为真时 -- 结果是 nil,会被后面的 or 继续兜底,等于没生效 local tls_security = n.security @@ -1127,7 +1254,12 @@ local function parse_yaml_content(content) -- 行解析器读出来的是字符串,而 "false" / "0" 在 Lua 里也是真值, -- 直接透传会让 skip-cert-verify=false 变成「跳过证书校验」,故显式判假。 ["skip-cert-verify"] = skip_cert_verify, - net = n.net or n.network, + -- transport 展开的传输层优先:它才是 sing-box 的权威写法 + net = t_net or n.net or n.network, + -- 简易解析器此前**完全没有**把 path / host 带进 node_data, + -- 于是走这条回退路径的 ws / grpc 节点连 path 与 Host 都丢了 + path = t_path or n.path, + host = t_host or n.host, alterId = tonumber(n.alterId), } result[#result + 1] = node.normalize(node_data) @@ -1322,6 +1454,23 @@ function M.parse_local_link(url) end -- 解析订阅内容,返回 { nodes = {...}, format = "..." } 或 nil, err +-- 统一兜底:丢弃残缺节点(缺 server,或端口为空 / 不在 1–65535)。 +-- +-- 这类节点会被写成客户端加载不了的配置,而 mihomo / sing-box / Xray 都是 +-- 「一个坏节点废掉整份文件」——用户看到的是整个订阅不可用,而不是少一个节点。 +-- URI 路径一直在各解析器内部做这个校验(valid_hostport),Clash YAML / +-- sing-box JSON / Surge 路径漏了:一份 `port: 1e999` 的 YAML 能产出 port=inf +-- 的节点,落盘读回来又是 nil。各解析器只管把字段填对,校验统一放在这里。 +local function finish(nodes, format) + local out = {} + for _, n in ipairs(nodes or {}) do + if type(n) == "table" and valid_hostport(n.server, n.port) then + out[#out + 1] = n + end + end + return { nodes = out, format = format } +end + function M.parse(content) if not content or content == "" then return { nodes = {}, format = "empty" } end local format = M.detect(content) @@ -1332,7 +1481,7 @@ function M.parse(content) -- 不是格式不认识。与空串保持同一返回形态(0 节点、格式 empty)。 return { nodes = {}, format = "empty" } elseif format == "uri" then - return { nodes = parse_lines(split_lines(content)), format = "uri" } + return finish(parse_lines(split_lines(content)), "uri") elseif format == "base64" then -- 兼容标准 base64 与 base64url(- _ 无 padding):base64_url_decode 两者皆可 local decoded = util.base64_url_decode(content) @@ -1348,21 +1497,21 @@ function M.parse(content) if res then res.format = "base64" end return res, err end - return { nodes = parse_lines(split_lines(decoded)), format = "base64" } + return finish(parse_lines(split_lines(decoded)), "base64") elseif format == "json" then local nodes, err = parse_json_content(content) if not nodes then return nil, err end - return { nodes = nodes, format = "json" } + return finish(nodes, "json") elseif format == "yaml" then local nodes = M.parse_yaml(content) - return { nodes = nodes, format = "yaml" } + return finish(nodes, "yaml") elseif format == "surge" then local nodes = parser_surge.parse(content) - return { nodes = nodes, format = "surge" } + return finish(nodes, "surge") elseif format == "wireguard-conf" then local nodes, err = parse_wireguard_conf(content) if not nodes then return nil, err end - return { nodes = nodes, format = "wireguard-conf" } + return finish(nodes, "wireguard-conf") end return nil, "无法识别的订阅格式" end @@ -1454,7 +1603,20 @@ function M.parse_local(content, mode) end return { nodes = nodes, format = "local-form" } end - -- 文本模式:使用通用解析 + -- 文本模式:使用通用解析。 + -- + -- 但先挡住「一份文本里混了多种格式」:detect() 只认优先级最高的那一种, + -- 其余部分被静默丢弃,而 parse 返回的是合法表 —— 同步报成功, + -- 用户以为整份都导进来了(实测:「URI + WG conf」只剩 WG 节点, + -- 「URI + JSON」只剩 URI 节点,err 均为 nil)。 + -- 一次只导入一种格式,混用就明确报错,而不是默默少一半节点。 + local formats = M.detect_all(content) + if #formats > 1 then + local names = {} + for i, f in ipairs(formats) do names[i] = FORMAT_LABELS[f] or f end + return nil, "同一份文本里混用了多种格式(" .. table.concat(names, " + ") .. + "):一次只能导入一种格式,请拆开后分次导入" + end return M.parse(content) end diff --git a/luci-app-substore/root/usr/share/substore/parser_json_config.lua b/luci-app-substore/root/usr/share/substore/parser_json_config.lua index 67ffe0ec1..815355a14 100644 --- a/luci-app-substore/root/usr/share/substore/parser_json_config.lua +++ b/luci-app-substore/root/usr/share/substore/parser_json_config.lua @@ -119,6 +119,11 @@ function M.parse_singbox_json(content) if outbound.tls.insecure ~= nil then node_data["skip-cert-verify"] = outbound.tls.insecure end + -- uTLS 指纹。简易 YAML 侧早就读了这个字段,JSON 侧一直漏着, + -- 同一条订阅走两条导入路径会得到不同的节点。 + if type(outbound.tls.utls) == "table" and outbound.tls.utls.fingerprint then + node_data.fp = outbound.tls.utls.fingerprint + end end -- sing-box 只有 vmess 出站有 security 字段,且它是加密方式 @@ -130,6 +135,45 @@ function M.parse_singbox_json(content) node_data.net = outbound.network end + -- sing-box 用 transport 对象表达传输层,字段名与 v2ray 的 + -- streamSettings 完全不同(上游 configuration/shared/v2ray-transport/): + -- ws path、headers.Host + -- grpc service_name + -- http path、host(**数组**) + -- httpupgrade path、host(**单个字符串**) + -- quic 本项目模型没有该传输方式,不猜映射 + -- 只读 outbound.network 是不够的:sing-box 出站里根本没有这个字段, + -- 于是 ws / grpc / h2 节点全部按 tcp 导入 —— 客户端拿明文 tcp 去连 + -- 只开了 ws 的端口,握手必然失败且不报错。这类节点在机场导出的 + -- sing-box 配置里占比很高。 + if type(outbound.transport) == "table" then + local t = outbound.transport + if t.type == "ws" then + node_data.net = "ws" + if t.path then node_data.path = t.path end + if type(t.headers) == "table" then + node_data.host = t.headers.Host or t.headers.host + end + elseif t.type == "grpc" then + node_data.net = "grpc" + if t.service_name then node_data.path = t.service_name end + elseif t.type == "http" then + node_data.net = "http" + if t.path then node_data.path = t.path end + if type(t.host) == "table" then + if t.host[1] then node_data.host = t.host[1] end + elseif type(t.host) == "string" and t.host ~= "" then + node_data.host = t.host + end + elseif t.type == "httpupgrade" then + node_data.net = "http" + if t.path then node_data.path = t.path end + if type(t.host) == "string" and t.host ~= "" then + node_data.host = t.host + end + end + end + nodes[#nodes + 1] = node.normalize(node_data) end end diff --git a/luci-app-substore/root/usr/share/substore/parser_yaml.lua b/luci-app-substore/root/usr/share/substore/parser_yaml.lua deleted file mode 100644 index 9d4930d5e..000000000 --- a/luci-app-substore/root/usr/share/substore/parser_yaml.lua +++ /dev/null @@ -1,187 +0,0 @@ --- parser_yaml.lua — YAML 解析器:Clash/Sing-box → 统一节点模型(纯 Lua) --- luci-app-substore - -local util = require("substore.util") -local node = require("substore.node") - -local M = {} - -local proto_map = { - vmess = "vmess", - vless = "vless", - trojan = "trojan", - ss = "shadowsocks", - shadowsocks = "shadowsocks", - ssr = "ssr", - http = "http", - socks5 = "socks5", -} - -local function split_lines(content) - local out = {} - for line in content:gmatch("[^\r\n]+") do - out[#out + 1] = line - end - return out -end - -local function trim_quotes(v) - return v:gsub('^["\'](.*)["\']$', '%1') -end - -local function parse_yaml_section(content, section_key) - local lines = split_lines(content) - local nodes = {} - local current = nil - local in_section = false - local section_indent = 0 - local list_indent = nil - - for _, line in ipairs(lines) do - local trimmed = util.trim(line) - if trimmed == "" or trimmed:sub(1, 1) == "#" then - -- skip empty and comments - else - local indent = line:match("^%s*") and #line:match("^%s*") or 0 - -- section start - if trimmed:match("^" .. section_key .. ":%s*$") then - in_section = true - section_indent = indent - current = nil - list_indent = nil - elseif in_section then - if indent <= section_indent then - in_section = false - if current then - nodes[#nodes + 1] = current - current = nil - end - else - local list_match = line:match("^%s*%-%s*(.*)$") - if list_match then - if current then - nodes[#nodes + 1] = current - end - current = {} - -- inline key:value on same line as - - local k, v = list_match:match("^([^:]+):%s*(.+)$") - if k then - current[util.trim(k)] = trim_quotes(util.trim(v)) - end - list_indent = indent - elseif current and indent > (list_indent or section_indent) then - local k, v = trimmed:match("^([^:]+):%s*(.+)$") - if k then - k = util.trim(k) - v = util.trim(v) - v = trim_quotes(v) - -- ignore nested structures with empty value - if v ~= "" then - current[k] = v - end - end - end - end - end - end - end - - if current then - nodes[#nodes + 1] = current - end - - return nodes -end - -local function normalize_clash_node(raw) - if not raw.server or not raw.port then - return nil - end - local proto = proto_map[raw.type or raw.proto] or "vmess" - local node_data = { - proto = proto, - name = raw.name or raw.Name or (raw.server .. ":" .. tostring(raw.port or "")), - server = raw.server, - port = tonumber(raw.port), - uuid = raw.uuid or raw.id, - password = raw.password, - method = raw.cipher or raw.method, - net = raw.network or raw.net, - security = raw.tls or raw.security, - sni = raw.sni or raw.servername, - alterId = tonumber(raw.alterId), - } - return node.normalize(node_data) -end - -function M.parse_clash_yaml(content) - local raw_nodes = parse_yaml_section(content, "proxies") - -- also try outbounds for compatibility - if #raw_nodes == 0 then - raw_nodes = parse_yaml_section(content, "outbounds") - end - local result = {} - for _, raw in ipairs(raw_nodes) do - local n = normalize_clash_node(raw) - if n then - result[#result + 1] = n - end - end - return result -end - -local function normalize_singbox_node(raw) - if not raw.server or not raw.server_port then - return nil - end - local proto = raw.type - -- map proto names - if proto == "shadowsocks" then proto = "shadowsocks" end - if proto == "ss" then proto = "shadowsocks" end - local node_data = { - proto = proto, - name = raw.tag or (raw.server .. ":" .. tostring(raw.server_port or "")), - server = raw.server, - port = tonumber(raw.server_port), - uuid = raw.uuid, - password = raw.password, - method = raw.method, - security = raw.security, - sni = raw.servername, - net = raw.network, - } - return node.normalize(node_data) -end - -function M.parse_singbox_yaml(content) - local raw_nodes = parse_yaml_section(content, "outbounds") - local result = {} - for _, raw in ipairs(raw_nodes) do - local n = normalize_singbox_node(raw) - if n then - result[#result + 1] = n - end - end - return result -end - --- 通用解析:根据 section 自动选择 -function M.parse(content) - if not content or content == "" then - return {} - end - if content:find("proxies:", 1, true) then - return M.parse_clash_yaml(content) - elseif content:find("outbounds:", 1, true) then - -- 尝试 sing-box 格式 - local nodes = M.parse_singbox_yaml(content) - if #nodes > 0 then - return nodes - end - -- fallback to clash parser - return M.parse_clash_yaml(content) - end - return {} -end - -return M diff --git a/luci-app-substore/root/usr/share/substore/util.lua b/luci-app-substore/root/usr/share/substore/util.lua index a163ad085..9253626f5 100644 --- a/luci-app-substore/root/usr/share/substore/util.lua +++ b/luci-app-substore/root/usr/share/substore/util.lua @@ -194,6 +194,64 @@ function M.split_hostport(s) end end +-- 解析 SIP003 插件串(shadowsocks 的 `plugin` 字段)。 +-- +-- 格式出自 SIP002 §"For plugin argument",与 SIP003 的 SS_PLUGIN_OPTIONS 同构: +-- [;=]... 例:obfs-local;obfs=http;obfs-host=www.baidu.com +-- 值里的 `;`、`=`、`\` 按规范用反斜杠转义,因此切分必须跳过转义序列 —— +-- 否则 `path=/a\;b` 会被切成两段,得到一个并不存在的选项,而 `\;` 是 v2ray-plugin +-- 的 path 里完全可能出现的写法。 +-- +-- 返回 name, opts_string, opts_map: +-- * opts_string 是 `;` 之后的原样串(含转义),用于 sing-box 的 plugin_opts —— +-- 该字段就是交给插件进程的 SS_PLUGIN_OPTIONS,原样透传才不失真; +-- * opts_map 是拆好的键值对,用于需要逐键映射的目标(mihomo 的 plugin-opts)。 +-- 不带 `=` 的裸标志(v2ray-plugin 的 `tls`)取值为 true。 +-- 空串或没有插件名时返回 nil。 +function M.parse_sip003_plugin(raw) + if type(raw) ~= "string" or raw == "" then return nil end + local fields, buf = {}, {} + local i, n = 1, #raw + -- 必须是 while:下面遇到转义序列要一次吃掉两个字符,Lua 的数值 for 会在每轮 + -- 重新赋值控制变量,循环体内改 i 不生效。 + while i <= n do + local c = raw:sub(i, i) + if c == "\\" and i < n then + buf[#buf + 1] = c .. raw:sub(i + 1, i + 1) + i = i + 2 + elseif c == ";" then + fields[#fields + 1] = table.concat(buf) + buf = {} + i = i + 1 + else + buf[#buf + 1] = c + i = i + 1 + end + end + fields[#fields + 1] = table.concat(buf) + + local name = M.trim(fields[1] or "") + if name == "" then return nil end + + local opts_list = {} + for idx = 2, #fields do + local f = M.trim(fields[idx]) + if f ~= "" then opts_list[#opts_list + 1] = f end + end + + local map = {} + for _, f in ipairs(opts_list) do + local k, v = f:match("^([^=]+)=(.*)$") + if k then + map[M.trim(k)] = v + else + map[f] = true + end + end + + return name, table.concat(opts_list, ";"), map +end + -- ---------- JSON ---------- local function utf8_char(cp) if cp < 0x80 then @@ -225,7 +283,14 @@ local JSON_NULL = {} -- JSON 空对象占位符。Lua 的空表无法区分 {} 与 [](is_array 判定空表为数组), -- 而 sing-box / Xray 的部分字段必须是对象("tls": {}、"settings": {}), -- 编码成 [] 会被客户端拒绝。需要空对象时显式使用本常量。 -M.JSON_EMPTY_OBJECT = setmetatable({}, { __tostring = function() return "{}" end }) +-- +-- 标记用的是**元表**而不是表本身的同一性:解码器每遇到一个 `{}` 必须返回一张 +-- 新表,不能返回这张共享的常量表。core.load 拿到 items 后会直接往里塞订阅 +-- (`items[id] = {...}`),共享表会让这次写入落到全局哨兵上,随后 json_encode +-- 又把哨兵短路成 `{}` —— 结果是「接口返回新增成功、进程内 list 也看得到, +-- 但文件里什么都没写,重启即消失」。 +local JSON_OBJ_MT = { __tostring = function() return "{}" end } +M.JSON_EMPTY_OBJECT = setmetatable({}, JSON_OBJ_MT) function M.json_encode(v) local function enc(v) @@ -244,12 +309,22 @@ function M.json_encode(v) else return string.format("\\u%04x", c:byte()) end end) .. '"' elseif t == "number" then - if v ~= v then return "null" end + -- 非有限数必须编成 null:`%.14g` 对 inf / -inf 产出的是字面量 + -- `inf` / `-inf`,那不是合法 JSON —— 整份文件会因此解析不了。 + -- 触发路径是真实存在的:订阅响应头 `subscription-userinfo` 里的 + -- `total=1e999` 经 tonumber 就是 inf,写进 subscriptions.json 后 + -- 下一次 load() 直接判「列表文件已损坏」,全部订阅从界面上消失; + -- 节点侧的 port 同理,一个 inf 会毁掉该订阅的整个节点文件。 + if v ~= v or v == math.huge or v == -math.huge then return "null" end return string.format("%.14g", v) elseif t == "boolean" then return v and "true" or "false" elseif t == "table" then - if v == M.JSON_EMPTY_OBJECT then + -- 带空对象标记、且**确实还是空的**才编成 {}:标记只是「这里曾经是 + -- 空对象」的线索,调用方完全可能拿到解码结果后往里加键 + -- (core.load 就是这么用 items 的)。少了 next(v) == nil 这一半, + -- 加过键的表会被编回 {},内容静默丢失。 + if getmetatable(v) == JSON_OBJ_MT and next(v) == nil then return "{}" elseif v == JSON_NULL then -- 数组里的 null 解码时被换成 JSON_NULL 占位(见 json_decode), @@ -286,11 +361,24 @@ function M.json_decode(s) end end - local function parse() + -- 嵌套深度上限。parse 是递归下降,每进一层容器就多一层 Lua 调用栈; + -- 几千层时 Lua 5.1 会**抛出** "stack overflow" 而不是返回错误,而本函数的 + -- 契约是 `nil, err`。抛出的异常会一路冒到调用方:core.add_local 里的 + -- `M.sync(id)` 没有 pcall,控制器也没兜住,于是本地订阅导入在订阅行已经 + -- 写盘之后变成 HTTP 500,且 sync 内的 save_meta(error=...) 全被跳过, + -- 留下一条「看起来还在更新中」的空记录。 + -- 64 层远超任何真实配置(订阅节点是扁平的,sing-box 配置最深也不过十几层)。 + local MAX_DEPTH = 64 + + local function parse(depth) skip_ws() if i > len then return nil, "unexpected end" end local c = s:sub(i, i) + if (c == "{" or c == "[") and depth >= MAX_DEPTH then + return nil, "nesting too deep" + end + if c == "{" then i = i + 1 local obj = {} @@ -299,16 +387,18 @@ function M.json_decode(s) -- {} 与 [],裸 {} 会被 is_array 判成数组、编码回 [],于是 -- `{"tls":{}}` 往返变成 `{"tls":[]}` —— sing-box / Xray 里 -- 要求是对象的字段(tls / settings)会因此被客户端拒绝。 - if s:sub(i, i) == "}" then i = i + 1 return M.JSON_EMPTY_OBJECT end + -- 每次返回**新表**(带同一标记元表),不能返回共享常量: + -- 见 JSON_OBJ_MT 处的说明。 + if s:sub(i, i) == "}" then i = i + 1 return setmetatable({}, JSON_OBJ_MT) end while true do skip_ws() - local k, ke = parse() + local k, ke = parse(depth + 1) 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, verr = parse() + local val, verr = parse(depth + 1) if verr then return nil, verr end obj[k] = val skip_ws() @@ -329,7 +419,7 @@ function M.json_decode(s) skip_ws() if s:sub(i, i) == "]" then i = i + 1 return arr end while true do - local val, verr = parse() + local val, verr = parse(depth + 1) -- 必须把内层错误透出去:此前无条件把 nil 当成 null 占位, -- 于是 `[1,]` 这种畸形输入被静默接受(parse 报错后 i 未前进, -- 下一轮读到 `]` 就当成数组结束),解出 `{1, JSON_NULL}`。 @@ -407,7 +497,7 @@ function M.json_decode(s) end end - local v, perr = parse() + local v, perr = parse(0) -- 此前写成 `local v = parse(); return v` —— parse 的第二返回值(错误串) -- 被直接丢弃,于是 json_decode **永远不返回错误**:所有调用方的 -- `local data, err = util.json_decode(...)` 里 err 判断都是死代码, @@ -492,6 +582,62 @@ function M.ensure_dir(path, mode) end end +-- ---------- 互斥锁(mkdir 锁 + 陈旧锁回收) ---------- +-- +-- 用途:把「读整表 → 改 → 写整表」这段临界区串行化。LuCI 页面保存订阅、 +-- cron 定时更新、组合订阅自动重算三者可能同时发生,后写者整表覆盖先写者, +-- 结果是订阅静默丢失。本机没有 nixio,用不了 flock;Lua 5.1 的 io.open 也没有 +-- O_EXCL —— 但 `mkdir` 本身是原子的,成功者只有一个,拿它当锁。 +-- +-- 崩溃残留:持有者被杀掉时目录会留在原地,后来者必须能回收,否则永久死锁 +-- (比丢数据更糟:整台设备的订阅从此无法再修改)。判据是**锁目录自身的 +-- mtime**,不是目录里的文件 —— mtime 由 mkdir 原子地设好,不存在「目录已建、 +-- 时间戳还没写」的窗口;写在目录里的持有者文件只作排障用,不参与判定。 +M.LOCK_STALE = 60 -- 秒。超过这个时长仍未释放即视为持有者已死。 + +-- 锁目录是否已陈旧。用一条 shell 判断完成,不解析 stat 输出(少一处格式依赖)。 +-- stat 失败时回退成 0,于是「距今」必然大于阈值 —— 判定为陈旧,可以回收。 +-- 这是有意的失败方向:无法确认的锁宁可回收,也不要永久卡死。 +local function lock_is_stale(path, stale) + local cmd = string.format( + "[ $(( $(date +%%s) - $(stat -c %%Y %s 2>/dev/null || echo 0) )) -gt %d ]", + M.shq(path), stale) + return os.execute(cmd) == 0 +end + +-- 取锁。成功返回 true,调用方**必须**在临界区结束时调 lock_release。 +-- 失败返回 false(别人正持有,且未判定为陈旧)。 +-- opts.stale 覆盖超时阈值;opts.pid 写入持有者文件(默认 os.getpid 不可用,留 0)。 +function M.lock_acquire(path, opts) + opts = opts or {} + local stale = tonumber(opts.stale) or M.LOCK_STALE + -- 最多两轮:第一轮抢不到就查一次陈旧性,回收后第二轮重抢。 + for _ = 1, 2 do + if os.execute("mkdir " .. M.shq(path) .. " >/dev/null 2>&1") == 0 then + -- 持有者信息纯属排障用(`cat` 一眼看出是谁、什么时候拿的)。 + -- 写失败不影响正确性,因此不检查返回值。 + local f = io.open(path .. "/owner", "wb") + if f then + f:write(string.format("%d %d\n", tonumber(opts.pid) or 0, os.time())) + f:close() + end + return true + end + if lock_is_stale(path, stale) then + os.execute("rm -rf " .. M.shq(path) .. " >/dev/null 2>&1") + else + return false + end + end + return false +end + +-- 释放锁。持有者之外的人不应调用;这里不做持有者校验 —— 校验需要额外的 +-- 竞态窗口,而收益只是把「用错 API」变成一条错误信息。 +function M.lock_release(path) + os.execute("rm -rf " .. M.shq(path) .. " >/dev/null 2>&1") +end + -- ---------- 人性化格式 ---------- -- 字节数 → 可读字符串(B/K/M/G/T),如 20G、512M function M.human_bytes(n) diff --git a/luci-app-substore/tests/acl_menu_test.lua b/luci-app-substore/tests/acl_menu_test.lua new file mode 100644 index 000000000..db051d0d5 --- /dev/null +++ b/luci-app-substore/tests/acl_menu_test.lua @@ -0,0 +1,83 @@ +-- acl_menu_test.lua — 菜单 ACL 接线(M29) +-- 用法:lua5.1 tests/acl_menu_test.lua +-- +-- 背景:本应用此前**没有任何 ACL** —— 没有 acl.d 文件,菜单也没有 depends.acl, +-- 于是任何能登录 LuCI 的用户都能看到并使用它(含订阅 URL 与下载 token)。 +-- +-- 修复:新增 ACL 组 luci-app-substore(rpcd/acl.d)+ 菜单 depends.acl 引用它。 +-- +-- 这条链路上任何一环写错都**不会报错**,只会静默失效(ACL 形同虚设), +-- 因此必须把跨文件的接线关系固化下来: +-- A acl.d 文件存在、是合法 JSON、定义了组 luci-app-substore +-- B menu.d 两个条目的 depends.acl 引用的正是这个组名(拼错 = 组不存在) +-- C Makefile 必须真的安装 acl.d 文件(本包用逐文件 install 规则, +-- 漏一条规则 = 文件不随包发布 = ACL 不存在,而构建不会报错) + +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; print("PASS " .. name) + else failed = failed + 1; print("FAIL " .. name) end +end + +local ACL_PATH = "root/usr/share/rpcd/acl.d/luci-app-substore.json" +local MENU_PATH = "root/usr/share/luci/menu.d/luci-app-substore.json" + +-- ---------- A:ACL 组定义 ---------- +local raw = util.read_file(ACL_PATH) +check("acl.d file exists", raw ~= nil and raw ~= "") + +local acl = raw and util.json_decode(raw) or nil +check("acl.d is valid json", type(acl) == "table") +check("acl.d defines the app group", type(acl) == "table" and type(acl["luci-app-substore"]) == "table") +check("acl group has a description", + type(acl) == "table" and acl["luci-app-substore"] + and type(acl["luci-app-substore"].description) == "string" + and acl["luci-app-substore"].description ~= "") +check("acl group declares read perms", + acl and acl["luci-app-substore"] and type(acl["luci-app-substore"].read) == "table") +check("acl group declares write perms", + acl and acl["luci-app-substore"] and type(acl["luci-app-substore"].write) == "table") + +-- ---------- B:菜单引用同一个组名 ---------- +local mraw = util.read_file(MENU_PATH) +check("menu.d file exists", mraw ~= nil and mraw ~= "") +local menu = mraw and util.json_decode(mraw) or nil +check("menu.d is valid json", type(menu) == "table") + +local entries = { "admin/services/substore", "admin/services/substore/list" } +for _, path in ipairs(entries) do + local e = type(menu) == "table" and menu[path] or nil + check("menu has " .. path, type(e) == "table") + local aclref = e and e.depends and e.depends.acl + check(path .. " declares depends.acl", type(aclref) == "table" and #aclref > 0) + -- 引用的组名必须与 acl.d 里定义的**完全一致**:拼错不会报错, + -- 只会让这个菜单项对任何非 root 用户都不可见(或让 ACL 形同虚设) + check(path .. " references the defined group", + type(aclref) == "table" and aclref[1] == "luci-app-substore" + and type(acl) == "table" and acl[aclref[1]] ~= nil) +end + +-- 菜单 action 指向的 view 必须在控制器里注册过(组名对了但路径写错同样静默失效) +local ctl_raw = util.read_file("root/usr/lib/lua/luci/controller/admin/substore.lua") or "" +check("menu view path is registered in controller", + ctl_raw:find('"admin", "services", "substore", "list"', 1, true) ~= nil + or ctl_raw:find("'admin', 'services', 'substore', 'list'", 1, true) ~= nil) + +-- ---------- C:Makefile 安装规则 ---------- +local mk = util.read_file("Makefile") or "" +check("Makefile installs the acl.d file", + mk:find("root/usr/share/rpcd/acl.d/luci-app-substore.json", 1, true) ~= nil) +check("Makefile creates the acl.d directory", + mk:find("/usr/share/rpcd/acl.d", 1, true) ~= nil) +check("Makefile installs the menu.d file", + mk:find("root/usr/share/luci/menu.d/luci-app-substore.json", 1, true) ~= nil) + +-- 两个文件都在 root/ 下(否则打包时不会被带上) +check("acl.d lives under root/", util.read_file("root/usr/share/rpcd/acl.d/luci-app-substore.json") ~= 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/controller_local_test.lua b/luci-app-substore/tests/controller_local_test.lua index 46ff249e8..3a59de6b0 100644 --- a/luci-app-substore/tests/controller_local_test.lua +++ b/luci-app-substore/tests/controller_local_test.lua @@ -152,12 +152,14 @@ ctl.action_local_save() check("local_save success has no err", not has_err()) check("local_save success ran sync", SYNC_CALLS == 1) --- ---------- 无 CSRF token:不做任何事,也不报错 ---------- +-- ---------- 无 CSRF token:拒绝执行,并明确回显(§18) ---------- +-- 此前是「静默重定向」:表单没提交上来,页面却和成功一样跳回去, +-- 用户看不到任何原因。校验失败也是失败,必须让用户看见(M28)。 reset() FORM = { name = "", content = "" } ctl.action_local_create() check("no token still redirects", type(LAST_REDIRECT) == "string") -check("no token no error banner", not has_err()) +check("no token reports error", has_err()) -- ---------- §18 单节点保存:解析/写入失败必须可见 ---------- -- 修复前 action_node_save 只在成功分支做事,其余情况一律静默重定向, @@ -237,14 +239,14 @@ ctl.action_node_save() check("node_save write failure reports error", has_err()) check("node_save write failure surfaced reason", URLENC[#URLENC] == "写入节点数据失败") --- 无 token:不做任何事,也不报错 +-- 无 token:拒绝执行,但**必须回显**(见上面 M28 的说明) reset() NODES = { { name = "old" } } WRITE_CALLS = 0 FORM = { id = "s00000001", idx = "1", content = VALID_NODE } ctl.action_node_save() check("node_save no token redirects", type(LAST_REDIRECT) == "string") -check("node_save no token no error banner", not has_err()) +check("node_save no token reports error", has_err()) check("node_save no token does not write", WRITE_CALLS == 0) print(string.format("\n%d passed, %d failed", passed, failed)) diff --git a/luci-app-substore/tests/controller_robustness_test.lua b/luci-app-substore/tests/controller_robustness_test.lua index 14888fe69..5bed1fcf9 100644 --- a/luci-app-substore/tests/controller_robustness_test.lua +++ b/luci-app-substore/tests/controller_robustness_test.lua @@ -93,6 +93,11 @@ local function core_reset() CORE.sync = function() return true end CORE.write_cron = function() end CORE.cron_time_valid = function() return true end + -- 控制器在 read_ua_fields 里调用它解析「订阅客户端类型」(预设 key → UA 字符串)。 + -- 与上面的 validate_rename_map 同理:stub 缺这个函数会让整条保存路径 nil 调用。 + -- 这里返回 ""(不设置 UA),让用例聚焦各自要覆盖的字段。 + CORE.resolve_user_agent = function() return "" end + CORE.UA_PRESETS = { { key = "clash-verge", label = "Clash Verge", ua = "clash-verge/v2.5.0" } } CORE.generate_link = function() return "body", "text/plain", "f.txt" end CORE.RULE_PROTOS = { "vmess", "trojan" } end @@ -175,6 +180,85 @@ FORM = { token = "t", id = "s00000001" } ctl.action_delete() check("delete success has no err", not has_err()) +-- ---------- 勾选批量删除:id 支持逗号分隔多个 ---------- +-- 行内删除按钮提交单个 id(上面两条覆盖);表头的「删除」按钮把勾选的多个 +-- id 用逗号拼起来提交,走同一个 action —— 与节点页 action_node_delete 的 +-- idx 是同一套约定。 +reset() +local seen_ids = {} +CORE.remove = function(id) seen_ids[#seen_ids + 1] = id; return true end +FORM = { token = "t", id = "s00000001,s00000002,s00000003" } +ctl.action_delete() +check("multi delete removes every id", + #seen_ids == 3 and seen_ids[1] == "s00000001" and seen_ids[3] == "s00000003") +check("multi delete success has no err", not has_err()) + +-- 逗号两侧的空格不影响切分(浏览器不会加空格,手工构造 / 复制粘贴会) +reset() +seen_ids = {} +CORE.remove = function(id) seen_ids[#seen_ids + 1] = id; return true end +FORM = { token = "t", id = " s00000001 , s00000002 " } +ctl.action_delete() +check("delete tolerates spaces around ids", + #seen_ids == 2 and seen_ids[1] == "s00000001" and seen_ids[2] == "s00000002") + +-- 批量删除成功后写一次 cron:删掉的订阅不能继续留在 /etc/cron.d/substore 里 +-- 断言里同时数 core.remove 的调用次数:只断言 write_cron 的话,改动前的实现 +-- (把整串 id 当成一个 id 传下去)也会通过 —— stub 对任何 id 都返回 true。 +reset() +local cron_writes, rm_calls = 0, {} +CORE.remove = function(id) rm_calls[#rm_calls + 1] = id; return true end +CORE.write_cron = function() cron_writes = cron_writes + 1 end +FORM = { token = "t", id = "s00000001,s00000002" } +ctl.action_delete() +check("multi delete rewrites cron once after removing both", + cron_writes == 1 and #rm_calls == 2) + +-- 部分失败必须回显:勾了 3 个只删掉 1 个,不能显示成「成功」。 +-- 同样要数调用次数,否则改动前的实现也会「通过」。 +-- +-- 同时断言 cron 被重写:删掉的那 1 个订阅的 cron 行必须清掉, +-- 否则 substore-cron.sh 会拿着已不存在的 id 反复失败(退出码非 0)。 +reset() +local partial_calls, partial_cron = 0, 0 +CORE.remove = function(id) partial_calls = partial_calls + 1; return id == "s00000001" end +CORE.write_cron = function() partial_cron = partial_cron + 1 end +FORM = { token = "t", id = "s00000001,s00000002,s00000003" } +ctl.action_delete() +check("partial delete attempts every id and reports error", + has_err() and partial_calls == 3) +check("partial delete rewrites cron for the removed ones", partial_cron == 1) + +-- 全部失败:一个都没删掉 → 没有任何订阅的 cron 行需要清理,不重写 +reset() +local none_cron = 0 +CORE.remove = function() return false, "订阅不存在" end +CORE.write_cron = function() none_cron = none_cron + 1 end +FORM = { token = "t", id = "s00000001,s00000002" } +ctl.action_delete() +check("all-failed delete does not rewrite cron", none_cron == 0) + +-- 全部失败:回显 core.remove 给出的原因 +reset() +CORE.remove = function() return false, "订阅不存在" end +FORM = { token = "t", id = "s00000001,s00000002" } +ctl.action_delete() +check("all-failed delete reports core reason", has_err()) + +-- 一个可用 id 都没有:不能静默跳回(否则用户以为删了) +reset() +FORM = { token = "t", id = " , " } +ctl.action_delete() +check("delete with no usable id reports error", has_err()) + +-- 只有分隔符的 id 不得被当成「一个叫 ',' 的订阅」交给 core +reset() +local called = 0 +CORE.remove = function() called = called + 1; return true end +FORM = { token = "t", id = ",,," } +ctl.action_delete() +check("delete does not pass empty ids to core", called == 0) + -- action_combo_save:名称为空 reset() FORM = { token = "t", name = "" } @@ -246,5 +330,74 @@ FORM = { token = "t", id = "s00000001", idx = "1" } ctl.action_node_delete() check("node_delete success has no err", not has_err()) +-- ---------- M28:CSRF token 必须真的校验 ---------- +-- 上游 LuCI 渲染表单时模板变量 `token` 就是 luci.dispatcher.context.authtoken +-- (luci-lua-runtime/luasrc/template.lua 的 viewns 元表), +-- 所以「提交的 token == authtoken」正是框架自己那套判定,不会误拒合法表单。 +-- 此前的判定是 `token ~= nil`:`token=`(空串)也能通过,等于没有校验。 +_G.luci.dispatcher.context = { authtoken = "secret" } + +-- 合法表单必须照常通过(这条是关键:改了校验不能把正常提交挡在门外) +reset() +local ok_calls = 0 +CORE.remove = function() ok_calls = ok_calls + 1; return true end +FORM = { token = "secret", id = "s00000001" } +ctl.action_delete() +check("M28 valid token accepted", ok_calls == 1 and not has_err()) + +-- 空串 token:此前被 `~= nil` 放过,现在必须拒绝 +reset() +local empty_calls = 0 +CORE.remove = function() empty_calls = empty_calls + 1; return true end +FORM = { token = "", id = "s00000001" } +ctl.action_delete() +check("M28 empty token rejected", empty_calls == 0) +check("M28 empty token reports error", has_err()) + +-- 不匹配的 token 同样拒绝 +reset() +local bad_calls = 0 +CORE.remove = function() bad_calls = bad_calls + 1; return true end +FORM = { token = "wrong", id = "s00000001" } +ctl.action_delete() +check("M28 mismatched token rejected", bad_calls == 0) +check("M28 mismatched token reports error", has_err()) + +-- 校验失败必须对所有动作生效(此前 9 个动作都是静默重定向) +reset() +local created = 0 +CORE.add = function() created = created + 1; return "s0000000a" end +FORM = { token = "", name = "n", url = "http://e.com/s" } +ctl.action_create() +check("M28 create rejects empty token", created == 0 and has_err()) + +reset() +local updated = 0 +CORE.sync = function() updated = updated + 1; return true end +FORM = { token = "wrong", id = "s00000001" } +ctl.action_update() +check("M28 update rejects bad token", updated == 0 and has_err()) + +-- 节点页动作走 back_to_nodes,同样要带 err +reset() +local wrote = 0 +CORE.read_nodes = function() return { { name = "a" } } end +CORE.write_nodes = function() wrote = wrote + 1; return true end +FORM = { token = "", id = "s00000001", idx = "1", content = "[]" } +ctl.action_node_save() +check("M28 node_save rejects empty token", + wrote == 0 and LAST_REDIRECT:find("err=", 1, true) ~= nil) + +-- 取不到 authtoken(老版本 LuCI / 非标准上下文)时退回原判定, +-- 不能因为拿不到框架内部字段就把所有表单都拒掉。 +_G.luci.dispatcher.context = nil +reset() +local legacy_calls = 0 +CORE.remove = function() legacy_calls = legacy_calls + 1; return true end +FORM = { token = "t", id = "s00000001" } +ctl.action_delete() +check("M28 falls back when authtoken unavailable", + legacy_calls == 1 and 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/converter_protocol_test.lua b/luci-app-substore/tests/converter_protocol_test.lua deleted file mode 100644 index 60f62a742..000000000 --- a/luci-app-substore/tests/converter_protocol_test.lua +++ /dev/null @@ -1,176 +0,0 @@ --- converter_protocol_test.lua — 协议转换单元测试 --- 用法:lua5.1 tests/converter_protocol_test.lua - -package.path = "./root/usr/share/?.lua;" .. package.path - -local converter = require("substore.converter") - -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 function eq(a, b) - if type(a) ~= type(b) then return false end - if type(a) == "table" then - for k, v in pairs(a) do if not eq(v, b[k]) then return false end end - for k in pairs(b) do if a[k] == nil then return false end end - return true - end - return a == b -end - --- ---------- 基础功能测试 ---------- -check("module exists", converter ~= nil) -check("convert_protocol method exists", type(converter.convert_protocol) == "function") - --- ---------- vmess 转换测试 ---------- -local vmess_node = { - proto = "vmess", - server = "1.1.1.1", - port = 443, - uuid = "test-uuid-vmess", - alterId = 0, - cipher = "auto", - network = "ws", - security = "tls", - sni = "example.com", - path = "/ws", - host = "example.com", -} - --- vmess → vless -local vless_converted = converter.convert_protocol(vmess_node, "vless") -check("vmess to vless proto", vless_converted and vless_converted.proto == "vless") -check("vmess to vless uuid", vless_converted and vless_converted.uuid == "test-uuid-vmess") -check("vmess to vless server", vless_converted and vless_converted.server == "1.1.1.1") -check("vmess to vless port", vless_converted and vless_converted.port == 443) -check("vmess to vless network", vless_converted and vless_converted.network == "ws") -check("vmess to vless security", vless_converted and vless_converted.security == "tls") -check("vmess to vless sni", vless_converted and vless_converted.sni == "example.com") -check("vmess to vless path", vless_converted and vless_converted.path == "/ws") -check("vmess to vless host", vless_converted and vless_converted.host == "example.com") -check("vmess to vless no alterId", vless_converted and vless_converted.alterId == nil) -check("vmess to vless no cipher", vless_converted and vless_converted.cipher == nil) - --- vmess → trojan -local trojan_converted = converter.convert_protocol(vmess_node, "trojan") -check("vmess to trojan proto", trojan_converted and trojan_converted.proto == "trojan") -check("vmess to trojan password", trojan_converted and trojan_converted.password == "test-uuid-vmess") -check("vmess to trojan server", trojan_converted and trojan_converted.server == "1.1.1.1") -check("vmess to trojan network", trojan_converted and trojan_converted.network == "ws") - --- vmess → shadowsocks (不支持) -local ss_converted = converter.convert_protocol(vmess_node, "shadowsocks") -check("vmess to ss unsupported", ss_converted == nil) - --- ---------- vless 转换测试 ---------- -local vless_node = { - proto = "vless", - server = "2.2.2.2", - port = 8443, - uuid = "test-uuid-vless", - security = "tls", - sni = "vless.example.com", - network = "grpc", -} - --- vless → vmess -local vmess_from_vless = converter.convert_protocol(vless_node, "vmess") -check("vless to vmess proto", vmess_from_vless and vmess_from_vless.proto == "vmess") -check("vless to vmess uuid", vmess_from_vless and vmess_from_vless.uuid == "test-uuid-vless") -check("vless to vmess server", vmess_from_vless and vmess_from_vless.server == "2.2.2.2") -check("vless to vmess network", vmess_from_vless and vmess_from_vless.network == "grpc") - --- vless → trojan -local trojan_from_vless = converter.convert_protocol(vless_node, "trojan") -check("vless to trojan proto", trojan_from_vless and trojan_from_vless.proto == "trojan") -check("vless to trojan password", trojan_from_vless and trojan_from_vless.password == "test-uuid-vless") -check("vless to trojan sni", trojan_from_vless and trojan_from_vless.sni == "vless.example.com") - --- ---------- trojan 转换测试 ---------- -local trojan_node = { - proto = "trojan", - server = "3.3.3.3", - port = 443, - password = "trojan-pass", - sni = "example.com", - network = "tcp", -} - --- trojan → vmess (password → uuid 生成) -local vmess_from_trojan = converter.convert_protocol(trojan_node, "vmess") -check("trojan to vmess proto", vmess_from_trojan and vmess_from_trojan.proto == "vmess") -check("trojan to vmess uuid exists", vmess_from_trojan and vmess_from_trojan.uuid ~= nil) -check("trojan to vmess uuid not password", vmess_from_trojan and vmess_from_trojan.uuid ~= "trojan-pass") -check("trojan to vmess server", vmess_from_trojan and vmess_from_trojan.server == "3.3.3.3") - --- trojan → vless -local vless_from_trojan = converter.convert_protocol(trojan_node, "vless") -check("trojan to vless proto", vless_from_trojan and vless_from_trojan.proto == "vless") -check("trojan to vless uuid exists", vless_from_trojan and vless_from_trojan.uuid ~= nil) -check("trojan to vless sni", vless_from_trojan and vless_from_trojan.sni == "example.com") - --- ---------- shadowsocks 转换测试 (不支持) ---------- -local ss_node = { - proto = "shadowsocks", - server = "4.4.4.4", - port = 8388, - method = "aes-256-gcm", - password = "ss-pass", -} - -local vmess_from_ss = converter.convert_protocol(ss_node, "vmess") -check("ss to vmess unsupported", vmess_from_ss == nil) - -local vless_from_ss = converter.convert_protocol(ss_node, "vless") -check("ss to vless unsupported", vless_from_ss == nil) - --- ---------- 特殊字段转换测试 ---------- -local vmess_with_special = { - proto = "vmess", - server = "5.5.5.5", - port = 443, - uuid = "uuid-special", - network = "h2", - security = "reality", - sni = "sni.example.com", - alpn = "h2,http/1.1", - fp = "chrome", - headerType = "none", -} - -local vless_special = converter.convert_protocol(vmess_with_special, "vless") -check("network h2 preserved", vless_special and vless_special.network == "h2") -check("security reality preserved", vless_special and vless_special.security == "reality") -check("sni preserved", vless_special and vless_special.sni == "sni.example.com") -check("alpn preserved", vless_special and vless_special.alpn == "h2,http/1.1") -check("fp preserved", vless_special and vless_special.fp == "chrome") - --- ---------- 字段缺失处理测试 ---------- -local vmess_minimal = { - proto = "vmess", - server = "6.6.6.6", - port = 443, - uuid = "uuid-minimal", -} - -local vless_minimal = converter.convert_protocol(vmess_minimal, "vless") -check("minimal vmess to vless works", vless_minimal ~= nil) -check("minimal server preserved", vless_minimal and vless_minimal.server == "6.6.6.6") -check("minimal uuid preserved", vless_minimal and vless_minimal.uuid == "uuid-minimal") - --- ---------- 不支持的转换 ---------- -local result = converter.convert_protocol(vmess_node, "unsupported_proto") -check("unsupported target returns nil", result == 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/converter_template_test.lua b/luci-app-substore/tests/converter_template_test.lua deleted file mode 100644 index 5f8ce90b3..000000000 --- a/luci-app-substore/tests/converter_template_test.lua +++ /dev/null @@ -1,115 +0,0 @@ --- converter_template_test.lua — URL 模板渲染单元测试 --- 用法:lua5.1 tests/converter_template_test.lua - -package.path = "./root/usr/share/?.lua;" .. package.path - -local converter = require("substore.converter") - -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 - --- ---------- 基础功能测试 ---------- -check("module exists", converter ~= nil) -check("render_template method exists", type(converter.render_template) == "function") -check("render_url_template method exists", type(converter.render_url_template) == "function") - --- ---------- 变量替换测试 ---------- -local node1 = { name = "TestNode", server = "1.1.1.1", port = 443, proto = "vmess", uuid = "abc-123", password = "pass" } -check("render_template variable name", converter.render_template("{name}", node1) == "TestNode") -check("render_template variable server", converter.render_template("{server}", node1) == "1.1.1.1") -check("render_template variable port", converter.render_template("{port}", node1) == "443") -check("render_template variable proto", converter.render_template("{proto}", node1) == "vmess") -check("render_template variable uuid", converter.render_template("{uuid}", node1) == "abc-123") -check("render_template variable password", converter.render_template("{password}", node1) == "pass") -check("render_template multiple vars", converter.render_template("{name}@{server}:{port}", node1) == "TestNode@1.1.1.1:443") - --- ---------- 函数调用测试 ---------- -check("render_template upper", converter.render_template("{upper:name}", node1) == "TESTNODE") -check("render_template lower", converter.render_template("{lower:server}", node1) == "1.1.1.1") -check("render_template upper server", converter.render_template("{upper:server}", node1) == "1.1.1.1") -check("render_template lower proto", converter.render_template("{lower:proto}", node1) == "vmess") - --- ---------- 默认值测试 ---------- -check("render_template default missing", converter.render_template("{name|DefaultName}", {}) == "DefaultName") -check("render_template default present", converter.render_template("{name|DefaultName}", node1) == "TestNode") -check("render_template default empty", converter.render_template("{missing|fallback}", node1) == "fallback") - --- ---------- 条件渲染测试 ---------- -check("render_template conditional true", converter.render_template("{if:proto?vmess:vless}", node1) == "vmess") -check("render_template conditional false", converter.render_template("{if:missing?yes:no}", node1) == "no") -check("render_template conditional with value", converter.render_template("{if:server?has-server:no-server}", node1) == "has-server") - --- ---------- 批量渲染测试 ---------- -local nodes = { - { name = "Node1", server = "1.1.1.1", port = 443 }, - { name = "Node2", server = "2.2.2.2", port = 8443 }, -} -local urls = converter.render_url_template(nodes, "{name}-{server}:{port}") -check("render_url_template count", type(urls) == "table" and #urls == 2) -check("render_url_template first", urls[1] == "Node1-1.1.1.1:443") -check("render_url_template second", urls[2] == "Node2-2.2.2.2:8443") - --- ---------- 协议 URL 模板测试 ---------- -local vmess_node = { - name = "VMessTest", - server = "1.1.1.1", - port = 443, - uuid = "uuid-vmess", - security = "tls", -} -local vless_node = { - name = "VLESS Test", - server = "2.2.2.2", - port = 8443, - uuid = "uuid-vless", -} -local trojan_node = { - name = "TrojanTest", - server = "3.3.3.3", - port = 443, - password = "trojan-pass", -} -local ss_node = { - name = "SSTest", - server = "4.4.4.4", - port = 8388, - method = "aes-256-gcm", - password = "ss-pass", -} - --- 检查模板常量存在 -check("vmess template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.vmess ~= nil) -check("vless template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.vless ~= nil) -check("trojan template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.trojan ~= nil) -check("ss template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.ss ~= nil) - --- 使用模板渲染 -local vmess_url = converter.render_template(converter.URL_TEMPLATES.vmess, vmess_node) -check("vmess url contains uuid", vmess_url:find("uuid-vmess", 1, true) ~= nil) -check("vmess url contains server", vmess_url:find("1.1.1.1") ~= nil) -check("vmess url contains name", vmess_url:find("VMessTest") ~= nil) - -local vless_url = converter.render_template(converter.URL_TEMPLATES.vless, vless_node) -check("vless url contains uuid", vless_url:find("uuid-vless", 1, true) ~= nil) -check("vless url contains server", vless_url:find("2.2.2.2") ~= nil) - -local trojan_url = converter.render_template(converter.URL_TEMPLATES.trojan, trojan_node) -check("trojan url contains password", trojan_url:find("trojan-pass", 1, true) ~= nil) -check("trojan url contains server", trojan_url:find("3.3.3.3") ~= nil) - -local ss_url = converter.render_template(converter.URL_TEMPLATES.ss, ss_node) -check("ss url contains method", ss_url:find("aes-256-gcm", 1, true) ~= nil) -check("ss url contains password", ss_url:find("ss-pass", 1, true) ~= 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/converter_test.lua b/luci-app-substore/tests/converter_test.lua deleted file mode 100644 index 06c3af8ff..000000000 --- a/luci-app-substore/tests/converter_test.lua +++ /dev/null @@ -1,81 +0,0 @@ --- converter_test.lua — 转换引擎单元测试 --- 用法:lua5.1 tests/converter_test.lua - -package.path = "./root/usr/share/?.lua;" .. package.path - -local converter = require("substore.converter") - -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 - --- ---------- 基础功能测试 ---------- -check("module exists", converter ~= nil) -check("validate_nodes method exists", type(converter.validate_nodes) == "function") -check("convert method exists", type(converter.convert) == "function") -check("process_pipeline method exists", type(converter.process_pipeline) == "function") -check("apply_template method exists", type(converter.apply_template) == "function") - --- ---------- validate_nodes 测试 ---------- -local valid_nodes = { - { proto = "vmess", server = "1.1.1.1", port = 443, name = "Node1" }, - { proto = "vless", server = "2.2.2.2", port = 8443, name = "Node2" }, -} -local ok, errs = converter.validate_nodes(valid_nodes) -check("validate_nodes valid", ok == true and (errs == nil or #errs == 0)) - -local invalid_nodes = { - { proto = "vmess", server = "1.1.1.1", port = nil }, - { proto = nil, server = "2.2.2.2", port = 443 }, -} -local ok2, errs2 = converter.validate_nodes(invalid_nodes) -check("validate_nodes invalid", ok2 == false and errs2 ~= nil and #errs2 > 0) - --- ---------- convert 测试 ---------- -local nodes_for_output = { - { proto = "vmess", name = "TestVMess", server = "1.1.1.1", port = 443, uuid = "uuid-1" }, - { proto = "trojan", name = "TestTrojan", server = "2.2.2.2", port = 443, password = "pass" }, -} -local yaml_out = converter.convert(nodes_for_output, "clash", {}) -check("convert clash returns string", type(yaml_out) == "string" and yaml_out:find("proxies:") ~= nil) -check("convert clash contains node name", yaml_out and yaml_out:find("TestVMess") ~= nil) - -local json_out = converter.convert(nodes_for_output, "json", {}) -check("convert json returns string", type(json_out) == "string" and json_out:find("TestVMess") ~= nil) - -local b64_out = converter.convert(nodes_for_output, "base64", {}) -check("convert base64 returns string", type(b64_out) == "string" and #b64_out > 0) - --- ---------- process_pipeline 测试 ---------- -local pipeline_nodes = { - { proto = "vmess", name = "HK VMESS", server = "1.1.1.1", port = 443 }, - { proto = "trojan", name = "US TROJAN", server = "2.2.2.2", port = 443 }, - { proto = "vmess", name = "JP VMESS", server = "3.3.3.3", port = 443 }, -} -local pipeline = { - { type = "filter", opts = { proto = "vmess" } }, -} -local filtered = converter.process_pipeline(pipeline_nodes, pipeline) -check("process_pipeline filter count", filtered ~= nil and #filtered == 2) -check("process_pipeline filter proto", filtered and filtered[1].proto == "vmess" and filtered[2].proto == "vmess") - --- ---------- apply_template 测试 ---------- -local template_nodes = { - { proto = "vmess", name = "NodeA", server = "1.1.1.1", port = 443 }, -} -local template = "{{name}}-{{proto}}" -local templated = converter.apply_template(template_nodes, template) -check("apply_template returns table", type(templated) == "table") -check("apply_template preserves count", templated ~= nil and #templated == 1) - --- ---------- 结果 ---------- -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/core_combo_test.lua b/luci-app-substore/tests/core_combo_test.lua index 00ea8440e..2bac8c994 100644 --- a/luci-app-substore/tests/core_combo_test.lua +++ b/luci-app-substore/tests/core_combo_test.lua @@ -99,6 +99,71 @@ check("empty sources err", eerr ~= nil) local e2 = core.add_combo("坏源", { "not-a-valid-id!!!" }) check("invalid source returns nil", e2 == nil) +-- ---------- 删除源订阅:引用它的组合必须立刻重算(本轮修复) ---------- +-- 此前只有 M.sync 与三个节点级控制器入口会调 refresh_combos,删除订阅这条路径 +-- 漏了:组合的物化节点在 nodes/.json,只有 combo_refresh 会重写它, +-- 于是组合的下载链接会继续吐已删订阅的节点,直到别的源更新才被动纠正。 +local srcD = core.add("订阅D", "http://example.com/d") +local srcE = core.add("订阅E", "http://example.com/e") +core.write_nodes(srcD, { + { proto = "vmess", name = "D-香港-01", server = "5.5.5.5", port = 443, uuid = "u5" }, + { proto = "vmess", name = "D-美国-01", server = "6.6.6.6", port = 443, uuid = "u6" }, +}) +core.write_nodes(srcE, { + { proto = "vmess", name = "E-日本-01", server = "7.7.7.7", port = 443, uuid = "u7" }, +}) +local comboDE = core.add_combo("组合D+E", { srcD, srcE }) +check("combo D+E node_count = 3", core.get(comboDE).node_count == 3) + +check("remove source D returns true", core.remove(srcD) == true) + +local deMeta = core.get(comboDE) +check("combo node_count drops right after source delete", deMeta.node_count == 1) +local deNodes = core.read_nodes(comboDE) +check("combo materialized nodes drop the deleted source", #deNodes == 1) +local leaked = false +for _, n in ipairs(deNodes) do + if n.server == "5.5.5.5" or n.server == "6.6.6.6" then leaked = true end +end +check("no node from the deleted source survives", not leaked) + +-- 死 id 必须从 sources 里摘掉,否则列表页「来源」列会显示裸 id +local deSrcs = deMeta.sources +local deadLeft = false +for _, s in ipairs(type(deSrcs) == "table" and deSrcs or {}) do + if s == srcD then deadLeft = true end +end +check("deleted source pruned from combo sources", + not deadLeft and type(deSrcs) == "table" and #deSrcs == 1 and deSrcs[1] == srcE) + +-- 下载链接(真正被 Passwall / OpenClash 拉取的东西)也不该再有已删订阅的节点 +local deToken = core.ensure_token(comboDE) +local deYaml = core.generate_link(deToken, "ClashMeta") +check("combo link drops the deleted source node", + deYaml and deYaml:find("D-香港-01", 1, true) == nil) +check("combo link keeps the remaining source node", + deYaml and deYaml:find("E-日本-01", 1, true) ~= nil) + +-- 来源被删光:必须给出「请选择至少一个订阅」,而不是静默 0 节点 +core.remove(srcE) +local deMeta2 = core.get(comboDE) +check("combo with all sources deleted reports an error", + type(deMeta2.error) == "string" and deMeta2.error ~= "" and deMeta2.node_count == 0) +check("all-sources-deleted combo has empty sources", + type(deMeta2.sources) == "table" and #deMeta2.sources == 0) + +-- 删除一个只被某个组合引用的订阅:该组合被清空,其他组合不受影响 +local srcF = core.add("订阅F", "http://example.com/f") +core.write_nodes(srcF, { + { proto = "vmess", name = "F-01", server = "8.8.8.8", port = 443, uuid = "u8" }, +}) +local comboF = core.add_combo("组合F", { srcF }) +check("combo F node_count = 1", core.get(comboF).node_count == 1) +core.remove(srcF) +check("combo F emptied after its only source is deleted", core.get(comboF).node_count == 0) +-- c 的来源是 b(未被删除),不得被误伤 +check("unrelated combo untouched", core.get(c).node_count == 1) + -- 清理 os.execute("rm -rf " .. string.format("%q", tmp)) diff --git a/luci-app-substore/tests/data_integrity_test.lua b/luci-app-substore/tests/data_integrity_test.lua index ae437cf7d..7fd121b31 100644 --- a/luci-app-substore/tests/data_integrity_test.lua +++ b/luci-app-substore/tests/data_integrity_test.lua @@ -125,6 +125,45 @@ local t1 = core.ensure_token(id1) check("subscription token length", type(t1) == "string" and #t1 == 16) check("subscription token stable", core.ensure_token(id1) == t1) +-- ---------- 写盘失败时不得交出不存在的状态(M.remove / M.ensure_token) ---------- +-- 两处此前都丢弃了 save() 的返回值: +-- M.remove → 返回 true,且**已经删掉节点文件**:订阅在列表里还在, +-- 点进去却是空的,用户以为删成功了 +-- M.ensure_token → 返回一个只存在于内存里的 token,而 id_by_token 从磁盘读, +-- 用户拿到的是必然报「无效的订阅 token」的链接 +-- 用替身让 atomic_write 失败(比 chmod 更可移植:测试可能以 root 运行, +-- 权限位对 root 不起作用)。 +local real_atomic_write = util.atomic_write + +-- 先正常建一条带节点的订阅(此时 atomic_write 还是真的) +local fail_id = core.add("写盘失败用例", "http://example.com/fail") +check("setup: add works before failure injection", fail_id ~= nil) +local fail_nodes = core.write_nodes(fail_id, { { proto = "vmess", name = "n", server = "1.2.3.4", port = 443 } }) +check("setup: nodes written", fail_nodes == true) + +-- ensure_token:token 写不进去时返回 nil,而不是一个用不了的 token。 +-- add() 建订阅时就已经带了 token,先清掉才能走到「生成并持久化」那条路 +-- (M.CLEAR 是 save_meta 的清除哨兵,见 core.lua 的说明)。 +-- 这一步必须用**真实**的 atomic_write:失败替身只用于下面两条断言。 +check("setup: clear token", core.save_meta(id5, { token = core.CLEAR }) == true) + +util.atomic_write = function() return false, "No space left on device" end +local rok, rerr = core.remove(fail_id) +check("remove reports write failure", rok == false) +check("remove surfaces the write error", type(rerr) == "string" and rerr ~= "") +check("remove keeps nodes file on write failure", + #core.read_nodes(fail_id) == 1) + +local tok_fail, tok_err = core.ensure_token(id5) +check("ensure_token reports write failure", tok_fail == nil) +check("ensure_token surfaces the write error", type(tok_err) == "string" and tok_err ~= "") + +util.atomic_write = real_atomic_write +-- 恢复后仍能正常发 token(替身没有把模块状态弄坏) +local tok_ok = core.ensure_token(id5) +check("ensure_token works after recovery", type(tok_ok) == "string" and #tok_ok == 16) +check("ensure_token stable after recovery", core.ensure_token(id5) == tok_ok) + os.execute("rm -rf " .. string.format("%q", tmp)) -- ---------- 结果 ---------- diff --git a/luci-app-substore/tests/dns_fallback_test.lua b/luci-app-substore/tests/dns_fallback_test.lua new file mode 100644 index 000000000..09bf2f55f --- /dev/null +++ b/luci-app-substore/tests/dns_fallback_test.lua @@ -0,0 +1,338 @@ +-- dns_fallback_test.lua — [2.6.12-r1] DNS 解析回退与连接时对端校验回归测试 +-- 用法:lua5.1 tests/dns_fallback_test.lua +-- +-- 背景(用户实测的回归):[2.6.8-r1] 把 check_public 里「解析不出 IP」一律 +-- fail-closed 之后,在**缺少 luci-lib-nixio** 的设备上 resolve() 恒返回 nil, +-- 于是所有域名订阅都报「无法解析目标主机名」——而 curl 自带 libc 解析器, +-- 2.6.7-r1 上同样的订阅是能正常下载的。 +-- +-- 根因:resolve() 只有 nixio 一条路,「本机没有解析手段」和「这个域名解析不出来」 +-- 被合并成了同一个 nil,而两者的处置完全相反。 +-- +-- 覆盖: +-- A nslookup 回退:nixio 不可用时改用 busybox nslookup 解析 +-- B nslookup 输出的 Server/Address 段**不能**被当成解析结果 +-- C 有解析手段但解析失败(NXDOMAIN)→ 仍然 fail-closed +-- D 本机完全无解析手段 → 放行,但标记 unverified +-- E 连接时对端校验:curl 回报的 %{remote_ip} 为内网 → 拒绝并清理临时文件 +-- F unverified 且拿不到对端 IP → 拒绝(「无法校验」不等于「放行」) +-- G 走代理时不校验对端(%{remote_ip} 是代理地址,多半就在内网) +-- H unverified + wget 后端 → 拒绝,且不发起下载(wget 拿不到对端地址) +-- H2 对照:有解析能力时 wget 后端照常工作 +-- +-- 全部用例不触网:nixio / nslookup / curl 一律用替身。 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local util = require("substore.util") +local http = require("substore.http") + +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 real_popen, real_execute = io.popen, os.execute +local real_try = util.try_require +local real_rnd = util.rnd_hex + +local function restore() + io.popen, os.execute, util.try_require, util.rnd_hex = + real_popen, real_execute, real_try, real_rnd +end + +-- 让 resolve() 走 nslookup 分支:nixio 取不到、nslookup 命令存在、输出固定。 +local spawned +local function stub_nslookup(output) + spawned = {} + util.try_require = function() return nil end + os.execute = function() return 0 end -- have("nslookup") 恒真 + io.popen = function(cmd) + spawned[#spawned + 1] = cmd + return { read = function() return output end, close = function() end } + end +end + +-- ---------- A:nslookup 回退 ---------- +-- Server 段故意写 8.8.8.8(公网)、应答段写内网地址: +-- 若实现误把 Server 段的 Address 也当解析结果,就会因为「有一个公网 IP」 +-- 而放行 —— 这条断言因此能真正区分对错,不是恒真。 +local NS_MIXED = "Server:\t\t8.8.8.8\nAddress:\t8.8.8.8:53\n\n" .. + "Name:\tintranet.example\nAddress: 192.168.1.10\n" + +stub_nslookup(NS_MIXED) +local okA, rA = http.check_public("intranet.example") +restore() +check("A nslookup fallback used", #spawned == 1) +check("A nslookup command quotes host", spawned[1] == "nslookup 'intranet.example' 2>/dev/null") +check("A domain resolving to private rejected", okA == false) +check("A reason is private-address", rA == "目标仅解析到内网/保留地址") + +-- 应答段是公网地址 → 放行 +local NS_PUBLIC = "Server:\t\t192.168.1.1\nAddress:\t192.168.1.1:53\n\n" .. + "Name:\texample.com\nAddress: 93.184.216.34\n" +stub_nslookup(NS_PUBLIC) +local okA2 = http.check_public("example.com") +restore() +check("A public domain allowed via nslookup", okA2 == true) + +-- 多 A 记录:只要有一个公网地址就放行(与 nixio 分支同语义) +local NS_MULTI = "Server:\t\t192.168.1.1\nAddress:\t192.168.1.1:53\n\n" .. + "Name:\tmulti.example\nAddress: 10.0.0.5\nAddress: 93.184.216.34\n" +stub_nslookup(NS_MULTI) +local okA3 = http.check_public("multi.example") +restore() +check("A multi-A with one public allowed", okA3 == true) + +-- IPv6 应答:%zone 后缀必须去掉,否则 fe80::1%eth0 匹配不上 link-local 前缀 +local NS_V6 = "Server:\t\t192.168.1.1\nAddress:\t192.168.1.1:53\n\n" .. + "Name:\tv6.example\nAddress: fe80::1%eth0\n" +stub_nslookup(NS_V6) +local okA4, rA4 = http.check_public("v6.example") +restore() +check("A ipv6 zone suffix stripped", okA4 == false and rA4 == "目标仅解析到内网/保留地址") + +-- ---------- B:Server 段的 Address 不是解析结果 ---------- +-- 解析失败时 busybox 只把错误写到 stderr,stdout 里只有 Server/Address 段。 +-- 应答段为空 → 必须判为「解析不出来」,而不是把 8.8.8.8(DNS 服务器自己)当成结果。 +local NS_NXDOMAIN = "Server:\t\t8.8.8.8\nAddress:\t8.8.8.8:53\n\n" .. + "** server can't find nope.invalid: NXDOMAIN\n" +stub_nslookup(NS_NXDOMAIN) +local okB, rB = http.check_public("nope.invalid") +restore() +check("B server-block address not used as answer", okB == false) +check("B empty answer is unresolvable", rB == "无法解析目标主机名") + +-- 空输出(nslookup 不存在 / 立刻失败)同样算解析不出来 +stub_nslookup("") +local okB2, rB2 = http.check_public("nope.invalid") +restore() +check("B empty output is unresolvable", okB2 == false and rB2 == "无法解析目标主机名") + +-- ---------- C:有解析手段但解析失败 → fail-closed ---------- +-- 这是 [2.6.8-r1] 引入的行为,必须保留:127.0.0.1.nip.io 这类 +-- 「公网可解析到内网」的域名,只要本机解析不出来就能绕过全部检查, +-- 而 curl 自己仍会把它解析到 127.0.0.1 并连上去。 +stub_nslookup(NS_NXDOMAIN) +local okC, rC, unC = http.check_public("127.0.0.1.nip.io") +restore() +check("C unresolvable with resolver rejected", okC == false) +check("C unresolvable reason", rC == "无法解析目标主机名") +check("C not marked unverified", unC == nil) + +-- ---------- D:本机完全无解析手段 → 放行 + unverified ---------- +-- os.execute 恒返回 1 → command -v 一律「找不到」→ nixio 与 nslookup 都没有。 +util.try_require = function() return nil end +os.execute = function() return 1 end +spawned = {} +io.popen = function(cmd) spawned[#spawned + 1] = cmd; return nil end +local okD, rD, unD = http.check_public("example.com") +local spawnedD = #spawned +restore() +check("D no resolver allows", okD == true) +check("D reason explains fallback", rD == "本机无 DNS 解析能力,改由连接时校验对端地址") +check("D marked unverified", unD == true) +check("D spawns no resolver process", spawnedD == 0) + +-- 无解析手段也不能放过 IP 字面量与 localhost(这些分支不经过 DNS) +util.try_require = function() return nil end +os.execute = function() return 1 end +io.popen = function() return nil end +local dLit = http.check_public("127.0.0.1") +local dLit6 = http.check_public("::1") +local dName = http.check_public("localhost") +local dPub = http.check_public("1.1.1.1") +restore() +check("D loopback literal still rejected", dLit == false) +check("D ipv6 loopback still rejected", dLit6 == false) +check("D localhost name still rejected", dName == false) +check("D public literal still allowed", dPub == true) + +-- ---------- E/F/G:连接时对端校验 ---------- +-- fetch_curl 通过 -w 取 %{http_code} 与 %{remote_ip},据此复核真正连上的地址。 +-- 替身按命令行里 -o/-D/2> 的目标把文件建出来,让「失败后 /tmp 无残留」 +-- 成为真断言(文件确实被创建过)。 +util.rnd_hex = function() return "DNSTAG" end +local TMP = "/tmp/substore_dl_DNSTAG_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 + +-- curl 替身:code 是 -w 的完整输出(形如 "200 93.184.216.34") +local function spy_curl(code) + os.execute = function() return 0 end -- have("curl") 恒真 → 选 curl 后端 + io.popen = function(cmd) + local o = cmd:match("%-o '([^']+)'") + local d = cmd:match("%-D '([^']+)'") + local e = cmd:match("2>'([^']+)'") + if o then touch(o, "body") end + if d then touch(d, "HTTP/1.1 200 OK\r\n") end + if e then touch(e, "") end + return { read = function() return code end, close = function() end } + end +end + +local function clear_tmp() + os.remove(TMP .. ".tmp"); os.remove(HDR); os.remove(ERRF) +end + +-- E1:公网对端 → 正常返回 +clear_tmp() +spy_curl("200 93.184.216.34") +local bodyE, _, errE = http.download("http://1.1.1.1/sub") +restore() +check("E1 public peer accepted", bodyE == "body" and errE == nil) + +-- E2:对端是内网 → 拒绝,且临时文件全部清理 +clear_tmp() +spy_curl("200 127.0.0.1") +local bodyE2, _, errE2 = http.download("http://1.1.1.1/sub") +restore() +check("E2 private peer rejected", bodyE2 == nil) +check("E2 reason names the peer ip", errE2 == "目标实际连接到内网/保留地址 (127.0.0.1)") +check("E2 removes .tmp", not exists(TMP .. ".tmp")) +check("E2 removes .hdr", not exists(HDR)) +check("E2 removes .err", not exists(ERRF)) + +-- E3:3xx 的第一跳就连到内网 → 同样拒绝 +-- (Location 检查只能拦第二跳,拦不住第一跳本身) +clear_tmp() +spy_curl("302 192.168.1.1") +local bodyE3, _, errE3 = http.download("http://1.1.1.1/sub") +restore() +check("E3 redirect hop private rejected", bodyE3 == nil) +check("E3 reason names the peer ip", errE3 == "目标实际连接到内网/保留地址 (192.168.1.1)") + +-- E4:link-local 对端同样拦 +clear_tmp() +spy_curl("200 fe80::1") +local bodyE4, _, errE4 = http.download("http://1.1.1.1/sub") +restore() +check("E4 link-local peer rejected", bodyE4 == nil and errE4 ~= nil) + +-- E5:对照 —— 替身确实落盘,所以上面的「无残留」不是空断言 +clear_tmp() +touch(TMP .. ".tmp", "x") +check("E5 control: temp files are observable", exists(TMP .. ".tmp")) +clear_tmp() + +-- F:预检无解析能力(unverified)且拿不到对端 IP → 拒绝。 +-- 「无法校验」必须等于「拒绝」,否则这条新的放行路径就是个洞。 +util.rnd_hex = function() return "DNSTAG" end +util.try_require = function() return nil end +spy_curl("200") -- 只有状态码,没有对端 IP +os.execute = function(cmd) + -- have("curl") 为真、have("nslookup") 为假 → 无解析能力 + 用 curl 下载 + if cmd:find("curl", 1, true) then return 0 end + return 1 +end +clear_tmp() +-- 这里必须用**域名**:只有走 resolve() 分支才会得到 unverified=true。 +local bodyF, _, errF = http.download("http://example.com/sub") +restore() +check("F unverified without peer ip rejected", bodyF == nil) +check("F reason explains missing peer ip", + errF == "无法校验目标地址(本机无 DNS 解析能力,且未取得对端 IP)") + +-- F2:有解析能力时拿不到对端 IP → 不额外拒绝(预检已经查过一轮) +clear_tmp() +spy_curl("200") +local bodyF2, _, errF2 = http.download("http://1.1.1.1/sub") +restore() +check("F2 resolved target without peer ip accepted", bodyF2 == "body" and errF2 == nil) + +-- G:走代理时不校验对端(%{remote_ip} 是代理地址,多半就在内网) +clear_tmp() +spy_curl("200 192.168.1.1") +local bodyG, _, errG = http.download("http://1.1.1.1/sub", { proxy = "http://192.168.1.1:1080" }) +restore() +check("G proxied private peer accepted", bodyG == "body" and errG == nil) + +-- G2:代理参数确实进了 curl 命令行(否则 G 可能是假阳性) +local saw_proxy +os.execute = function() return 0 end +io.popen = function(cmd) + saw_proxy = cmd:find("%-x 'http://192.168.1.1:1080'", 1) ~= nil + local o = cmd:match("%-o '([^']+)'") + local d = cmd:match("%-D '([^']+)'") + local e = cmd:match("2>'([^']+)'") + if o then touch(o, "body") end + if d then touch(d, "HTTP/1.1 200 OK\r\n") end + if e then touch(e, "") end + return { read = function() return "200 192.168.1.1" end, close = function() end } +end +clear_tmp() +http.download("http://1.1.1.1/sub", { proxy = "http://192.168.1.1:1080" }) +restore() +check("G2 proxy flag passed to curl", saw_proxy == true) + +-- ---------- H:unverified + wget 后端 → 拒绝,且不发起下载 ---------- +-- curl 后端靠 %{remote_ip} 在连接后复核对端地址;wget 拿不到对端地址, +-- busybox wget 也没有可用的重定向拦截。预检 fail-open 放行之后这条路径上 +-- 再无任何校验 —— 与 verify_peer_ip 同处置:校验不了就拒绝,而不是放行。 +-- 拒绝还必须发生在 wget **之前**:否则请求已经发出去,拒绝就只是事后补救。 +local WGET_TMP = "/tmp/substore_dl_wget_DNSTAGW.tmp" +local execsH = {} +util.rnd_hex = function() return "DNSTAGW" end +util.try_require = function() return nil end +os.execute = function(cmd) + execsH[#execsH + 1] = cmd + if cmd:find("command %-v wget") then return 0 end -- 有 wget + if cmd:find("command %-v") then return 1 end -- 没有 curl / nslookup → 无解析能力 + -- 真正的下载命令:**照常落盘**,模拟一个成功的下载。 + -- 不落盘的话,旧实现会因为「内容为空」而返回 nil, + -- 断言就在错误的理由上通过,测不出「本该拒绝却把内容交付了」。 + local o = cmd:match("%-O '([^']+)'") + if o then touch(o, "evilbody") end + return 0 +end +io.popen = function() return nil end +os.remove(WGET_TMP) +local bodyH, _, errH = http.download("http://example.com/sub") +restore() +check("H unverified wget rejected", bodyH == nil) +check("H reason names the wget backend", + errH == "本机无 DNS 解析能力,wget 后端无法校验目标地址,已拒绝下载(安装 curl 后重试)") +local ran_wget = false +for _, c in ipairs(execsH) do + if c:find("%-O ", 1) then ran_wget = true end +end +check("H wget never invoked", ran_wget == false) +check("H no temp file left", not exists(WGET_TMP)) + +-- H2:对照 —— 有解析能力(unverified 为 nil)时 wget 后端照常下载, +-- 证明 H 拒绝的是「无法校验」,不是把 wget 后端整个废掉。 +local WGET_TMP2 = "/tmp/substore_dl_wget_DNSTAGW2.tmp" +local ran_wget2 = false +util.rnd_hex = function() return "DNSTAGW2" end +util.try_require = function() return nil end +os.execute = function(cmd) + if cmd:find("command %-v curl") then return 1 end -- 没有 curl + if cmd:find("command %-v") then return 0 end -- 有 wget / nslookup + local o = cmd:match("%-O '([^']+)'") + if o then ran_wget2 = true; touch(o, "wgetbody") end + return 0 +end +io.popen = function() return { read = function() return NS_PUBLIC end, close = function() end } end +os.remove(WGET_TMP2) +local bodyH2, _, errH2 = http.download("http://example.com/sub") +restore() +check("H2 resolved target via wget accepted", bodyH2 == "wgetbody" and errH2 == nil) +check("H2 wget really ran", ran_wget2 == true) +check("H2 no temp file left", not exists(WGET_TMP2)) + +clear_tmp() +print("") +print(passed .. " passed, " .. failed .. " failed") +os.exit(failed == 0 and 0 or 1) diff --git a/luci-app-substore/tests/list_lock_test.lua b/luci-app-substore/tests/list_lock_test.lua new file mode 100644 index 000000000..45c0dc225 --- /dev/null +++ b/luci-app-substore/tests/list_lock_test.lua @@ -0,0 +1,173 @@ +-- list_lock_test.lua — 订阅列表互斥锁回归测试 +-- 用法:lua5.1 tests/list_lock_test.lua +-- +-- 缺陷(docs/LEGACY_ISSUES.md 1.1):`load()` 读出整表、`save()` 整表写回, +-- 中间无锁。LuCI 页面保存订阅、cron 定时更新、组合订阅自动重算三者可能同时 +-- 发生,后写者整表覆盖先写者 —— 订阅静默丢失,没有任何报错。 +-- +-- 本机没有 nixio(用不了 flock),Lua 5.1 的 io.open 也没有 O_EXCL, +-- 因此用 `mkdir` 做锁(成功者唯一),并用锁目录自身的 mtime 判定陈旧锁 —— +-- mtime 由 mkdir 原子设好,不存在「目录已建、时间戳还没写」的窗口。 +-- +-- 反向对照:改动前 util 里没有任何锁原语,core 也不认 .lock 目录, +-- 下面「第二个 acquire 返回 false」「锁被占用时 add 拒绝写入」等会失败。 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local util = require("substore.util") +local core = require("substore.core") + +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 + +-- 改动前这些符号不存在。用 nil 兜底而不是直接调用:否则脚本会在第一次 +-- nil 调用处中断,后面的断言全看不到,反向验证就只剩半份报告。 +local lock_acquire = util.lock_acquire or function() return nil end +local lock_release = util.lock_release or function() end + +local tmp = "/tmp/substore_lock_test" +local q = string.format("%q", tmp) +os.execute("rm -rf " .. q) +os.execute("mkdir -p " .. q .. "/nodes") + +core.DATA_DIR = tmp +core.LIST_FILE = tmp .. "/subscriptions.json" +core.NODES_DIR = tmp .. "/nodes" +local LOCK_DIR = tmp .. "/.lock" + +local function exists(p) + local f = io.open(p, "rb") + if f then f:close() return true end + return false +end + +-- ---------- 第一部分:util 的锁原语 ---------- +check("lock_acquire exists", type(util.lock_acquire) == "function") +check("lock_release exists", type(util.lock_release) == "function") +check("LOCK_STALE is a positive number", + type(util.LOCK_STALE) == "number" and util.LOCK_STALE > 0) + +os.execute("rm -rf " .. string.format("%q", tmp .. "/p1")) +local p1 = tmp .. "/p1" + +check("first acquire succeeds", lock_acquire(p1) == true) +check("lock dir created", exists(p1)) +-- 持有者信息是排障用的(谁、什么时候拿的) +local owner = util.read_file(p1 .. "/owner") +check("owner file written", type(owner) == "string") +check("owner file has a timestamp", + type(owner) == "string" and tonumber(owner:match("(%d+)%s*$")) ~= nil) + +-- 第二个持有者必须被挡住(这是锁的全部意义) +check("second acquire refused", lock_acquire(p1) == false) +check("second acquire did not clobber owner", + util.read_file(p1 .. "/owner") == owner) + +lock_release(p1) +check("release removes the lock dir", not exists(p1)) +check("acquire after release succeeds", lock_acquire(p1) == true) +lock_release(p1) + +-- 陈旧锁回收:持有者被 kill 时目录会留下,不回收就永久死锁 +-- (比丢数据更糟:订阅从此再也改不了)。把 mtime 拨回 2001 年模拟。 +lock_acquire(p1) +os.execute("touch -d '@1000000000' " .. string.format("%q", p1)) +check("stale lock reclaimed", lock_acquire(p1) == true) +lock_release(p1) +check("stale reclaim left no extra dir", not exists(p1)) + +-- 未陈旧的锁不得被回收 +lock_acquire(p1) +check("fresh lock not reclaimed", lock_acquire(p1) == false) +lock_release(p1) + +-- ---------- 第二部分:core 的写入口被锁串行化 ---------- +-- 手工造一个「别人正持有」的锁 +os.execute("rm -rf " .. string.format("%q", LOCK_DIR)) +os.execute("mkdir -p " .. string.format("%q", LOCK_DIR)) + +local blocked_id, blocked_err = core.add("A", "https://example.com/a") +check("add refused while lock held", blocked_id == nil) +check("refusal names the conflict", + type(blocked_err) == "string" and blocked_err:find("另一个进程", 1, true) ~= nil) +-- 关键:被挡住时**不得**落盘。改动前这里会写成功。 +check("refused add did not write the list", not exists(core.LIST_FILE)) +check("refused add left no subscription", #core.list() == 0) +-- 别人的锁也不能被顺手删掉 +check("refused add left the foreign lock alone", exists(LOCK_DIR)) + +os.execute("rm -rf " .. string.format("%q", LOCK_DIR)) + +-- 锁释放后一切照常 +local id, err = core.add("A", "https://example.com/a") +check("add succeeds once unlocked", type(id) == "string" and err == nil) +check("subscription persisted", #core.list() == 1) +check("lock released after add", not exists(LOCK_DIR)) + +-- ---------- 释放必须覆盖每一条返回路径 ---------- +-- 包一层的好处就在这里:函数中途 return 也不会漏掉释放。 +-- 漏释放的症状是「过一会儿自己好了」(陈旧回收),最难查。 + +-- (a) 参数校验失败提前 return +local _, e1 = core.add("", "https://example.com/x") +check("empty name rejected", type(e1) == "string") +check("lock released after validation failure", not exists(LOCK_DIR)) + +-- (b) 列表文件损坏时提前 return +util.atomic_write(core.LIST_FILE, "{ this is not json") +local _, e2 = core.add("B", "https://example.com/b") +check("corrupt list rejected", type(e2) == "string" and e2:find("损坏", 1, true) ~= nil) +check("lock released after corruption error", not exists(LOCK_DIR)) +-- 损坏的文件不得被覆盖 +check("corrupt list left untouched", + (util.read_file(core.LIST_FILE) or ""):find("not json", 1, true) ~= nil) + +-- 恢复一份干净的列表 +util.atomic_write(core.LIST_FILE, util.json_encode({ _seq = 1, items = { + [id] = { name = "A", url = "https://example.com/a", enabled = true }, +} })) + +-- (c) 正常路径 +local ok3 = core.save_meta(id, { name = "A2" }) +check("save_meta succeeds", ok3 == true) +check("lock released after save_meta", not exists(LOCK_DIR)) +check("save_meta actually applied", core.get(id).name == "A2") + +-- (d) remove +check("remove succeeds", core.remove(id) == true) +check("lock released after remove", not exists(LOCK_DIR)) +check("list empty after remove", #core.list() == 0) + +-- ---------- 可重入:save_combo 内部会调 save_meta ---------- +-- 不可重入的话第二次取锁会把自己挡在门外,组合订阅永远保存不了。 +-- 这里直接验证嵌套调用链能跑通,且过程中不会留下锁。 +local a1 = core.add("src1", "https://example.com/1") +local a2 = core.add("src2", "https://example.com/2") +check("two sources added", type(a1) == "string" and type(a2) == "string") +local cid, cerr = core.add_combo("combo", { a1, a2 }) +check("add_combo succeeds (nested save_meta)", type(cid) == "string" and cerr == nil) +check("lock released after add_combo", not exists(LOCK_DIR)) +local cok, cerr2 = core.save_combo(cid, "combo2", { a1 }) +check("save_combo succeeds (nested save_meta)", cok ~= nil and cerr2 == nil) +check("lock released after save_combo", not exists(LOCK_DIR)) + +-- 嵌套期间外层不得被内层提前释放:内层返回后外层仍持有, +-- 所以此时从「另一个进程」的视角看锁应当还在。 +-- 用一个能观察到中间状态的钩子来验证。 +local saw_lock_inside = false +local real_save_meta = core.save_meta +core.save_meta = function(...) + if exists(LOCK_DIR) then saw_lock_inside = true end + return real_save_meta(...) +end +core.save_combo(cid, "combo3", { a1 }) +core.save_meta = real_save_meta +check("outer lock still held while inner runs", saw_lock_inside) + +os.execute("rm -rf " .. q) + +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/node_converter_test.lua b/luci-app-substore/tests/node_converter_test.lua deleted file mode 100644 index dbd7cf5a1..000000000 --- a/luci-app-substore/tests/node_converter_test.lua +++ /dev/null @@ -1,177 +0,0 @@ --- node_converter_test.lua — 协议转换单元测试 --- 用法:lua5.1 tests/node_converter_test.lua - -package.path = "./root/usr/share/?.lua;" .. package.path - -local converter = require("substore.node_converter") - -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 function eq(a, b) - if type(a) ~= type(b) then return false end - if type(a) == "table" then - for k, v in pairs(a) do if not eq(v, b[k]) then return false end end - for k in pairs(b) do if a[k] == nil then return false end end - return true - end - return a == b -end - --- ---------- 基本功能测试 ---------- -check("module exists", converter ~= nil) -check("convert method exists", type(converter.convert) == "function") -check("get_field_mapping method exists", type(converter.get_field_mapping) == "function") - --- ---------- vmess 转换测试 ---------- -local vmess_node = { - proto = "vmess", - server = "1.1.1.1", - port = 443, - uuid = "test-uuid-vmess", - alterId = 0, - cipher = "auto", - network = "ws", -} - -local vless_converted = converter.convert(vmess_node, "vless") -check("vmess to vless proto", vless_converted and vless_converted.proto == "vless") -check("vmess to vless uuid", vless_converted and vless_converted.uuid == "test-uuid-vmess") -check("vmess to vless server", vless_converted and vless_converted.server == "1.1.1.1") -check("vmess to vless port", vless_converted and vless_converted.port == 443) - -local trojan_converted = converter.convert(vmess_node, "trojan") -check("vmess to trojan proto", trojan_converted and trojan_converted.proto == "trojan") -check("vmess to trojan password", trojan_converted and trojan_converted.password == "test-uuid-vmess") -check("vmess to trojan server", trojan_converted and trojan_converted.server == "1.1.1.1") - -local ss_converted = converter.convert(vmess_node, "shadowsocks") -check("vmess to ss proto", ss_converted and ss_converted.proto == "shadowsocks") -check("vmess to ss password", ss_converted and ss_converted.password == "test-uuid-vmess") - --- ---------- vless 转换测试 ---------- -local vless_node = { - proto = "vless", - server = "2.2.2.2", - port = 8443, - uuid = "test-uuid-vless", - security = "tls", -} - -local vmess_from_vless = converter.convert(vless_node, "vmess") -check("vless to vmess proto", vmess_from_vless and vmess_from_vless.proto == "vmess") -check("vless to vmess uuid", vmess_from_vless and vmess_from_vless.uuid == "test-uuid-vless") - -local trojan_from_vless = converter.convert(vless_node, "trojan") -check("vless to trojan proto", trojan_from_vless and trojan_from_vless.proto == "trojan") -check("vless to trojan password", trojan_from_vless and trojan_from_vless.password == "test-uuid-vless") - --- ---------- trojan 转换测试 ---------- -local trojan_node = { - proto = "trojan", - server = "3.3.3.3", - port = 443, - password = "trojan-pass", - sni = "example.com", -} - -local vmess_from_trojan = converter.convert(trojan_node, "vmess") -check("trojan to vmess proto", vmess_from_trojan and vmess_from_trojan.proto == "vmess") -check("trojan to vmess uuid", vmess_from_trojan and vmess_from_trojan.uuid == "trojan-pass") - -local vless_from_trojan = converter.convert(trojan_node, "vless") -check("trojan to vless proto", vless_from_trojan and vless_from_trojan.proto == "vless") -check("trojan to vless uuid", vless_from_trojan and vless_from_trojan.uuid == "trojan-pass") - --- ---------- shadowsocks 转换测试 ---------- -local ss_node = { - proto = "shadowsocks", - server = "4.4.4.4", - port = 8388, - method = "aes-256-gcm", - password = "ss-pass", -} - -local vmess_from_ss = converter.convert(ss_node, "vmess") -check("ss to vmess proto", vmess_from_ss and vmess_from_ss.proto == "vmess") -check("ss to vmess uuid", vmess_from_ss and vmess_from_ss.uuid == "ss-pass") - -local vless_from_ss = converter.convert(ss_node, "vless") -check("ss to vless proto", vless_from_ss and vless_from_ss.proto == "vless") -check("ss to vless uuid", vless_from_ss and vless_from_ss.uuid == "ss-pass") - -local trojan_from_ss = converter.convert(ss_node, "trojan") -check("ss to trojan proto", trojan_from_ss and trojan_from_ss.proto == "trojan") -check("ss to trojan password", trojan_from_ss and trojan_from_ss.password == "ss-pass") - --- ---------- 字段映射测试 ---------- -local mapping = converter.get_field_mapping("vmess", "vless") -check("vmess to vless mapping exists", mapping ~= nil) -check("vmess to vless has uuid", mapping and mapping.uuid == "uuid") -check("vmess to vless has server", mapping and mapping.server == "server") - -local mapping2 = converter.get_field_mapping("vless", "trojan") -check("vless to trojan mapping exists", mapping2 ~= nil) -check("vless to trojan uuid to password", mapping2 and mapping2.uuid == "password") - --- ---------- 不支持的转换 ---------- -local result = converter.convert(vmess_node, "unsupported_proto") -check("unsupported target returns nil", result == nil) - --- ---------- 字段兼容性测试 ---------- -local vmess_with_net = { - proto = "vmess", - server = "5.5.5.5", - port = 443, - uuid = "uuid-1", - network = "tcp", - security = "tls", -} -local vless_with_net = converter.convert(vmess_with_net, "vless") -check("network preserved", vless_with_net and vless_with_net.network == "tcp") -check("security preserved", vless_with_net and vless_with_net.security == "tls") - --- ---------- hysteria2 测试 ---------- -local hysteria_node = { - proto = "hysteria2", - server = "6.6.6.6", - port = 443, - password = "hysteria-pass", -} -local singbox_from_hysteria = converter.convert(hysteria_node, "sing-box") -check("hysteria2 to sing-box proto", singbox_from_hysteria and singbox_from_hysteria.proto == "sing-box") - --- ---------- tuic 测试 ---------- -local tuic_node = { - proto = "tuic", - server = "7.7.7.7", - port = 443, - uuid = "tuic-uuid", - password = "tuic-pass", -} -local singbox_from_tuic = converter.convert(tuic_node, "sing-box") -check("tuic to sing-box proto", singbox_from_tuic and singbox_from_tuic.proto == "sing-box") - --- ---------- wireguard 测试 ---------- -local wg_node = { - proto = "wireguard", - server = "8.8.8.8", - port = 51820, - private_key = "wg-private", - public_key = "wg-public", -} -local singbox_from_wg = converter.convert(wg_node, "sing-box") -check("wireguard to sing-box proto", singbox_from_wg and singbox_from_wg.proto == "sing-box") - --- ---------- 结果 ---------- -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/output_formats_test.lua b/luci-app-substore/tests/output_formats_test.lua index eada2a676..664c45926 100644 --- a/luci-app-substore/tests/output_formats_test.lua +++ b/luci-app-substore/tests/output_formats_test.lua @@ -108,7 +108,12 @@ local ok_alpn, line_alpn = pcall(fmts.surge_line, { proto = "tuic", name = "T", port = 443, uuid = "u", password = "p", security = "tls", sni = "s.example.com", alpn = { "h3", "h2" } }) check("tuic alpn array does not raise", ok_alpn) -check("tuic alpn array joined", ok_alpn and line_alpn:find("alpn=h3,h2", 1, true) ~= nil) +-- 多值 alpn 在 Surge 家族的行语法里表达不了(逗号分隔的 key=value,无转义), +-- 拼成 `alpn=h3,h2` 会被读成 `alpn=h3` 加一个悬空字段。该参数被省略、 +-- 节点本身保留 —— alpn 只是协商提示,缺省时客户端用服务端给出的列表。 +check("tuic alpn array omitted, node kept", + ok_alpn and line_alpn ~= nil and line_alpn:find("alpn=", 1, true) == nil + and line_alpn:find("tuic, 1.2.3.4, 443", 1, true) ~= nil) check("tuic alpn string still works", fmts.surge_line({ proto = "tuic", name = "T", server = "1.2.3.4", port = 443, uuid = "u", alpn = "h3" }):find("alpn=h3", 1, true) ~= nil) diff --git a/luci-app-substore/tests/output_layer_fixes_test.lua b/luci-app-substore/tests/output_layer_fixes_test.lua new file mode 100644 index 000000000..987e9be11 --- /dev/null +++ b/luci-app-substore/tests/output_layer_fixes_test.lua @@ -0,0 +1,256 @@ +-- output_layer_fixes_test.lua — 输出层代码级审计发现的 10 项缺陷回归测试 +-- 用法:lua5.1 tests/output_layer_fixes_test.lua +-- +-- 每一项都对应一个「生成的配置在目标客户端上会被拒绝 / 静默跑错」的缺陷: +-- F1 clashmeta ws 节点同时有 path 与 host 时输出两个并列的 ws-opts 键 +-- F2 v2ray ws/grpc/http 传输层无参数时输出 [](Xray 解进结构体指针报错) +-- F3 clashmeta 节点名与组名 / 保留名冲突时产生重复 name,mihomo 拒绝加载 +-- F4 clashmeta esc_yaml 未引用的标量以 % / ! / - 开头时是非法 YAML +-- F5 QX vless 节点丢掉 obfs / tls 参数 +-- F6 surge vless + ws 节点丢掉整个传输层 +-- F7 surge/QX 参数值里的逗号把凭据静默截断 +-- F8 uri IPv6 字面量地址未加方括号 +-- F9 clashmeta amnezia-wg-option 子键未转义 +-- F10 output content_type_for / extension_for 对空 target 落空 +-- +-- 每一项都先用 git stash 之外的 HEAD 版本反向验证过:断言在修复前必须失败。 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local node = require("substore.node") +local util = require("substore.util") +local clash_meta = require("substore.output_clash_meta") +local v2ray = require("substore.output_v2ray") +local formats = require("substore.output_formats") +local uri = require("substore.output_uri") +local output = require("substore.output") + +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 + +-- ---------- F1:ws-opts 只能出现一次 ---------- +-- mihomo 的 ws-opts 是单个映射;同一层级出现两个同名键时 YAML 解析器 +-- 报 duplicate key(或后者覆盖前者),path 与 headers 必定丢一个。 +local ws_node = node.normalize({ proto = "vmess", name = "WS", server = "1.2.3.4", + port = 443, uuid = "11111111-2222-3333-4444-555555555555", net = "ws", + path = "/ray", host = "ws.example.com" }) +local ws_yaml = clash_meta.generate({ ws_node }) +local ws_opts_count = 0 +for _ in ws_yaml:gmatch("ws%-opts:") do ws_opts_count = ws_opts_count + 1 end +check("F1 ws-opts emitted exactly once", ws_opts_count == 1) +check("F1 ws path present", ws_yaml:find("path: /ray", 1, true) ~= nil) +check("F1 ws host present", ws_yaml:find("Host: ws.example.com", 1, true) ~= nil) +-- headers 必须嵌在 ws-opts 之下(缩进比 ws-opts 深) +local ws_opts_line = line_with(ws_yaml, "ws%-opts:") +local headers_line = line_with(ws_yaml, "headers:") +check("F1 headers nested under ws-opts", + ws_opts_line ~= nil and headers_line ~= nil + and #headers_line:match("^(%s*)") > #ws_opts_line:match("^(%s*)")) + +-- 只有 host、没有 path 的 ws 节点:ws-opts 是 path 与 headers 的共同父键, +-- 原来 `ws-opts:` 那一行写在 `if node.path` 里面,于是这种节点输出一个 +-- 悬空的 `headers:` —— 既不在任何 ws-opts 之下,也不是合法的 vmess 键, +-- mihomo 要么报未知字段要么直接忽略 Host,ws 握手被服务端拒绝。 +local ws_host_only = node.normalize({ proto = "vmess", name = "WSH", server = "1.2.3.4", + port = 443, uuid = "11111111-2222-3333-4444-555555555555", net = "ws", + host = "ws.example.com" }) +local wsh_yaml = clash_meta.generate({ ws_host_only }) +check("F1 host-only ws emits ws-opts", + line_with(wsh_yaml, "ws%-opts:") ~= nil) +local wsh_opts = line_with(wsh_yaml, "ws%-opts:") +local wsh_headers = line_with(wsh_yaml, "headers:") +check("F1 host-only headers nested under ws-opts", + wsh_opts ~= nil and wsh_headers ~= nil + and #wsh_headers:match("^(%s*)") > #wsh_opts:match("^(%s*)")) +check("F1 host-only Host preserved", + wsh_yaml:find("Host: ws.example.com", 1, true) ~= nil) + +-- ---------- F2:空传输层必须是 {} 而不是 [] ---------- +-- Xray 用标准库 json.Unmarshal 解析配置,wsSettings 等是结构体指针, +-- 把 JSON 数组解进结构体会直接 UnmarshalTypeError,Xray 拒绝启动。 +local grpc_node = node.normalize({ proto = "vless", name = "G", server = "5.6.7.8", + port = 443, uuid = "u", net = "grpc" }) +local gcfg = v2ray.generate({ grpc_node }) +check("F2 grpc empty settings is object", gcfg:find('"grpcSettings":{}', 1, true) ~= nil) +check("F2 grpc empty settings is not array", gcfg:find('"grpcSettings":[]', 1, true) == nil) + +local ws_empty = node.normalize({ proto = "vless", name = "W", server = "5.6.7.9", + port = 443, uuid = "u", net = "ws" }) +check("F2 ws empty settings is object", + v2ray.generate({ ws_empty }):find('"wsSettings":{}', 1, true) ~= nil) + +local http_empty = node.normalize({ proto = "vless", name = "H", server = "5.6.7.10", + port = 443, uuid = "u", net = "http" }) +check("F2 http empty settings is object", + v2ray.generate({ http_empty }):find('"httpSettings":{}', 1, true) ~= nil) + +-- 解码出的空对象一旦被写入键,必须照常编码出这些键(不能因为是 +-- JSON_EMPTY_OBJECT 的同款元表就一律输出 {}) +local decoded = util.json_decode('{"a":{}}') +decoded.a.b = 1 +check("F2 decoded empty object still encodable", + util.json_encode(decoded):find('"a":{"b":1}', 1, true) ~= nil) + +-- ---------- F3:节点名冲突必须消解 ---------- +-- mihomo 的 proxies 里 name 是主键,重名直接拒绝加载整份配置。 +local d1 = node.normalize({ proto = "trojan", name = "HK01", server = "1.1.1.1", + port = 443, password = "a" }) +local d2 = node.normalize({ proto = "trojan", name = "HK01", server = "2.2.2.2", + port = 443, password = "b" }) +local dup = {} +for l in clash_meta.generate({ d1, d2 }):gmatch(" %- name: ([^\n]+)") do + dup[#dup + 1] = l +end +check("F3 duplicate node names uniquified", dup[1] ~= dup[2]) +check("F3 first name preserved", dup[1] == "HK01") + +-- 节点名撞上生成的策略组名:组名是 proxies 之外的顶层键,同名同样冲突 +local p = node.normalize({ proto = "trojan", name = "Proxy", server = "3.3.3.3", + port = 443, password = "c" }) +local collide = clash_meta.generate({ p }) +-- 节点被改名(`#` 使 YAML 需要引号),策略组本身仍叫 Proxy +check("F3 node named like group renamed", + collide:find(' - name: "Proxy #2"', 1, true) ~= nil) +-- 只看 proxies 段:策略组本身仍叫 Proxy,不能被这条断言误伤 +local proxies_section = collide:match("^proxies:\n(.-)\nproxy%-groups:") or "" +check("F3 no proxy left named Proxy", + proxies_section:find(" - name: Proxy\n", 1, true) == nil) +check("F3 group keeps its name", + collide:find("proxy-groups:\n - name: Proxy\n", 1, true) ~= nil) + +-- ---------- F4:未加引号的 YAML 标量必须合法 ---------- +-- `%` 在 YAML 里是指令前缀,`!` 是标签前缀,行首 `-` 是块序列项, +-- 这三种开头的裸标量都是非法 YAML,mihomo 解析直接失败。 +check("F4 percent-leading value quoted", + clash_meta.generate({ node.normalize({ proto = "trojan", name = "P", + server = "1.1.1.1", port = 443, password = "%40abc" }) }) + :find('password: "%40abc"', 1, true) ~= nil) +check("F4 bang-leading value quoted", + clash_meta.generate({ node.normalize({ proto = "trojan", name = "B", + server = "1.1.1.1", port = 443, password = "!secret" }) }) + :find('password: "!secret"', 1, true) ~= nil) +check("F4 dash-leading value quoted", + clash_meta.generate({ node.normalize({ proto = "trojan", name = "D", + server = "1.1.1.1", port = 443, password = "-abc" }) }) + :find('password: "-abc"', 1, true) ~= nil) +-- 普通值不应被无谓地加引号(否则所有既有输出都变样) +check("F4 plain value not quoted", + clash_meta.generate({ node.normalize({ proto = "trojan", name = "N", + server = "1.1.1.1", port = 443, password = "plainpass" }) }) + :find("password: plainpass", 1, true) ~= nil) + +-- ---------- F5 / F6:vless 的 ws 传输层 ---------- +-- QX 与 Surge 家族的 vless 与 vmess 共用同一套传输参数名。此前只有 vmess +-- 分支写了,vless + ws 导出成明文 tcp 条目,客户端按 tcp 去连只开了 ws 的 +-- 端口,必然失败且不报错。 +local vless_ws = node.normalize({ proto = "vless", name = "VLWS", server = "1.2.3.4", + port = 443, uuid = "u", net = "ws", path = "/p", host = "h.com", security = "tls" }) + +local qx = formats.to_qx({ vless_ws }) +local qx_vless = line_with(qx, "vless=") +check("F5 qx vless has obfs=ws", qx_vless ~= nil and qx_vless:find("obfs=ws", 1, true) ~= nil) +check("F5 qx vless has obfs-uri", qx_vless ~= nil and qx_vless:find("obfs-uri=/p", 1, true) ~= nil) +check("F5 qx vless has obfs-host", qx_vless ~= nil and qx_vless:find("obfs-host=h.com", 1, true) ~= nil) +check("F5 qx vless has tls-verification", + qx_vless ~= nil and qx_vless:find("tls-verification=true", 1, true) ~= nil) + +local surge = formats.to_surge({ vless_ws }) +local surge_vless = line_with(surge, "VLWS = vless") +check("F6 surge vless has ws=true", + surge_vless ~= nil and surge_vless:find("ws=true", 1, true) ~= nil) +check("F6 surge vless has ws-path", + surge_vless ~= nil and surge_vless:find("ws-path=/p", 1, true) ~= nil) +check("F6 surge vless has ws-headers", + surge_vless ~= nil and surge_vless:find("ws-headers=Host:h.com", 1, true) ~= nil) + +-- ---------- F7:值里的逗号不能静默截断 ---------- +-- `password=pa,ss` 在逗号分隔语法里会被读成 `password=pa` 加一个悬空字段。 +local comma_pw = node.normalize({ proto = "trojan", name = "CP", server = "1.2.3.4", + port = 443, password = "pa,ss" }) +local cp_line = formats.surge_line(comma_pw) +check("F7 surge drops comma-valued node", cp_line == nil) +-- 节点被丢弃后,成员列表也不得再引用它(否则是悬空引用) +local cp_cfg = formats.to_surge({ comma_pw }) +check("F7 surge select excludes dropped node", + line_with(cp_cfg, "= select") == "PROXY = select, DIRECT") + +local qx_cp = formats.to_qx({ comma_pw }) +check("F7 qx drops comma-valued node", qx_cp:find("trojan=", 1, true) == nil) +check("F7 qx policy excludes dropped node", + line_with(qx_cp, "static=") == "static=PROXY, DIRECT") + +-- 合法的多字段行不得被误判(逗号是结构分隔符,不是值的一部分) +local ok_node = node.normalize({ proto = "trojan", name = "OK", server = "5.6.7.8", + port = 443, password = "plain", sni = "s.example.com" }) +local ok_line = formats.surge_line(ok_node) +check("F7 legal multi-field line kept", ok_line ~= nil) +check("F7 legal line has all fields", + ok_line:find("password=plain", 1, true) ~= nil + and ok_line:find("sni=s.example.com", 1, true) ~= nil) +local ok_qx = formats.to_qx({ ok_node }) +check("F7 legal qx line kept", ok_qx:find("trojan=5.6.7.8:443", 1, true) ~= nil) +check("F7 legal qx keeps sni", + line_with(ok_qx, "trojan="):find("tls-host=s.example.com", 1, true) ~= nil) + +-- ---------- F8:IPv6 字面量地址 ---------- +-- RFC 3986 的 authority 里 IPv6 必须写成 [addr],否则 `::` 与端口分隔符 +-- 无法区分,客户端把地址解析成垃圾。 +local v6 = node.normalize({ proto = "trojan", name = "v6", server = "fd00::1", + port = 443, password = "p", security = "tls" }) +check("F8 ipv6 bracketed in uri", + uri.to_share_uri(v6):find("@[fd00::1]:443", 1, true) ~= nil) +-- 已经是方括号形态的不得重复加括号 +local v6b = node.normalize({ proto = "trojan", name = "v6b", server = "[fd00::1]", + port = 443, password = "p", security = "tls" }) +check("F8 bracketed ipv6 not double-wrapped", + uri.to_share_uri(v6b):find("@[fd00::1]:443", 1, true) ~= nil + and uri.to_share_uri(v6b):find("[[", 1, true) == nil) +-- IPv4 / 域名不受影响 +check("F8 ipv4 unchanged", + uri.to_share_uri(node.normalize({ proto = "trojan", name = "v4", server = "1.2.3.4", + port = 443, password = "p", security = "tls" })):find("@1.2.3.4:443", 1, true) ~= nil) + +-- ---------- F9:amnezia-wg-option 子键转义 ---------- +-- 子键来自导入的 YAML/JSON(不可信),键名里的引号 / 换行会截断映射。 +local awg = node.normalize({ proto = "wireguard", name = "AWG", server = "1.2.3.4", + port = 51820, ["private-key"] = "k", ["public-key"] = "pk", + ["amnezia-wg-option"] = { jc = 4, ["x: 1"] = 2 } }) +local awg_yaml = clash_meta.generate({ awg }) +check("F9 awg numeric subkey plain", awg_yaml:find("jc: 4", 1, true) ~= nil) +check("F9 awg subkey quoted", + awg_yaml:find('"x: 1": 2', 1, true) ~= nil) + +-- ---------- F10:空 target 的 Content-Type 与后缀 ---------- +-- `?target=` 传进来的是 "",而 "" 在 Lua 里是真值,`format or DEFAULT_FORMAT` +-- 兜不住它 —— 正文按默认格式生成,响应头与文件名却是另一回事。 +check("F10 empty target content type", + output.content_type_for("") == output.content_type_for("clashmeta")) +check("F10 empty target extension", + output.extension_for("") == output.extension_for("clashmeta")) +check("F10 whitespace target treated as empty", + output.content_type_for(" ") == output.content_type_for("clashmeta")) +check("F10 nil target content type", + output.content_type_for(nil) == output.content_type_for("clashmeta")) +check("F10 empty target extension is yaml", output.extension_for("") == "yaml") +check("F10 unknown target falls back to txt", output.extension_for("nonsense") == "txt") + +-- ---------- 结果 ---------- +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/p2_batch7_test.lua b/luci-app-substore/tests/p2_batch7_test.lua index 565d39353..67f8c1bc6 100644 --- a/luci-app-substore/tests/p2_batch7_test.lua +++ b/luci-app-substore/tests/p2_batch7_test.lua @@ -10,7 +10,6 @@ local util = require("substore.util") local node = require("substore.node") local output = require("substore.output") local probe = require("substore.probe") -local conv = require("substore.converter") local passed, failed = 0, 0 local function check(name, cond) @@ -164,33 +163,20 @@ local u = uuid() check("util.uuid shape", type(u) == "string" and u:match(UUID_RE) ~= nil) check("util.uuid unique-ish", uuid() ~= uuid()) -local trojan = { name = "t", proto = "trojan", server = "a.example.com", port = 443, password = "secretpw" } -local as_vmess = conv.convert_protocol(trojan, "vmess") -check("trojan->vmess produced node", type(as_vmess) == "table") -check("trojan->vmess uuid valid", as_vmess and as_vmess.uuid ~= nil and as_vmess.uuid:match(UUID_RE) ~= nil) -check("trojan->vmess uuid not the password", as_vmess and as_vmess.uuid ~= "secretpw") -local as_vless = conv.convert_protocol(trojan, "vless") -check("trojan->vless uuid valid", as_vless and as_vless.uuid ~= nil and as_vless.uuid:match(UUID_RE) ~= nil) --- 反向对照:旧实现是 base64(seed) 的前 36 字符,base64 只产出 24 个字符, --- 所以长度必然不等于 36 且含非十六进制字符。确认新值确实不是那个形状。 -check("uuid is not base64-shaped", - as_vmess and as_vmess.uuid:find("[+/=]", 1) == nil and #as_vmess.uuid == 36) +-- L25 原先经 converter.convert_protocol 验证「转换时生成的 UUID 不是 +-- base64(seed) 的形状」。converter / node_converter 已作为死代码删除 +-- (见 docs/LEGACY_ISSUES.md 1.4),这里改为直接验证 util.uuid 本身 —— +-- 那才是唯一在用的实现,形状要求与当初一致:36 字符、只含十六进制与连字符。 +-- 反向对照:旧实现是 base64(seed) 的前 36 字符,而 base64 只产出 24 个字符, +-- 长度必然不等于 36 且含非十六进制字符。 +check("util.uuid not base64-shaped", + u:find("[+/=]", 1) == nil and #u == 36) -- ---------- L24:gsub 替换串必须按字面处理 ---------- check("gsub_literal escapes percent", gsub_literal("100%") == "100%%") check("gsub_literal empty", gsub_literal(nil) == "") -local tpl = { { name = "100%off", proto = "vmess", server = "s.example.com", port = 443 } } -check("apply_template keeps percent", - conv.apply_template(tpl, "[{{name}}]")[1].name == "[100%off]") -check("apply_template keeps capture-like text", - conv.apply_template({ { name = "a%1b", proto = "vmess", server = "s", port = 1 } }, "[{{name}}]")[1].name - == "[a%1b]") -check("apply_template keeps trailing percent", - conv.apply_template({ { name = "x%", proto = "vmess", server = "s", port = 1 } }, "[{{name}}]")[1].name - == "[x%]") - --- node.apply_rules 的 {server} 模板占位符走的是另一条路径,同样要按字面替换。 +-- node.apply_rules 的 {server} 模板占位符同样要按字面替换。 -- 该分支由 rules.template_apply 开关控制,必须显式打开。 local TMPL_ON = { template_apply = true } local tmpl_nodes = { { diff --git a/luci-app-substore/tests/parser_mixed_format_test.lua b/luci-app-substore/tests/parser_mixed_format_test.lua new file mode 100644 index 000000000..56ffe28b3 --- /dev/null +++ b/luci-app-substore/tests/parser_mixed_format_test.lua @@ -0,0 +1,172 @@ +-- parser_mixed_format_test.lua — 混合格式文本导入的显式报错(M11 / A3) +-- 用法:lua5.1 tests/parser_mixed_format_test.lua +-- +-- 背景:detect() 只返回优先级最高的**一种**格式,其余部分被静默丢弃, +-- 而 parse 返回的是合法表 —— 同步报成功,用户以为整份都导进来了。 +-- 实测(2.6.15-r1): +-- 「URI + WG conf」→ detect=wireguard-conf,只得到 WG 节点,err=nil +-- 「URI + JSON」 → detect=uri,只得到 URI 节点,err=nil +-- +-- 修复:新增 detect_all()(整份文档级标记 + 行首锚定), +-- parse_local 文本模式在命中 >1 种格式时**明确报错**。 +-- +-- 本文件覆盖三类: +-- A 单格式内容一律不得被误判(误判会把正常配置挡在门外,比漏报更糟) +-- B 混合内容必须报错,且错误信息点名命中的格式 +-- C 作用域:只影响本地文本导入,远程订阅(M.parse)行为不变 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local parser = require("substore.parser") +local util = require("substore.util") + +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 function vmess_uri(name) + return "vmess://" .. util.base64_encode(util.json_encode({ + v = "2", ps = name, add = "1.2.3.4", port = "443", + id = "11111111-2222-3333-4444-555555555555", + aid = "0", net = "ws", type = "none", host = "", path = "/", tls = "", + })) +end + +local URI = vmess_uri("U-01") +local VLESS = "vless://11111111-2222-3333-4444-555555555555@1.2.3.4:443?encryption=none&type=ws#V-01" + +local WG_CONF = [[ +[Interface] +PrivateKey = aGVsbG93b3JsZHByaXZhdGVrZXkwMDAwMDAwMDAwMDA= +Address = 10.7.0.2/32 +DNS = 1.1.1.1 + +[Peer] +PublicKey = cGVlcnB1YmxpY2tleTAwMDAwMDAwMDAwMDAwMDAwMDAwMDA= +AllowedIPs = 0.0.0.0/0 +Endpoint = wg.example.com:51820 +]] + +local JSON_OBJ = util.json_encode({ + outbounds = { { type = "vmess", server = "1.2.3.4", server_port = 443, + uuid = "11111111-2222-3333-4444-555555555555" } }, +}) + +local JSON_ARRAY = util.json_encode({ + { type = "vmess", server = "1.2.3.4", port = 443, + uuid = "11111111-2222-3333-4444-555555555555" }, +}) + +local CLASH_YAML = [[ +proxies: + - name: n1 + type: vmess + server: 1.2.3.4 + port: 443 + uuid: 11111111-2222-3333-4444-555555555555 + alterId: 0 + cipher: auto +]] + +local SURGE = [[ +[Proxy] +n1 = vmess, 1.2.3.4, 443, username=11111111-2222-3333-4444-555555555555 + +[Proxy Group] +auto = select, n1 + +[Rule] +FINAL,auto +]] + +-- ---------- A:单格式内容不得被误判 ---------- +-- 每条都断言「能解析出节点」且「没有 err」:只断言 err 的话, +-- 一份本来就解析不出节点的内容(比如 base64 那段)会让断言失去意义。 +local function ok_single(name, content, want_format, want_nodes) + local res, err = parser.parse_local(content, "text") + check("A " .. name .. " imports without error", err == nil) + check("A " .. name .. " parses nodes", + res ~= nil and #res.nodes >= want_nodes) + if want_format then + check("A " .. name .. " format is " .. want_format, + res ~= nil and res.format == want_format) + end +end + +ok_single("uri list", URI .. "\n" .. VLESS .. "\n", "uri", 2) +ok_single("uri with comment line", "# 我的订阅 https://example.com/sub\n" .. URI .. "\n", "uri", 1) +ok_single("wireguard conf", WG_CONF, "wireguard-conf", 1) +ok_single("json config", JSON_OBJ, "json", 1) +ok_single("json array", JSON_ARRAY, "json", 1) +ok_single("clash yaml", CLASH_YAML, "yaml", 1) +ok_single("surge", SURGE, "surge", 1) +-- base64:整份内容是一个 base64 blob(解出来是 URI 列表) +ok_single("base64 of uri list", + util.base64_encode(URI .. "\n" .. VLESS .. "\n"), "base64", 2) + +-- 最容易误判的一类:**正常配置里带订阅地址**。 +-- Clash YAML 的 `url: https://…`、sing-box JSON 的 `"url": "https://…"` 都含 "://", +-- 若用 detect() 里那条宽松的 content:find("://") 当 URI 判据,这些配置会被判成 +-- 「YAML + URI 混合」而拒绝导入。 +ok_single("clash yaml with proxy-provider url", CLASH_YAML .. + "proxy-providers:\n p:\n url: https://example.com/sub\n path: ./p.yaml\n", + "yaml", 1) +ok_single("singbox json with url in route rule", util.json_encode({ + outbounds = { { type = "vmess", server = "1.2.3.4", server_port = 443, + uuid = "11111111-2222-3333-4444-555555555555" } }, + route = { rules = { { url = "https://example.com/x" } } }, +}), "json", 1) +-- YAML 流式映射 `{path: /x}` 不是 JSON 对象(键没有引号) +ok_single("clash yaml with flow mapping", CLASH_YAML .. + " ws-opts:\n {path: /ws, headers: {Host: a.example.com}}\n", + "yaml", 1) +-- WG conf 的 [Interface] 也是 "[" 开头,不得被判成 JSON 数组 +ok_single("wireguard conf not read as json array", WG_CONF, "wireguard-conf", 1) +-- Surge 的 [Proxy] 段头同样以 "[" 开头 +ok_single("surge not read as json array", SURGE, "surge", 1) +-- 注释里的链接不是节点行 +ok_single("wireguard conf with commented url", WG_CONF .. "\n# https://example.com/sub\n", + "wireguard-conf", 1) + +-- detect_all 单格式只命中一种 +check("A detect_all uri", #parser.detect_all(URI .. "\n" .. VLESS) == 1) +check("A detect_all wireguard-conf", #parser.detect_all(WG_CONF) == 1) +check("A detect_all surge", #parser.detect_all(SURGE) == 1) +check("A detect_all empty", #parser.detect_all("") == 0) +check("A detect_all plain text", #parser.detect_all("hello world") == 0) + +-- ---------- B:混合内容必须明确报错 ---------- +local function mixed(name, content, want_a, want_b) + local res, err = parser.parse_local(content, "text") + check("B " .. name .. " rejected", res == nil) + check("B " .. name .. " error is explicit", + type(err) == "string" and err:find("混用了多种格式", 1, true) ~= nil) + -- 错误信息必须点名两种格式,否则用户不知道要拆成哪两份 + check("B " .. name .. " names both formats", + type(err) == "string" and err:find(want_a, 1, true) ~= nil + and err:find(want_b, 1, true) ~= nil) +end + +mixed("uri + wireguard conf", URI .. "\n" .. WG_CONF, "URI 链接", "WireGuard .conf") +mixed("wireguard conf + uri", WG_CONF .. "\n" .. URI, "URI 链接", "WireGuard .conf") +mixed("uri + json object", URI .. "\n" .. JSON_OBJ, "URI 链接", "JSON") +mixed("json object + uri", JSON_OBJ .. "\n" .. URI, "URI 链接", "JSON") +mixed("uri + json array", URI .. "\n" .. JSON_ARRAY, "URI 链接", "JSON") +mixed("uri + clash yaml", URI .. "\n" .. CLASH_YAML, "URI 链接", "Clash YAML") +mixed("uri + surge", URI .. "\n" .. SURGE, "URI 链接", "Surge/Loon 配置") + +-- 报错不能是「静默丢弃」的另一种说法:绝不能返回部分节点 +local resB = parser.parse_local(URI .. "\n" .. WG_CONF, "text") +check("B mixed import returns no partial nodes", resB == nil) + +-- ---------- C:作用域 ---------- +-- 远程订阅走 M.parse,不经 detect_all —— 混合格式的远程订阅行为不变。 +-- 这是刻意的:本地导入是人工粘贴(能提示用户拆开),远程订阅的响应内容 +-- 不受用户控制,直接拒收会让「订阅源多吐了一行」变成整条订阅不可用。 +local resC = parser.parse(URI .. "\n" .. WG_CONF) +check("C remote parse still returns nodes", resC ~= nil and #resC.nodes >= 1) + +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/qx_tag_comma_test.lua b/luci-app-substore/tests/qx_tag_comma_test.lua new file mode 100644 index 000000000..d1ab07fb5 --- /dev/null +++ b/luci-app-substore/tests/qx_tag_comma_test.lua @@ -0,0 +1,117 @@ +-- qx_tag_comma_test.lua — QX 节点名含逗号时的整条丢弃回归测试 +-- 用法:lua5.1 tests/qx_tag_comma_test.lua +-- +-- 缺陷(docs/LEGACY_ISSUES.md 1.5):`[server_local]` 行是逗号分隔的 +-- `key=value` 序列,语法里没有引号 / 转义。节点名 `A,B` 经 one_line 后仍含 +-- 逗号,输出成 `..., tag=A,B` —— 按该语法会被读成 `tag=A` 加一个悬空字段, +-- 用户拿到的不是他填的那个名字。QX 对这种行的实际处置未经核实(本机没有 +-- Quantumult X 可实测),因此按推荐方案 B 整条丢弃:定义行与 [policy] 成员 +-- 一并去掉,与 Surge 家族丢 wireguard / ssr、Clash 原版丢不支持协议同一约定。 +-- +-- 反向对照:改动前 to_qx 只把 `tag=` 排在最后、由 names_of 挡成员列表, +-- 定义行照写 —— 下面「无 tag=Bad,Name 行」「无 Bad,Name 出现」两条会失败。 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local output = require("substore.output") + +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 function trojan(name, server) + return { + proto = "trojan", name = name, server = server or "1.2.3.4", port = 443, + password = "pw", security = "tls", sni = "s.example.com", + } +end + +local function qx(nodes) + local body, err = output.generate(nodes, "qx") + return body, err +end + +-- ---------- 基线:无逗号名字照常输出 ---------- +local base, base_err = qx({ trojan("Good Node") }) +check("baseline qx generates", type(base) == "string" and base_err == nil) +check("baseline emits definition line", base:find("tag=Good Node", 1, true) ~= nil) +check("baseline lists member in policy", base:find("static=PROXY, DIRECT, Good Node", 1, true) ~= nil) + +-- ---------- 含逗号名字:整条丢弃 ---------- +local mixed = qx({ trojan("Good Node", "1.1.1.1"), trojan("Bad,Name", "2.2.2.2") }) +check("mixed qx generates", type(mixed) == "string") + +check("no definition line for comma name", mixed:find("tag=Bad,Name", 1, true) == nil) +check("comma name absent entirely", mixed:find("Bad,Name", 1, true) == nil) +-- 单独查 `tag=Bad`:即使名字被截断成 `tag=Bad` 也必须不出现 +check("no truncated tag=Bad either", mixed:find("tag=Bad", 1, true) == nil) +-- 该节点的服务器地址也必须消失(定义行整体没了) +check("dropped node server absent", mixed:find("2.2.2.2", 1, true) == nil) + +-- 正常节点不受牵连 +check("good node still emitted", mixed:find("tag=Good Node", 1, true) ~= nil) +check("good node still in policy", mixed:find("static=PROXY, DIRECT, Good Node", 1, true) ~= nil) +check("good node server present", mixed:find("1.1.1.1", 1, true) ~= nil) + +-- 定义行只应有一条([server_local] 段内)。 +-- 段内为空时输出是 `[server_local]\n\n[policy]`,非空时是 +-- `[server_local]\n\n\n[policy]` —— 取两者之间即可,不要去数 `\n\n`。 +local function def_count_of(body) + local section = body:match("%[server_local%]\n(.-)\n%[policy%]") or "" + local n = 0 + for line in (section .. "\n"):gmatch("([^\n]*)\n") do + if line:match("^%a+=") then n = n + 1 end + end + return n +end +check("exactly one definition line left", def_count_of(mixed) == 1) + +-- 成员列表里不得出现被丢弃的名字 +local policy = mixed:match("static=[^\n]*") or "" +check("policy line has no dangling member", + not policy:find("Bad", 1, true) and policy:find("Good Node", 1, true) ~= nil) + +-- ---------- 全部节点都含逗号:仍产出合法骨架 ---------- +local all_bad = qx({ trojan("A,B"), trojan("C,D") }) +check("all-comma qx generates", type(all_bad) == "string") +check("all-comma has no definitions", def_count_of(all_bad) == 0) +check("all-comma policy is just the default", + all_bad:find("static=PROXY, DIRECT\n", 1, true) ~= nil) +check("all-comma has no stray comma", all_bad:find("A,B", 1, true) == nil + and all_bad:find("C,D", 1, true) == nil) + +-- ---------- 判定必须基于 one_line 之后的名字 ---------- +-- names_of 先 one_line 再比对;定义行也必须用同一个字符串,否则两边对不上。 +-- `Bad\n,Name` 里的换行会被压成空格,逗号仍在 —— 仍须丢弃。 +local nl = qx({ trojan("Bad\n,Name") }) +check("newline+comma name dropped", nl:find("Bad", 1, true) == nil) +check("newline+comma name adds no lines", + select(2, nl:gsub("\n", "\n")) == select(2, qx({}):gsub("\n", "\n"))) + +-- 换行但**无**逗号的名字:仍应输出(换行被压成空格,不触发本条规则), +-- 且不得比一个普通节点多出任何行(名字里的换行若漏了 one_line 会凭空多出一行)。 +local nl_ok = qx({ trojan("Multi\nLine") }) +check("newline-only name kept", nl_ok:find("tag=Multi Line", 1, true) ~= nil) +local function line_count(body) + return select(2, body:gsub("\n", "\n")) +end +check("newline-only name flattened to one line", + line_count(nl_ok) == line_count(qx({ trojan("Plain") }))) + +-- ---------- 值里的逗号仍按既有规则处理(不因本条改动而回归) ---------- +local pwd = qx({ { proto = "trojan", name = "P", server = "3.3.3.3", port = 443, + password = "pa,ss", security = "tls" } }) +check("comma in password still drops the node", pwd:find("3.3.3.3", 1, true) == nil) + +-- ---------- Surge 家族不受影响:名字在 `=` 左侧,逗号不影响该行解析 ---------- +-- 本条只针对 QX。Surge 的 [Proxy] 行是 `NAME = type, host, port, …`, +-- 名字里含逗号的节点仍应写出定义行,只是不进 [Proxy Group] 成员列表。 +local surge = output.generate({ trojan("Bad,Name", "4.4.4.4") }, "surge") +check("surge still emits comma-named node", surge:find("Bad,Name = trojan", 1, true) ~= nil) +local group = surge:match("PROXY = select[^\n]*") or "" +check("surge group excludes comma name", group:find("Bad,Name", 1, true) == 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/singbox_transport_test.lua b/luci-app-substore/tests/singbox_transport_test.lua new file mode 100644 index 000000000..542aeee51 --- /dev/null +++ b/luci-app-substore/tests/singbox_transport_test.lua @@ -0,0 +1,131 @@ +-- singbox_transport_test.lua — sing-box 出站 transport 读取回归测试 +-- 用法:lua5.1 tests/singbox_transport_test.lua +-- +-- 缺陷(docs/LEGACY_ISSUES.md 1.3):两个读取侧都不认 sing-box 的 transport 对象 —— +-- parser_json_config 读的是 outbound.network(sing-box 出站里根本没有这个字段), +-- 简易 YAML 解析器只展开 tls: 子块。于是 ws / grpc / h2 节点全部按 tcp 导入, +-- 客户端拿明文 tcp 去连只开了 ws 的端口,握手必然失败且**不报错**。 +-- 简易 YAML 侧更彻底:path / host 连带都没带进 node_data。 +-- +-- 字段名取自上游 sing-box.sagernet.org/configuration/shared/v2ray-transport/: +-- ws path、headers.Host +-- grpc service_name +-- http path、host(数组) +-- httpupgrade path、host(单个字符串) + +package.path = "./root/usr/share/?.lua;" .. package.path + +local parser = require("substore.parser") + +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 function first(content) + local r = parser.parse(content) + return r and r.nodes and r.nodes[1] or nil +end + +-- ---------- JSON 侧 ---------- +local JSON_WS = [[{"outbounds":[{"type":"vmess","tag":"n","server":"1.2.3.4", +"server_port":443,"uuid":"11111111-2222-3333-4444-555555555555", +"transport":{"type":"ws","path":"/ws","headers":{"Host":"a.com"}}, +"tls":{"enabled":true,"server_name":"a.com","utls":{"enabled":true,"fingerprint":"chrome"}}}]}]] + +local jw = first(JSON_WS) +check("json ws node parsed", jw ~= nil) +check("json ws net", jw and jw.net == "ws") +check("json ws path", jw and jw.path == "/ws") +check("json ws host from headers.Host", jw and jw.host == "a.com") +check("json tls utls fingerprint", jw and jw.fp == "chrome") +check("json tls server_name", jw and jw.sni == "a.com") + +local jg = first([[{"outbounds":[{"type":"vless","tag":"n","server":"1.2.3.4", +"server_port":443,"uuid":"u","transport":{"type":"grpc","service_name":"svc"}}]}]]) +check("json grpc net", jg and jg.net == "grpc") +check("json grpc service_name -> path", jg and jg.path == "svc") + +local jh = first([[{"outbounds":[{"type":"vless","tag":"n","server":"1.2.3.4", +"server_port":443,"uuid":"u","transport":{"type":"http","host":["h1.com","h2.com"],"path":"/p"}}]}]]) +check("json http net", jh and jh.net == "http") +check("json http path", jh and jh.path == "/p") +-- http 的 host 是数组,取首个 +check("json http host takes first of array", jh and jh.host == "h1.com") + +local ju = first([[{"outbounds":[{"type":"vless","tag":"n","server":"1.2.3.4", +"server_port":443,"uuid":"u","transport":{"type":"httpupgrade","host":"hu.com","path":"/hu"}}]}]]) +check("json httpupgrade net", ju and ju.net == "http") +check("json httpupgrade path", ju and ju.path == "/hu") +-- httpupgrade 的 host 是单个字符串(与 http 的数组不同) +check("json httpupgrade host", ju and ju.host == "hu.com") + +-- quic 在本项目模型里没有对应传输方式,不得凭空映射成别的 +local jq = first([[{"outbounds":[{"type":"vless","tag":"n","server":"1.2.3.4", +"server_port":443,"uuid":"u","transport":{"type":"quic"}}]}]]) +check("json quic not mapped to a wrong transport", + jq and jq.net ~= "ws" and jq.net ~= "grpc" and jq.net ~= "http") + +-- 无 transport 的普通节点不得被改动 +local jplain = first([[{"outbounds":[{"type":"trojan","tag":"n","server":"1.2.3.4", +"server_port":443,"password":"p"}]}]]) +check("json node without transport still parses", jplain and jplain.proto == "trojan") +check("json node without transport has no path", jplain and jplain.path == nil) + +-- ---------- 简易 YAML 侧 ---------- +-- 用一条 Clash YAML 解析器认不出的 sing-box 风格 YAML,强制走回退解析器 +local YAML_WS = [[outbounds: + - type: vmess + tag: n + server: 1.2.3.4 + server_port: 443 + uuid: 11111111-2222-3333-4444-555555555555 + transport: + type: ws + path: /ws + headers: + Host: a.com + tls: + enabled: true + server_name: a.com + utls: + enabled: true + fingerprint: chrome +]] +local yw = first(YAML_WS) +check("yaml ws node parsed", yw ~= nil) +check("yaml ws net", yw and yw.net == "ws") +check("yaml ws path", yw and yw.path == "/ws") +check("yaml ws host", yw and yw.host == "a.com") +check("yaml tls utls fingerprint", yw and yw.fp == "chrome") + +local YAML_GRPC = [[outbounds: + - type: vless + tag: n + server: 1.2.3.4 + server_port: 443 + uuid: u + transport: + type: grpc + service_name: svc +]] +local yg = first(YAML_GRPC) +check("yaml grpc net", yg and yg.net == "grpc") +check("yaml grpc service_name -> path", yg and yg.path == "svc") + +-- 两条导入路径必须给出一致的节点(同一条订阅走 JSON 与 YAML 不该有差别) +check("json and yaml agree on net", jw and yw and jw.net == yw.net) +check("json and yaml agree on path", jw and yw and jw.path == yw.path) +check("json and yaml agree on host", jw and yw and jw.host == yw.host) +check("json and yaml agree on fp", jw and yw and jw.fp == yw.fp) + +-- ---------- 结果 ---------- +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/ss_plugin_test.lua b/luci-app-substore/tests/ss_plugin_test.lua new file mode 100644 index 000000000..abd29a1bf --- /dev/null +++ b/luci-app-substore/tests/ss_plugin_test.lua @@ -0,0 +1,178 @@ +-- ss_plugin_test.lua — shadowsocks SIP003 插件全链路回归测试 +-- 用法:lua5.1 tests/ss_plugin_test.lua +-- +-- 缺陷:`plugin` 能被解析、能通过 node.normalize 存活,但三个输出模块全都 +-- 静默丢掉它 —— 带 obfs / v2ray-plugin 的 ss 节点导出后,客户端以明文 SS +-- 去连只接受带插件握手的服务端,必然失败且不报错。表单侧同样致命: +-- plugin 不在 node.PROTO_FIELDS.shadowsocks 里,用户在界面上编辑一次 +-- 该节点,core.merge_form_node 就会把插件配置清空。 +-- +-- 上游依据(均已核对,非推测): +-- * SIP002 SS-URI = "ss://" userinfo "@" host ":" port [ "/" ] [ "?" plugin ] [ "#" tag ] +-- plugin 的取值整体做百分号编码 +-- * sing-box shadowsocks 出站:plugin(字符串,仅支持 obfs-local / v2ray-plugin) +-- 与 plugin_opts(SIP003 原始参数串,原样透传) +-- * mihomo shadowsocks 出站:plugin(obfs / v2ray-plugin / …)、 +-- plugin-opts 是**映射**;obfs 的 mode 必须 ∈ {tls,http}, +-- v2ray-plugin 的 mode 必须是 websocket,否则整个 outbound 构造失败, +-- mihomo 拒绝加载整份配置 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local util = require("substore.util") +local node = require("substore.node") +local core = require("substore.core") +local parser = require("substore.parser") +local clash_meta = require("substore.output_clash_meta") +local singbox = require("substore.output_singbox") +local uri_mod = require("substore.output_uri") + +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 function line_with(text, pattern) + for l in text:gmatch("[^\n]+") do + if l:find(pattern) then return l end + end + return nil +end + +-- SIP002 规范里的示例 URI +local SPEC_URI = "ss://YWVzLTI1Ni1nY206cGFzcw==@192.168.100.1:8888/" + .. "?plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dwww.baidu.com#Example2" +local SPEC_PLUGIN = "obfs-local;obfs=http;obfs-host=www.baidu.com" + +-- ---------- util.parse_sip003_plugin ---------- +-- 取一个本地引用:修复前 util 里没有这个函数,直接调用会让整个文件在第 55 行 +-- 崩掉,后面的断言一条都跑不到,反向验证就失去了意义。 +local parse_plugin = util.parse_sip003_plugin or function() return nil end +local name, opts_str, opts_map = parse_plugin(SPEC_PLUGIN) +opts_map = opts_map or {} +check("parse plugin name", name == "obfs-local") +check("parse plugin opts string", opts_str == "obfs=http;obfs-host=www.baidu.com") +check("parse plugin opts map obfs", opts_map.obfs == "http") +check("parse plugin opts map obfs-host", opts_map["obfs-host"] == "www.baidu.com") +check("parse empty plugin returns nil", parse_plugin("") == nil) +check("parse nil plugin returns nil", parse_plugin(nil) == nil) +-- SIP003 规定分号、等号、反斜杠可用反斜杠转义 +local esc_name, _, esc_map = parse_plugin("obfs-local;obfs=http\\;x") +esc_map = esc_map or {} +check("parse escaped semicolon name", esc_name == "obfs-local") +check("parse escaped semicolon kept", esc_map and esc_map.obfs == "http\\;x") + +-- ---------- 解析:ss:// URI 的 plugin 必须落到模型 ---------- +local parsed = parser.parse(SPEC_URI) +check("uri parsed", parsed ~= nil and #parsed.nodes == 1) +local n = parsed.nodes[1] +check("plugin parsed from uri", n.plugin == SPEC_PLUGIN) +check("plugin survives normalize", node.normalize(n).plugin == SPEC_PLUGIN) + +-- ---------- mihomo(clashmeta) ---------- +local d = node.normalize(n) +local yaml = clash_meta.generate({ d }) +check("clashmeta emits plugin: obfs", yaml:find(" plugin: obfs\n", 1, true) ~= nil) +check("clashmeta emits plugin-opts", yaml:find(" plugin-opts:\n", 1, true) ~= nil) +check("clashmeta obfs mode", yaml:find(" mode: http\n", 1, true) ~= nil) +check("clashmeta obfs host", yaml:find(" host: www.baidu.com\n", 1, true) ~= nil) +-- plugin-opts 的子键必须缩进在它之下 +local po = line_with(yaml, "plugin%-opts:") +local mode_line = line_with(yaml, "mode: http") +check("clashmeta plugin-opts children nested", + po ~= nil and mode_line ~= nil + and #mode_line:match("^(%s*)") > #po:match("^(%s*)")) + +local function ss(plugin) + return node.normalize({ proto = "shadowsocks", name = "S", server = "1.2.3.4", + port = 8388, method = "aes-256-gcm", password = "p", plugin = plugin }) +end + +-- simple-obfs 是 obfs-local 的历史别名,mihomo 只认 obfs +local simple = clash_meta.generate({ ss("simple-obfs;obfs=tls;obfs-host=h.com") }) +check("clashmeta simple-obfs mapped to obfs", + simple:find(" plugin: obfs\n", 1, true) ~= nil) +check("clashmeta simple-obfs mode tls", simple:find(" mode: tls\n", 1, true) ~= nil) + +-- v2ray-plugin:mode 必须是 websocket +local v2 = clash_meta.generate({ ss("v2ray-plugin;mode=websocket;host=h.com;path=/ws;tls") }) +check("clashmeta v2ray-plugin name", + v2:find(" plugin: v2ray-plugin\n", 1, true) ~= nil) +check("clashmeta v2ray-plugin mode", + v2:find(" mode: websocket\n", 1, true) ~= nil) +check("clashmeta v2ray-plugin host", v2:find(" host: h.com\n", 1, true) ~= nil) +check("clashmeta v2ray-plugin path", v2:find(" path: /ws\n", 1, true) ~= nil) +check("clashmeta v2ray-plugin tls", v2:find(" tls: true\n", 1, true) ~= nil) + +-- 参数非法时必须**整个不输出**插件:mihomo 对这两类插件是强校验的, +-- 输出一个必然被拒绝的组合会让整份配置加载失败,一个坏节点废掉整个订阅。 +local bad_obfs = clash_meta.generate({ ss("obfs-local") }) +check("clashmeta obfs without mode omitted", + bad_obfs:find("plugin:", 1, true) == nil) +local bad_obfs2 = clash_meta.generate({ ss("obfs-local;obfs=bogus") }) +check("clashmeta obfs invalid mode omitted", + bad_obfs2:find("plugin:", 1, true) == nil) +local bad_v2 = clash_meta.generate({ ss("v2ray-plugin;mode=quic") }) +check("clashmeta v2ray-plugin invalid mode omitted", + bad_v2:find("plugin:", 1, true) == nil) +-- 未知插件名:mihomo 的 plugin 分支没有收尾 else,未知名字本来就静默跳过 +local unknown = clash_meta.generate({ ss("kcptun;key=abc") }) +check("clashmeta unknown plugin omitted", + unknown:find("plugin:", 1, true) == nil) +-- 无插件的普通 ss 节点不得凭空多出 plugin 字段 +local plain = clash_meta.generate({ ss(nil) }) +check("clashmeta plain ss has no plugin", plain:find("plugin", 1, true) == nil) + +-- ---------- sing-box ---------- +local sbox = singbox.generate({ d }) +check("singbox emits plugin", sbox:find('"plugin":"obfs-local"', 1, true) ~= nil) +check("singbox plugin_opts is raw SIP003 string", + sbox:find('"plugin_opts":"obfs=http;obfs-host=www.baidu.com"', 1, true) ~= nil) +-- sing-box 只支持这两个插件名,其余会拒绝加载整份配置 +check("singbox unknown plugin omitted", + singbox.generate({ ss("kcptun;key=abc") }):find('"plugin"', 1, true) == nil) +check("singbox v2ray-plugin kept", + singbox.generate({ ss("v2ray-plugin;mode=websocket") }):find('"plugin":"v2ray-plugin"', 1, true) ~= nil) +check("singbox plain ss has no plugin", + singbox.generate({ ss(nil) }):find('"plugin"', 1, true) == nil) + +-- ---------- 分享链接回写(SIP002) ---------- +local out_uri = uri_mod.to_share_uri(d) +check("uri keeps plugin query", + out_uri:find("/?plugin=", 1, true) ~= nil) +check("uri percent-encodes plugin", + out_uri:find("plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dwww.baidu.com", 1, true) ~= nil) +-- 往返:导出 → 再解析,插件配置必须一字不差 +local back = parser.parse(out_uri) +check("uri round-trip re-parses", back ~= nil and #back.nodes == 1) +check("uri round-trip plugin preserved", + back.nodes[1].plugin == SPEC_PLUGIN) +-- 无插件时不得多出 `/?plugin=` +check("uri plain ss has no plugin query", + uri_mod.to_share_uri(ss(nil)):find("plugin", 1, true) == nil) + +-- ---------- 表单:字段清单与合并 ---------- +local ss_fields = node.PROTO_FIELDS.shadowsocks +local has_plugin = false +for _, k in ipairs(ss_fields) do if k == "plugin" then has_plugin = true end end +check("PROTO_FIELDS.shadowsocks has plugin", has_plugin) +-- 表单渲染 plugin 后,清空输入框必须能真的删掉它 +local cleared = core.merge_form_node(d, { proto = "shadowsocks", name = "S", + server = "1.2.3.4", port = 8388, method = "aes-256-gcm", password = "p", plugin = "" }) +check("merge_form_node clears plugin", cleared.plugin == "" or cleared.plugin == nil) +-- 表单提交了新值时必须生效 +local updated = core.merge_form_node(d, { proto = "shadowsocks", name = "S", + server = "1.2.3.4", port = 8388, method = "aes-256-gcm", password = "p", + plugin = "v2ray-plugin;mode=websocket" }) +check("merge_form_node applies plugin", updated.plugin == "v2ray-plugin;mode=websocket") + +-- ---------- 结果 ---------- +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/subscriptions_bulk_delete_test.lua b/luci-app-substore/tests/subscriptions_bulk_delete_test.lua new file mode 100644 index 000000000..d00677ab5 --- /dev/null +++ b/luci-app-substore/tests/subscriptions_bulk_delete_test.lua @@ -0,0 +1,233 @@ +-- subscriptions_bulk_delete_test.lua — 订阅列表页的勾选批量删除 +-- 用法:lua5.1 tests/subscriptions_bulk_delete_test.lua +-- +-- 需求(用户提出): +-- 1) 「名称」列前面要有选择框,表头的选择框能全选,每条订阅也能单独勾选; +-- 2) 「添加组合订阅」后面要有「删除」按钮,勾选后删单条或多条, +-- 一个都没勾选时点击不得删除任何东西。 +-- +-- .htm 需要 LuCI 运行时才能渲染,本机没有。做法与 subscriptions_format_gate_test.lua +-- 相同:把模板按 LuCI 的方式重建成 Lua chunk(<% %> 里的代码原样,文本变 __w(...)), +-- 用替身环境**真的渲染一遍**,再对生成的 HTML 断言。这比「源码里有没有某个字符串」 +-- 强:它同时验证了模板语法、循环/分支与最终输出。 +-- +-- 有两类断言只能退化成字符串匹配(本机没有 JS 引擎,跑不了页面里的脚本): +-- 删除按钮的 JS 行为。这类断言一律在**渲染结果**上做(而不是读 .htm 源码), +-- 至少能保证脚本真的被输出、且关键顺序(先判空、后提交)没被写反。 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local core = require("substore.core") + +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 + +-- ---------- 数据目录改到 /tmp,绝不碰真实的 /etc/substore ---------- +-- LIST_FILE / NODES_DIR 是 require 时算好的常量,必须一并改掉。 +local TMPDIR = "/tmp/substore_bulkdel_" .. tostring(os.time()) .. "_" .. tostring(math.random(1e6)) +os.execute("rm -rf " .. TMPDIR) +core.DATA_DIR = TMPDIR +core.LIST_FILE = TMPDIR .. "/subscriptions.json" +core.NODES_DIR = TMPDIR .. "/nodes" +core.ensure_dirs() + +local VIEW = "root/usr/lib/lua/luci/view/substore/subscriptions.htm" + +-- 与 tests/subscriptions_format_gate_test.lua 的 build_chunk 同构: +-- 字面 HTML 也要 __w 出来,否则标签属性(class / value / onclick)全丢,断言无从谈起。 +local function build_chunk(src) + local out = { "local __w = __substore_w" } + local i, n = 1, #src + while i <= n do + local s = src:find("<%", i, true) + if not s then + out[#out + 1] = "__w(" .. string.format("%q", src:sub(i)) .. ")" + break + end + if s > i then + out[#out + 1] = "__w(" .. string.format("%q", src:sub(i, s - 1)) .. ")" + end + local e = src:find("%>", s + 2, true) + if not e then return nil, "unclosed <% at " .. s end + local tag = src:sub(s + 2, e - 1) + local kind = tag:sub(1, 1) + local body = tag:sub(2) + if kind == "=" then + out[#out + 1] = "__w(" .. body .. ")" + elseif kind == ":" then + out[#out + 1] = "__w(" .. string.format("%q", body) .. ")" + elseif kind == "+" then + out[#out + 1] = "__w()" -- header / footer 不参与断言 + else + out[#out + 1] = tag + end + out[#out + 1] = "\n" + i = e + 2 + end + return table.concat(out, "\n") +end + +-- ---------- LuCI 替身 ---------- +local luci_stub = { + util = { + pcdata = function(s) return tostring(s or "") end, + urlencode = function(s) return tostring(s or "") end, + }, + i18n = { translate = function(s) return tostring(s or "") end }, + dispatcher = { + context = { query = {} }, + build_url = function(...) + return "/cgi-bin/luci/" .. table.concat({ ... }, "/") + end, + }, +} +local http_stub = { + formvalue = function() return nil end, + getenv = function() return nil end, +} +local real_require = require +local function fake_require(name) + if name == "luci.http" then return http_stub end + if name == "luci.util" then return luci_stub.util end + if name == "luci.i18n" then return luci_stub.i18n end + return real_require(name) +end + +local function render() + local f = assert(io.open(VIEW, "rb")) + local src = f:read("*a") + f:close() + local chunk, cerr = build_chunk(src) + if not chunk then error(cerr) end + local fn, lerr = loadstring(chunk, "subscriptions.htm") + if not fn then error(lerr) end + local buf = {} + local env = setmetatable({ + __substore_w = function(s) if s ~= nil then buf[#buf + 1] = tostring(s) end end, + require = fake_require, + luci = luci_stub, + token = "TESTTOKEN", -- LuCI 注入的 CSRF token + }, { __index = _G }) + setfenv(fn, env) + fn() + return table.concat(buf) +end + +local function count_of(html, pat) + local n = 0 + for _ in html:gmatch(pat) do n = n + 1 end + return n +end + +-- ---------- 造三条订阅 ---------- +local id1 = core.add("SubOne", "http://example.com/1.yaml", {}) +local id2 = core.add("SubTwo", "http://example.com/2.yaml", {}) +local id3 = core.add("SubThree", "http://example.com/3.yaml", {}) +check("fixtures created", id1 and id2 and id3 and id1 ~= id2 and id2 ~= id3) + +local html = render() +check("template renders", #html > 0) + +-- ---------- 1) 选择框列在「名称」之前,且每行一个 ---------- +local pos_toggle = html:find("substore_toggle_all(this)", 1, true) +local pos_name_th = html:find("", 1, true) +check("header select-all checkbox exists", pos_toggle ~= nil) +check("Name column header exists", pos_name_th ~= nil) +check("select-all sits before the Name column", + pos_toggle ~= nil and pos_name_th ~= nil and pos_toggle < pos_name_th) + +-- 表头那个选择框必须在**第一行数据之前** —— 否则它会落到某一行的单元格里 +local pos_first_row = html:find('class="cbi-section-table-row"', 1, true) +check("select-all is inside the header row", + pos_toggle ~= nil and pos_first_row ~= nil and pos_toggle < pos_first_row) + +-- 每条订阅一个独立选择框,value 是订阅 id +for _, id in ipairs({ id1, id2, id3 }) do + check("row checkbox for " .. id, + html:find('', 1, true) ~= nil) +end +check("one checkbox per subscription", count_of(html, 'class="sub%-check"') == 3) + +-- 选择框必须是该行的**第一个**单元格:出现在「名称」单元格之前 +local row_start = html:find('class="cbi-section-table-row"', 1, true) or 0 +local row_cb = html:find('class="sub-check"', row_start, true) or 0 +local row_name = html:find("
<%:Name%> <%:URL%> <%:Nodes%>
<% if it.combo then %> [<%:Combination%>] <%=luci.util.pcdata(it.name or "")%> @@ -91,7 +109,9 @@
@@ -103,7 +123,13 @@ onclick="var e=document.getElementById('link_<%=it.id%>');e.select();document.execCommand('copy');"><%:Copy%>
-
<%:Pick a format to generate the subscription link; you can switch formats at any time%>
+
<% + if fmt_disabled then + %><%:Update and parse nodes before choosing a format%><% + else + %><%:Pick a format to generate the subscription link; you can switch formats at any time%><% + end + %>
<% if it["local"] then %> @@ -137,7 +163,7 @@ if #items == 0 then %>
<%:No subscriptions yet.%><%:No subscriptions yet.%>
Name", row_cb + 1, true) or 0 +check("row checkbox is the first cell of the row", row_cb > row_start and row_name > row_cb) + +-- ---------- 2) 「删除」按钮在「添加组合订阅」之后 ---------- +local pos_combo = html:find("/admin/services/substore/combo", 1, true) +local pos_del_btn = html:find('onclick="substore_delete_selected()"', 1, true) +check("toolbar delete button exists", pos_del_btn ~= nil) +check("delete button comes after the Add-combination button", + pos_combo ~= nil and pos_del_btn ~= nil and pos_del_btn > pos_combo) + +-- 批量删除表单:提交到 delete 动作,带 token 与 id,且**不在表格里** +-- (每一行已经各有一个删除表单,HTML 不允许表单嵌套) +local form_pos = html:find('id="batch_delete_form"', 1, true) +local table_pos = html:find('class="cbi-section-table"', 1, true) +check("hidden batch form exists", form_pos ~= nil) +check("batch form is outside the table", form_pos ~= nil and table_pos ~= nil and form_pos < table_pos) +local form_html = form_pos and html:sub(form_pos, (html:find("", form_pos, true) or #html)) or "" +check("batch form posts to the delete action", + form_html:find('action="/cgi-bin/luci/admin/services/substore/delete"', 1, true) ~= nil) +check("batch form carries the CSRF token", + form_html:find('name="token" value="TESTTOKEN"', 1, true) ~= nil) +check("batch form carries the id field", form_html:find('name="id"', 1, true) ~= nil) + +-- ---------- 3) 一个都没勾选时不得删除 ---------- +-- 页面里的 JS 本机跑不了,只能对**渲染出来的脚本**做结构断言。 +local js_start = html:find("function substore_delete_selected()", 1, true) +check("delete JS is emitted", js_start ~= nil) +local js = js_start and html:sub(js_start, (html:find("", js_start, true) or #html)) or "" + +check("delete JS reads the checked boxes", js:find(".sub-check:checked", 1, true) ~= nil) +-- 关键:判空必须存在,且早于提交 —— 写反了就会「没勾选也提交」,即删空列表 +local pos_empty_guard = js:find("boxes.length === 0", 1, true) +local pos_submit = js:find("form.submit()", 1, true) +check("delete JS has an empty-selection guard", pos_empty_guard ~= nil) +check("empty-selection guard runs before submitting", + pos_empty_guard ~= nil and pos_submit ~= nil and pos_empty_guard < pos_submit) +-- 空选时只提示,不提交 +local guard = (pos_empty_guard and pos_submit) + and js:sub(pos_empty_guard, pos_submit) or "" +check("empty selection alerts instead of deleting", guard:find("alert(", 1, true) ~= nil) +check("empty selection does not submit", guard:find("form.submit", 1, true) == nil) + +-- 多个 id 以逗号拼接后写入表单(控制器按逗号切分) +check("delete JS joins ids with a comma", js:find("ids.join(',')", 1, true) ~= nil) +check("delete JS writes the joined ids into the form", + js:find("form.elements['id'].value", 1, true) ~= nil) + +-- 全选:表头选择框要把所有 .sub-check 都同步 +local all_start = html:find("function substore_toggle_all(", 1, true) +local all_js = all_start and html:sub(all_start, (html:find("", all_start, true) or #html)) or "" +check("toggle-all JS is emitted", all_start ~= nil) +check("toggle-all targets the row checkboxes", + all_js:find("querySelectorAll('.sub-check')", 1, true) ~= nil) +check("toggle-all copies the header state", + all_js:find("boxes[i].checked = el.checked", 1, true) ~= nil) + +-- ---------- 空列表:colspan 必须等于表头列数 ---------- +-- 少一列会让「暂无订阅」提示错位;多一列在部分浏览器上会撑出多余格。 +local header_cells = count_of(html, "]") +check("header has 8 columns", header_cells == 8) +for _, id in ipairs({ id1, id2, id3 }) do core.remove(id) end +local html_empty = render() +check("empty render still renders", #html_empty > 0) +local colspan = html_empty:match('colspan="(%d+)"') +check("empty-row colspan matches the header column count", + colspan ~= nil and tonumber(colspan) == header_cells) +check("empty state message still shown", + html_empty:find("No subscriptions yet.", 1, true) ~= nil) + +-- ---------- 反面对照:断言不是恒真 ---------- +-- 改回旧的 colspan 时上面的断言必须失败(证明它真的在检查) +check("control: colspan assertion is not vacuous", tonumber(colspan) ~= 7) + +os.execute("rm -rf " .. TMPDIR) +print("") +print(passed .. " passed, " .. failed .. " failed") +os.exit(failed == 0 and 0 or 1) diff --git a/luci-app-substore/tests/subscriptions_format_gate_test.lua b/luci-app-substore/tests/subscriptions_format_gate_test.lua new file mode 100644 index 000000000..b6c6aba54 --- /dev/null +++ b/luci-app-substore/tests/subscriptions_format_gate_test.lua @@ -0,0 +1,207 @@ +-- subscriptions_format_gate_test.lua — 订阅列表页「选择格式」下拉的启用条件 +-- 用法:lua5.1 tests/subscriptions_format_gate_test.lua +-- +-- 需求:刚添加、还没点「更新」的订阅(以及更新失败 / 解析结果为空的订阅), +-- node_count 为 0,此时「订阅链接转换」列的格式下拉必须是灰色不可选的 —— +-- 否则用户会选出一个必然为空的订阅链接,等于引导他生成无效内容。 +-- +-- .htm 需要 LuCI 运行时才能渲染,本机没有。这里的做法是把模板按 LuCI 的方式 +-- 重建成 Lua chunk(<% %> 里的代码原样,文本变成 __w(...)),用一套替身环境 +-- **真的渲染一遍**,再对生成的 HTML 断言。比「源码里有没有某个字符串」强得多: +-- 它同时验证了模板语法、分支条件与最终输出。 + +package.path = "./root/usr/share/?.lua;" .. package.path + +local core = require("substore.core") + +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 + +-- ---------- 数据目录改到 /tmp,绝不碰真实的 /etc/substore ---------- +-- LIST_FILE / NODES_DIR 是 require 时算好的常量,必须一并改掉。 +local TMPDIR = "/tmp/substore_fmtgate_" .. tostring(os.time()) .. "_" .. tostring(math.random(1e6)) +os.execute("rm -rf " .. TMPDIR) +core.DATA_DIR = TMPDIR +core.LIST_FILE = TMPDIR .. "/subscriptions.json" +core.NODES_DIR = TMPDIR .. "/nodes" +core.ensure_dirs() + +-- ---------- 模板 → 可执行 chunk ---------- +local VIEW = "root/usr/lib/lua/luci/view/substore/subscriptions.htm" + +-- 与 tests/template_escape_test.lua 的 build_chunk 同构,有两处不同: +-- 1) 首行从环境取 __w 的实现(那边是 `local __w = function() end`,只为过语法检查) +-- 2) **字面 HTML 也要 __w 出来**。那边只关心 <% %> 里的代码,字面文本全部丢弃; +-- 这里要断言渲染结果,丢了字面文本就等于丢了全部标签属性 —— 实测会漏掉 +-- `disabled="disabled"`(它是字面 HTML,不是表达式),断言恒假。 +local function build_chunk(src) + local out = { "local __w = __substore_w" } + local i, n = 1, #src + while i <= n do + local s = src:find("<%", i, true) + if not s then + out[#out + 1] = "__w(" .. string.format("%q", src:sub(i)) .. ")" + break + end + if s > i then + out[#out + 1] = "__w(" .. string.format("%q", src:sub(i, s - 1)) .. ")" + end + local e = src:find("%>", s + 2, true) + if not e then return nil, "unclosed <% at " .. s end + local tag = src:sub(s + 2, e - 1) + local kind = tag:sub(1, 1) + local body = tag:sub(2) + if kind == "=" then + out[#out + 1] = "__w(" .. body .. ")" + elseif kind == ":" then + out[#out + 1] = "__w(" .. string.format("%q", body) .. ")" + elseif kind == "+" then + out[#out + 1] = "__w()" -- header / footer 不参与断言 + else + out[#out + 1] = tag + end + out[#out + 1] = "\n" + i = e + 2 + end + return table.concat(out, "\n") +end + +-- ---------- LuCI 替身 ---------- +local luci_stub = { + util = { + pcdata = function(s) return tostring(s or "") end, + urlencode = function(s) return tostring(s or "") end, + }, + i18n = { translate = function(s) return tostring(s or "") end }, + dispatcher = { + context = { query = {} }, + build_url = function(...) + return "/cgi-bin/luci/" .. table.concat({ ... }, "/") + end, + }, +} +local http_stub = { + formvalue = function() return nil end, + getenv = function() return nil end, +} +local real_require = require +local function fake_require(name) + if name == "luci.http" then return http_stub end + if name == "luci.util" then return luci_stub.util end + if name == "luci.i18n" then return luci_stub.i18n end + return real_require(name) +end + +local function render() + local f = assert(io.open(VIEW, "rb")) + local src = f:read("*a") + f:close() + local chunk, cerr = build_chunk(src) + if not chunk then error(cerr) end + local fn, lerr = loadstring(chunk, "subscriptions.htm") + if not fn then error(lerr) end + local buf = {} + local env = setmetatable({ + __substore_w = function(s) if s ~= nil then buf[#buf + 1] = tostring(s) end end, + require = fake_require, + luci = luci_stub, + token = "TESTTOKEN", -- LuCI 注入的 CSRF token + }, { __index = _G }) + setfenv(fn, env) + fn() + return table.concat(buf) +end + +-- 取出某个订阅那一行的 开标签:select_tag_of 只取到开标签的 ">" 为止, +-- 选项在它之后,拿开标签找