🦄 Sync 2026-10-02 14:52:05

This commit is contained in:
github-actions[bot] committed 2026-10-02 14:52:05 +08:00
1 parent 4c85793342
commit 0bfe84f1e8
133 files changed
+7983 -3225

No files matched your search

+2 -2
View File
@@ -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)?
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
}
+1
View File
@@ -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
+2 -2
View File
@@ -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))
+1 -2
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
both
snapshot
@@ -1 +1 @@
26.273.63965~32775cf
26.274.67354~aa3d488
+2 -2
View File
@@ -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
+1
View File
@@ -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) |
@@ -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'],
@@ -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;
},
@@ -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,
@@ -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() {
+151 -151
View File
@@ -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"
+151 -151
View File
@@ -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"
+160 -160
View File
@@ -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 ""
+151 -151
View File
@@ -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"
@@ -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
+1
View File
@@ -1,4 +1,5 @@
.DS_Store
/homeproxy-pro
/docs/
/demo/
__pycache__/
+2 -2
View File
@@ -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
+2 -6
View File
@@ -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<NN>-preflight-<DATE>/`,保留上一发版 .apk 24h;`killall -HUP rpcd` 由 apk scripts 自动做 | `runtime/{config,service}.sh` |
**一句话总结**:pro 的核心价值是**把"单文件能跑"变成"orchestrator + table-driven adapter + 可独立测试的模块"**,并把约束、质量、回滚三件事从靠人盯变成靠代码执行(arch-guard 135 checks 静态锁住跨文件不变量)。
@@ -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;
}
});
})
@@ -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 ""
"<code>kmod-tun</code>"
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 ""
@@ -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 支持,你需要安装 <code>ip-full</code> 和 <code>kmod-tun</code>。"
#: 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 服务器"
@@ -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 %}
@@ -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') {
@@ -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,
@@ -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'
});
@@ -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))
@@ -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.
@@ -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)."
@@ -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
};
}
},
+207
View File
@@ -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
@@ -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
@@ -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:-<no rule>})"
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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 时服务会拒绝启动并记录明确日志
@@ -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))
@@ -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'
@@ -284,8 +284,7 @@
"action": "hijack-dns"
},
{
"action": "sniff",
"timeout": "300ms"
"action": "sniff"
},
{
"action": "resolve",
@@ -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
+11 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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:= \
+8 -17
View File
@@ -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
}
@@ -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
@@ -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
@@ -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
+3 -3
View File
@@ -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 \
@@ -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%>',
+2 -3
View File
@@ -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
+3 -5
View File
@@ -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 ""
@@ -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
}
+580
View File
@@ -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/<combo_id>.json`,只有 `combo_refresh` 会重写它;
- 会触发重算的路径只有 `M.sync`(源更新时)与三个节点级控制器入口
(`action_node_save` / `action_node_delete` / `action_node_set_group`),
**删除订阅这条路径从未调用过**;
- 结果:删掉源订阅后,组合的下载链接
(`/substore/download?token=<combo>&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 <DATA_DIR>/.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 最后一批,九项分布在界面、探测、转换与文件权限四处。共同点是**问题都不报错**:
+6 -2
View File
@@ -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
+130 -118
View File
@@ -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=<token>&target=<format>` that Passwall / OpenClash and
other clients can pull directly
- A random per-subscription token backs the public download endpoint
`/substore/download?token=<token>&target=<format>`, 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
+92 -82
View File
@@ -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=<token>&target=<format>`,Passwall / OpenClash 等客户端可直接拉取
`/substore/download?token=<token>&target=<format>`,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),同机任何用户都能读到,因此写入后显式收紧。
+5 -3
View File
@@ -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=<token>&target=<format>`(如 `target=ClashMeta`),无需登录态、靠不可猜测的 token 鉴权,
按 `target` 实时转换节点并返回(`node_converter.lua` 负责协议间转换,`output.*` 负责格式生成)。
按 `target` 实时转换节点并返回(由 `output.*` 按目标格式生成)。
> **历史说明**:早期设计里 `node_converter.lua` / `converter.lua` 负责「任意协议 →
> 任意协议」的转换。实际实现中该转换入口从未接入(`output.*` 各模块直接消费统一
> 节点模型),两个模块只被彼此与单元测试引用,已于 `[2.6.11-r1]` 作为死代码删除。
节点统一内部模型(协议无关):
```lua
+7 -3
View File
@@ -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:
+392 -30
View File
@@ -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 <DATA_DIR>/.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`、
失败恢复后能正常写入。
---
+3 -1
View File
@@ -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
+78 -6
View File
@@ -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 <ua>` / `-U <ua>` 进入 shell 命令 ——
`util.shq` 的单引号挡得住注入,但挡不住 UA 里的**换行**:curl 会把它当成额外的
请求头拼进去(header injection),日志里也会被换行截断、伪造出额外行。
与代理同理,非法 UA **明确失败而非静默忽略**(§12):静默忽略会让用户以为 UA
已生效,实际拿到的仍是占位节点
- 探测目标主机名拒绝以 `-` 开头:`probe.lua` 的命令形如 `ping -c 1 -W 2 <host>`,
而 `--help` / `-c` 会被 busybox 的 getopt 当成**选项**。`util.shq` 的单引号由
shell 剥掉,getopt 看到的仍是 `-x`,引号挡不住这一层,必须在取值时就拒绝
@@ -44,9 +100,25 @@
script data escaped 状态,其后的 `</script>` 不再结束脚本块,整页被吞进脚本
## 安全测试
- 已验证 SSRF 私网拒绝
- Base64/JSON 解析异常处理
- 超大响应体截断测试
均为自包含 Lua 5.1 测试,`lua5.1 tests/<file>.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 节安全设计。
+27 -1
View File
@@ -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,被拒绝
+13 -2
View File
@@ -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 "类型"
@@ -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
@@ -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 @@
<input type="text" id="proxy" name="proxy" value="<%=luci.util.pcdata(is_edit and it.proxy or "")%>" style="width:60%"/>
<div style="color:#808080;margin-top:0.25em">通过代理下载订阅(留空则不使用),支持 http:// 或 socks5:// 地址,如 socks5://192.168.5.1:1080</div>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label for="ua_preset" style="display:inline-block;min-width:10em">订阅客户端类型</label>
<select id="ua_preset" name="ua_preset" onchange="var r=document.getElementById('ua_custom_row');if(r)r.style.display=this.value=='custom'?'block':'none';">
<option value="" <%=ua_preset == "" and "selected" or ""%>>默认(不设置)</option>
<% for _, p in ipairs(core.UA_PRESETS) do %>
<option value="<%=p.key%>" <%=ua_preset == p.key and "selected" or ""%>><%=p.label%></option>
<% end %>
<option value="custom" <%=ua_preset == "custom" and "selected" or ""%>>自定义</option>
</select>
<div style="color:#808080;margin-top:0.25em">
部分机场按客户端类型下发节点:同一链接只有选对类型才解析得出真实节点,选错会得到「与您使用客户端不兼容」的占位节点。不确定就保持「默认」。<br/>
「自定义」用于预设里没有的客户端:填<b>要原样发送的 User-Agent 请求头</b>,格式与预设里的值同形,即
<code>客户端名/版本号</code>,例如 <code>clash-verge/v2.5.0</code>、<code>v2rayN/7.22.0</code>、
<code>mihomo.party/v2.0.0 (clash.meta)</code>。<br/>
可以含空格、括号、分号、下划线等可见字符(整串原样发送,不解析、不改写);不能含换行等控制字符,
最长 256 字符。版本号填<b>机场要求的最低可用版本</b>,具体填什么以机场或客户端说明为准。
选「自定义」但留空 = 不发送 User-Agent(等同「默认」)。
</div>
</div>
<div id="ua_custom_row" class="cbi-section-descr" style="margin:0.5em 0;display:<%=ua_preset == "custom" and "block" or "none"%>">
<label for="ua_custom" style="display:inline-block;min-width:10em">自定义 User-Agent</label>
<input type="text" id="ua_custom" name="ua_custom" value="<%=luci.util.pcdata(ua_custom)%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label style="display:inline-block;min-width:10em">启用定时更新</label>
<input type="checkbox" name="cron_enable" value="1" <%=cron_enabled and "checked" or ""%>
@@ -55,7 +55,7 @@
<div id="text_area_wrap" style="display:<%=local_mode=="text" and "block" or "none"%>">
<textarea id="content_text" name="content" rows="16" style="width:100%"
<%=local_mode=="text" and "" or "disabled"%>><%=luci.util.pcdata(content)%></textarea>
<div style="color:#808080;margin-top:0.25em"><%:Paste YAML/URI/JSON node list here. Mixed protocols supported.%></div>
<div style="color:#808080;margin-top:0.25em"><%:Paste a node list here — one format at a time (URI / Base64 / YAML / JSON / WireGuard .conf); protocols may be mixed within that format.%></div>
</div>
<div id="form_nodes_container" style="display:<%=local_mode=="form" and "block" or "none"%>">
<div style="margin-bottom:0.5em;color:#808080"><%:Add nodes via form below. Each node will be saved as JSON and parsed on save.%></div>
@@ -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",
@@ -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",
@@ -22,10 +22,20 @@
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/combo')%>">
<%:Add combination%>
</a>
<!-- 勾选批量删除。与节点页同一套交互:没勾选时点击不做任何删除(只提示)。 -->
<button type="button" class="cbi-button cbi-button-reset" onclick="substore_delete_selected()"><%:Delete%></button>
</div>
<!-- 批量删除用的隐藏表单:JS 把选中订阅的 id 收集到 id(逗号分隔)后提交。
必须放在表格**外面** —— 每一行里已经各有一个删除表单,HTML 不允许表单嵌套。 -->
<form id="batch_delete_form" method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/delete')%>" style="display:none">
<input type="hidden" name="token" value="<%=token%>"/>
<input type="hidden" name="id" value=""/>
</form>
<table class="cbi-section-table">
<tr class="cbi-section-table-titles">
<th style="width:1em"><input type="checkbox" onclick="substore_toggle_all(this)"/></th>
<th><%:Name%></th>
<th><%:URL%></th>
<th><%:Nodes%></th>
@@ -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
%>
<tr class="cbi-section-table-row">
<td><input type="checkbox" class="sub-check" value="<%=luci.util.pcdata(it.id)%>"/></td>
<td>
<% if it.combo then %>
<span class="cbi-value-description" style="color:#0066aa">[<%:Combination%>]</span> <%=luci.util.pcdata(it.name or "")%>
@@ -91,7 +109,9 @@
<td>
<div style="display:flex;gap:0.25em;align-items:center">
<select id="fmt_<%=it.id%>" data-base="<%=luci.util.pcdata(sub_link_base)%>"
onchange="substore_setlink(this, '<%=it.id%>')">
onchange="substore_setlink(this, '<%=it.id%>')"<%
if fmt_disabled then %> disabled="disabled" title="<%:Update and parse nodes before choosing a format%>"
style="opacity:0.5;cursor:not-allowed"<% end %>>
<option value=""><%:Select format%></option>
<% for _, fo in ipairs(output.FORMAT_OPTIONS) do %><option value="<%=fo[1]%>"><%=fo[2]%></option><% end %>
</select>
@@ -103,7 +123,13 @@
onclick="var e=document.getElementById('link_<%=it.id%>');e.select();document.execCommand('copy');"><%:Copy%></button>
</div>
</div>
<div class="cbi-value-description"><%:Pick a format to generate the subscription link; you can switch formats at any time%></div>
<div class="cbi-value-description"><%
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
%></div>
</td>
<td>
<% if it["local"] then %>
@@ -137,7 +163,7 @@
if #items == 0 then
%>
<tr class="cbi-section-table-row">
<td colspan="7" style="text-align:center;color:#808080"><%:No subscriptions yet.%></td>
<td colspan="8" style="text-align:center;color:#808080"><%:No subscriptions yet.%></td>
</tr>
<%
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();
}
</script>
<%+footer%>
@@ -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" ]
}
}
}
@@ -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" ]
},
@@ -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
@@ -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/<os>
-- 版本号取用户给出的**最低可用版本**;机场若提高门槛,用户可在「自定义」里改。
-- 顺序即表单下拉的显示顺序,不要用 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/<combo>.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
@@ -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 <ua>` 的形式进入 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
@@ -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)。
@@ -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
@@ -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
@@ -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
@@ -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]"
@@ -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 } 对象
@@ -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
@@ -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" } }
@@ -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
@@ -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
@@ -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
@@ -194,6 +194,64 @@ function M.split_hostport(s)
end
end
-- 解析 SIP003 插件串(shadowsocks 的 `plugin` 字段)。
--
-- 格式出自 SIP002 §"For plugin argument",与 SIP003 的 SS_PLUGIN_OPTIONS 同构:
-- <name>[;<key>=<value>]... 例: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)
+83
View File
@@ -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)
@@ -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))
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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/<combo>.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))
@@ -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))
-- ---------- 结果 ----------
@@ -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)
+173
View File
@@ -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)
@@ -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)
@@ -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)
@@ -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)
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more