mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-26 18:52:51 +08:00
🦄 Sync 2026-10-02 14:52:05
This commit is contained in:
1 parent
4c85793342
commit
0bfe84f1e8
133 files changed
+7983
-3225
No files matched your search
+2
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
both
|
||||
snapshot
|
||||
@@ -1 +1 @@
|
||||
26.273.63965~32775cf
|
||||
26.274.67354~aa3d488
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
.DS_Store
|
||||
/homeproxy-pro
|
||||
/docs/
|
||||
/demo/
|
||||
__pycache__/
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:= \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%>',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 最后一批,九项分布在界面、探测、转换与文件权限四处。共同点是**问题都不报错**:
|
||||
|
||||
@@ -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
@@ -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
@@ -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),同机任何用户都能读到,因此写入后显式收紧。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`、
|
||||
失败恢复后能正常写入。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 节安全设计。
|
||||
|
||||
|
||||
@@ -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,被拒绝
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
Reference in new issue
Block a user