⛅ Sync 2026-09-16 03:46:54
Merge-upstream / merge (push) Canceled after 0s

This commit is contained in:
github-actions[bot] committed 2026-09-16 03:46:54 +08:00
1 parent c13653dc17
commit 77ae924426
29 files changed
+1215 -429

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
PKG_VERSION:=0.1.41 PKG_VERSION:=0.1.41
PKG_RELEASE:=16 PKG_RELEASE:=17
# Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI). # Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI).
ifdef SOURCE_DATE_EPOCH ifdef SOURCE_DATE_EPOCH
@@ -11,5 +11,5 @@
* GENERATED — do not edit. Edit fwlive.css and run: node scripts/embed-fwlive-css.js * GENERATED — do not edit. Edit fwlive.css and run: node scripts/embed-fwlive-css.js
*/ */
return baseclass.extend({ return baseclass.extend({
styleText: "\n.fwlive-map {\n\tmax-width: none;\n\twidth: 100%;\n\t--fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546));\n\t--fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c));\n\t--fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9));\n\t--fwlive-paused-color: #b45309;\n}\n/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */\n#maincontent.container:has(.fwlive-map[data-view=\"detailed\"]) {\n\tmax-width: none;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.fwlive-map[data-row-tint=\"accessible\"] {\n\t--fwlive-pass-color: #0d9488;\n\t--fwlive-deny-color: #c2410c;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row,\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tbackground: rgba(180, 83, 9, 0.08);\n\tbackground: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent);\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row {\n\tmargin: 0 -8px;\n\tpadding: 6px 8px 0;\n\tborder-radius: 3px 3px 0 0;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tmargin: 0 -8px 10px;\n\tpadding: 8px;\n\tborder-radius: 0 0 3px 3px;\n\tborder-bottom-color: transparent;\n}\n.fwlive-title-row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 8px 16px;\n\tmargin: 0 0 4px;\n}\n.fwlive-title-row h2 {\n\tmargin: 0;\n\tflex: 0 1 auto;\n}\n.fwlive-title-status {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 8px;\n\tflex-wrap: wrap;\n\tmin-width: 0;\n\tmargin-left: auto;\n}\n.fwlive-watch-strip {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: stretch;\n\tjustify-content: flex-start;\n\tgap: 0;\n\tmin-height: 40px;\n\tpadding: 8px 0 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n\tmargin-bottom: 10px;\n}\n.fwlive-watch-group {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmin-height: 28px;\n\tpadding: 0 10px;\n\tborder-left: 1px solid var(--border-color-medium);\n\tflex-wrap: wrap;\n}\n.fwlive-watch-group:last-child {\n\tpadding-right: 0;\n}\n.fwlive-watch-group-label {\n\tmargin-right: 2px;\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.72em;\n\tfont-weight: 700;\n\tletter-spacing: 0.08em;\n\ttext-transform: uppercase;\n\twhite-space: nowrap;\n}\n.fwlive-watch-seg {\n\tdisplay: inline-flex;\n\talign-items: stretch;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\toverflow: hidden;\n}\n.fwlive-watch-seg .fwlive-seg-btn {\n\tmargin: 0;\n\tborder: 0;\n\tborder-radius: 0;\n\tborder-right: 1px solid var(--border-color-medium);\n\tbackground: transparent;\n\tpadding: 4px 10px;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n}\n.fwlive-watch-seg .fwlive-seg-btn:last-child {\n\tborder-right: 0;\n}\n.fwlive-watch-seg .fwlive-seg-btn[aria-pressed=\"true\"] {\n\tbackground: rgba(26, 111, 156, 0.12);\n\tbackground: color-mix(in srgb, var(--primary-color-high) 12%, transparent);\n\tcolor: var(--primary-color-high);\n\tfont-weight: 600;\n}\n.fwlive-watch-label { font-weight: 600; }\n.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-dot {\n\twidth: 8px;\n\theight: 8px;\n\tborder-radius: 50%;\n\tbackground: var(--text-color-low, var(--border-color-high));\n\tdisplay: inline-block;\n\tflex-shrink: 0;\n}\n.fwlive-dot.fwlive-dot-on {\n\tbackground: var(--fwlive-pass-color);\n\tbox-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent);\n}\n.fwlive-btn-ghost {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n}\n.fwlive-btn-quiet {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n\tcolor: var(--text-color-medium);\n\tfont-weight: 500;\n}\n.fwlive-log-merged {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 4px 10px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-medium, var(--white-color-low, var(--background-color-high)));\n\tcolor: var(--text-color-high);\n\tfont-weight: 500;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n\tcursor: pointer;\n}\n.fwlive-log-merged:hover {\n\tbackground: var(--background-color-low, var(--background-color-medium));\n}\n.fwlive-log-merged:disabled {\n\topacity: 0.65;\n\tcursor: default;\n}\n.fwlive-log-on-dot {\n\twidth: 6px;\n\theight: 6px;\n\tborder-radius: 50%;\n\tbackground: var(--fwlive-pass-color);\n\tflex-shrink: 0;\n}\n.fwlive-log-rate {\n\tcolor: var(--text-color-medium);\n\tfont-weight: 400;\n}\n.fwlive-display-bar {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmargin: 0 0 12px;\n\tpadding: 8px 12px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n}\n.fwlive-display-bar-label {\n\tflex: 0 0 auto;\n\tfoLine truncated styleText: "\n.fwlive-map {\n\tmax-width: none;\n\twidth: 100%;\n\t--fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546));\n\t--fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c));\n\t--fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9));\n\t--fwlive-paused-color: #b45309;\n}\n/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */\n#maincontent.container:has(.fwlive-map[data-view=\"detailed\"]) {\n\tmax-width: none;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.fwlive-map[data-row-tint=\"accessible\"] {\n\t--fwlive-pass-color: #0d9488;\n\t--fwlive-deny-color: #c2410c;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row,\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tbackground: rgba(180, 83, 9, 0.08);\n\tbackground: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent);\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row {\n\tmargin: 0 -8px;\n\tpadding: 6px 8px 0;\n\tborder-radius: 3px 3px 0 0;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tmargin: 0 -8px 10px;\n\tpadding: 8px;\n\tborder-radius: 0 0 3px 3px;\n\tborder-bottom-color: transparent;\n}\n.fwlive-title-row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 8px 16px;\n\tmargin: 0 0 4px;\n}\n.fwlive-title-row h2 {\n\tmargin: 0;\n\tflex: 0 1 auto;\n}\n.fwlive-title-status {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 8px;\n\tflex-wrap: wrap;\n\tmin-width: 0;\n\tmargin-left: auto;\n}\n.fwlive-watch-strip {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: stretch;\n\tjustify-content: flex-start;\n\tgap: 0;\n\tmin-height: 40px;\n\tpadding: 8px 0 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n\tmargin-bottom: 10px;\n}\n.fwlive-watch-group {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmin-height: 28px;\n\tpadding: 0 10px;\n\tborder-left: 1px solid var(--border-color-medium);\n\tflex-wrap: wrap;\n}\n.fwlive-watch-group:last-child {\n\tpadding-right: 0;\n}\n.fwlive-watch-group-label {\n\tmargin-right: 2px;\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.72em;\n\tfont-weight: 700;\n\tletter-spacing: 0.08em;\n\ttext-transform: uppercase;\n\twhite-space: nowrap;\n}\n.fwlive-watch-seg {\n\tdisplay: inline-flex;\n\talign-items: stretch;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\toverflow: hidden;\n}\n.fwlive-watch-seg .fwlive-seg-btn {\n\tmargin: 0;\n\tborder: 0;\n\tborder-radius: 0;\n\tborder-right: 1px solid var(--border-color-medium);\n\tbackground: transparent;\n\tpadding: 4px 10px;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n}\n.fwlive-watch-seg .fwlive-seg-btn:last-child {\n\tborder-right: 0;\n}\n.fwlive-watch-seg .fwlive-seg-btn[aria-pressed=\"true\"] {\n\tbackground: rgba(26, 111, 156, 0.12);\n\tbackground: color-mix(in srgb, var(--primary-color-high) 12%, transparent);\n\tcolor: var(--primary-color-high);\n\tfont-weight: 600;\n}\n.fwlive-watch-label { font-weight: 600; }\n.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-dot {\n\twidth: 8px;\n\theight: 8px;\n\tborder-radius: 50%;\n\tbackground: var(--text-color-low, var(--border-color-high));\n\tdisplay: inline-block;\n\tflex-shrink: 0;\n}\n.fwlive-dot.fwlive-dot-on {\n\tbackground: var(--fwlive-pass-color);\n\tbox-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent);\n}\n.fwlive-btn-ghost {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n}\n.fwlive-btn-quiet {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n\tcolor: var(--text-color-medium);\n\tfont-weight: 500;\n}\n.fwlive-log-merged {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 4px 10px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-medium, var(--white-color-low, var(--background-color-high)));\n\tcolor: var(--text-color-high);\n\tfont-weight: 500;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n\tcursor: pointer;\n}\n.fwlive-log-merged:hover {\n\tbackground: var(--background-color-low, var(--background-color-medium));\n}\n.fwlive-log-merged:disabled {\n\topacity: 0.65;\n\tcursor: default;\n}\n.fwlive-log-on-dot {\n\twidth: 6px;\n\theight: 6px;\n\tborder-radius: 50%;\n\tbackground: var(--fwlive-pass-color);\n\tflex-shrink: 0;\n}\n.fwlive-log-rate {\n\tcolor: var(--text-color-medium);\n\tfont-weight: 400;\n}\n.fwlive-display-bar {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmargin: 0 0 12px;\n\tpadding: 8px 12px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n}\n.fwlive-display-bar-label {\n\tflex: 0 0 auto;\n\tfoLine truncated
}); });
@@ -670,6 +670,25 @@
color: var(--text-color-high); color: var(--text-color-high);
font-size: 0.92em; font-size: 0.92em;
} }
.fwlive-summary {
margin: 0 0 10px;
padding: 8px 12px;
background: var(--background-color-low);
border: 1px solid var(--border-color-medium);
border-radius: 3px;
color: var(--text-color-high);
}
.fwlive-summary-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.fwlive-summary-body {
margin: 8px 0 0;
font: inherit;
white-space: pre-wrap;
}
.fwlive-tint-warn { .fwlive-tint-warn {
display: none; display: none;
color: var(--warn-color-high); color: var(--warn-color-high);
@@ -14,7 +14,7 @@
* callbacks - {} (unused; present for API consistency) * callbacks - {} (unused; present for API consistency)
* *
* renderRows(host, state, callbacks) → void * renderRows(host, state, callbacks) → void
* host - <tbody> element (cleared and rebuilt; element itself is kept) * host - <tbody> element (kept; normal polls reuse keyed rows)
* state - shallow copy: { rows, columns, viewMode, messageLayout, * state - shallow copy: { rows, columns, viewMode, messageLayout,
* expandedRowId, rowTint, showHostnames, * expandedRowId, rowTint, showHostnames,
* hostnameCache, firewallBackend } * hostnameCache, firewallBackend }
@@ -24,8 +24,9 @@
* Internals (not a second public contract): columnLabel, columnCellClass, * Internals (not a second public contract): columnLabel, columnCellClass,
* flowCell, buildColumnCell. * flowCell, buildColumnCell.
* *
* Modules must not mutate state. host contents are cleared then rebuilt * Modules must not mutate state. Forced renders clear and rebuild the host;
* (idempotent replace). Does not touch #fwlive-scroll or #fwlive-empty. * normal polls reuse unchanged keyed rows and only build changed rows. Does
* not touch #fwlive-scroll or #fwlive-empty.
*/ */
function columnLabel(col) { function columnLabel(col) {
@@ -255,49 +256,146 @@ function renderThead(host, state, _callbacks) {
} }
} }
function rowRenderKey(row, state, columns) {
return JSON.stringify([
row.id,
row.timestamp,
row.action,
row.rule_hint,
row.rule_label,
row.interface_in,
row.interface_out,
row.direction,
row.proto,
row.src,
row.sport,
row.dst,
row.dport,
row.flags,
row.length,
row.message,
columns,
state.viewMode,
state.messageLayout,
state.expandedRowId === row.id,
!!state.rowTint,
!!state.showHostnames,
state.firewallBackend
]);
}
function rowClass(index, row, state, callbacks) {
return [
index % 2 ? 'fwlive-row-alt' : '',
state.viewMode === 'simple' ? 'fwlive-row-clickable' : '',
state.expandedRowId === row.id ? 'fwlive-row-expanded' : '',
state.rowTint ? callbacks.actionRowTintClass(row.action) : ''
]
.filter(Boolean)
.join(' ');
}
function buildRow(row, index, state, columns, callbacks) {
const cells = [];
for (let c = 0; c < columns.length; c++)
cells.push(buildColumnCell(columns[c], row, state, callbacks));
const tr = E(
'tr',
{
'class': rowClass(index, row, state, callbacks),
'click': state.viewMode === 'simple' ? (ev) => callbacks.onRowClick(row.id, ev) : null
},
cells
);
tr._fwliveRowId = String(row.id);
tr._fwliveRowKey = rowRenderKey(row, state, columns);
return tr;
}
function buildExpansionRow(row, state, columns) {
const expansion = E('tr', { 'class': 'fwlive-msg-expand' }, [
E('td', { 'colspan': String(columns.length) }, [
E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]),
E('pre', { 'class': 'fwlive-msg-expand-body' }, [
log.formatMessageDisplay(row.message, 'wrap') || '—'
])
])
]);
expansion._fwliveExpansionFor = String(row.id);
expansion._fwliveExpansionKey = rowRenderKey(row, state, columns);
return expansion;
}
function renderAllRows(host, rows, state, columns, callbacks) {
host.innerHTML = '';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
host.appendChild(buildRow(row, i, state, columns, callbacks));
if (state.viewMode === 'simple' && state.expandedRowId === row.id)
host.appendChild(buildExpansionRow(row, state, columns));
}
}
function canReuseRows(host) {
return (
host &&
host.childNodes &&
typeof host.childNodes.length === 'number' &&
typeof host.insertBefore === 'function' &&
typeof host.removeChild === 'function'
);
}
function renderRows(host, state, callbacks) { function renderRows(host, state, callbacks) {
const rows = state.rows || []; const rows = state.rows || [];
const columns = state.columns || []; const columns = state.columns || [];
host.innerHTML = ''; if (state.forceRender || !canReuseRows(host)) {
renderAllRows(host, rows, state, columns, callbacks);
return;
}
const existingRows = new Map();
const existingExpansions = new Map();
const oldChildren = Array.prototype.slice.call(host.childNodes);
for (let i = 0; i < oldChildren.length; i++) {
const child = oldChildren[i];
if (child._fwliveRowId != null) existingRows.set(child._fwliveRowId, child);
else if (child._fwliveExpansionFor != null)
existingExpansions.set(child._fwliveExpansionFor, child);
}
const desired = [];
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i]; const row = rows[i];
const rowClass = [ const id = String(row.id);
i % 2 ? 'fwlive-row-alt' : '', const key = rowRenderKey(row, state, columns);
state.viewMode === 'simple' ? 'fwlive-row-clickable' : '', let tr = existingRows.get(id);
state.expandedRowId === r.id ? 'fwlive-row-expanded' : '', if (!tr || tr._fwliveRowKey !== key) tr = buildRow(row, i, state, columns, callbacks);
state.rowTint ? callbacks.actionRowTintClass(r.action) : '' else tr.setAttribute('class', rowClass(i, row, state, callbacks));
] tr._fwliveRowId = id;
.filter(Boolean) tr._fwliveRowKey = key;
.join(' '); desired.push(tr);
const cells = [];
for (let c = 0; c < columns.length; c++)
cells.push(buildColumnCell(columns[c], r, state, callbacks));
const tr = E( if (state.viewMode === 'simple' && state.expandedRowId === row.id) {
'tr', let expansion = existingExpansions.get(id);
{ if (!expansion || expansion._fwliveExpansionKey !== key)
'class': rowClass, expansion = buildExpansionRow(row, state, columns);
'click': state.viewMode === 'simple' ? (ev) => callbacks.onRowClick(r.id, ev) : null desired.push(expansion);
},
cells
);
host.appendChild(tr);
if (state.viewMode === 'simple' && state.expandedRowId === r.id) {
host.appendChild(
E('tr', { 'class': 'fwlive-msg-expand' }, [
E('td', { 'colspan': String(columns.length) }, [
E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]),
E('pre', { 'class': 'fwlive-msg-expand-body' }, [
log.formatMessageDisplay(r.message, 'wrap') || '—'
])
])
])
);
} }
} }
for (let i = 0; i < desired.length; i++) {
const current = host.childNodes[i] || null;
if (current !== desired[i]) host.insertBefore(desired[i], current);
}
const used = new Set(desired);
const currentChildren = Array.prototype.slice.call(host.childNodes);
for (let i = 0; i < currentChildren.length; i++) {
if (!used.has(currentChildren[i])) host.removeChild(currentChildren[i]);
}
} }
return baseclass.extend({ return baseclass.extend({
@@ -102,6 +102,12 @@ return view.extend({
resumeMerge: false, resumeMerge: false,
pollFn: null, pollFn: null,
pollDataInFlight: false, pollDataInFlight: false,
/* Every refresh trigger goes through this coordinator. A request made
* while one is active is coalesced into one follow-up, never overlapped. */
pollRequestPromise: null,
pollRequestQueued: false,
pollRequestWaiters: [],
pollRequestQueuedWaiters: [],
/* Layer 2 — visibility / RTT cadence / shed surfacing. */ /* Layer 2 — visibility / RTT cadence / shed surfacing. */
pollEpoch: 0, pollEpoch: 0,
pollCadenceSec: 1, pollCadenceSec: 1,
@@ -111,6 +117,10 @@ return view.extend({
serverTruncated: 0, serverTruncated: 0,
serverShed: null, serverShed: null,
degradedSampling: false, degradedSampling: false,
/* Layer 2 summary fallback — rows remain available behind an explicit toggle. */
summaryMode: false,
summaryRowsShown: false,
summaryData: null,
resolveLoadShed: false, resolveLoadShed: false,
visibilityBound: false, visibilityBound: false,
renderRaf: 0, renderRaf: 0,
@@ -769,6 +779,14 @@ return view.extend({
this.notePollRtt(rtt, errored); this.notePollRtt(rtt, errored);
this.updateAdaptiveBanner(); this.updateAdaptiveBanner();
if (this.clientBackoffEnabled() && rtt > constants.POLL_RTT_SLOW_MS) {
if (!this.summaryMode) this.enterSummaryMode(reply.summary);
else {
this.summaryData =
reply.summary && typeof reply.summary === 'object' ? reply.summary : null;
this.renderSummary();
}
}
const batch = this.normalizePollBatch(raw); const batch = this.normalizePollBatch(raw);
this.lastPollNewEvents = batch.pollNew; this.lastPollNewEvents = batch.pollNew;
@@ -884,6 +902,7 @@ return view.extend({
notePollRtt(ms, errored) { notePollRtt(ms, errored) {
if (!this.clientBackoffEnabled()) { if (!this.clientBackoffEnabled()) {
if (this.summaryMode) this.leaveSummaryMode();
this.degradedSampling = false; this.degradedSampling = false;
/* Drop any partial streak so a slow sample from before the /* Drop any partial streak so a slow sample from before the
* adaptive:0 window cannot trip degraded on re-enable. */ * adaptive:0 window cannot trip degraded on re-enable. */
@@ -905,6 +924,7 @@ return view.extend({
const cadence = this.cadenceForKind(kind); const cadence = this.cadenceForKind(kind);
this.degradedSampling = cadence === constants.POLL_CADENCE_SLOW_S; this.degradedSampling = cadence === constants.POLL_CADENCE_SLOW_S;
if (cadence !== this.pollCadenceSec) this.setPollCadence(cadence); if (cadence !== this.pollCadenceSec) this.setPollCadence(cadence);
if (kind === 'fast' && this.summaryMode) this.leaveSummaryMode();
}, },
stopPollingForHidden() { stopPollingForHidden() {
@@ -919,11 +939,12 @@ return view.extend({
resumePollingAfterVisible() { resumePollingAfterVisible() {
this.bumpPollEpoch(); this.bumpPollEpoch();
this.resetRttHistory(); this.resetRttHistory();
/* In-flight poll from the hidden epoch must not apply after catch-up. */
this.pollDataInFlight = false;
if (!this.pollFn) this.pollFn = this.pollData.bind(this); if (!this.pollFn) this.pollFn = this.pollData.bind(this);
this.setPollCadence(this.pollCadenceSec || constants.POLL_CADENCE_FAST_S); this.setPollCadence(this.pollCadenceSec || constants.POLL_CADENCE_FAST_S);
this.pollData(); /* An old hidden-epoch request may still be on the wire. Queue the
* catch-up behind it; stale-reply checks discard its application, while
* the coordinator prevents a second server request from overlapping. */
this.requestPoll();
}, },
onVisibilityChange() { onVisibilityChange() {
@@ -992,6 +1013,93 @@ return view.extend({
} }
}, },
summaryListText(label, values) {
const lines = [label + ':'];
if (!Array.isArray(values) || !values.length) {
lines.push(_('none'));
return lines;
}
for (let i = 0; i < values.length; i++) {
const item = values[i];
if (!item || typeof item.value !== 'string') continue;
const count = Number.isFinite(Number(item.count)) ? Number(item.count) : 0;
lines.push('%s — %d'.format(item.value, count));
}
if (lines.length === 1) lines.push(_('none'));
return lines;
},
renderSummary() {
const card = document.getElementById('fwlive-summary');
const body = document.getElementById('fwlive-summary-body');
if (!card || !body) return;
const summary = this.summaryData;
const lines = [_('Showing a compact summary of the top of the shown sample.')];
if (!summary || typeof summary !== 'object') {
lines.push(_('Summary data is unavailable; show rows to inspect the sample.'));
} else if (summary.truncated) {
lines.push(_('Summary was bounded before all categories could be included.'));
} else {
this.summaryListText(_('Top talkers'), summary.top_talkers).forEach((line) =>
lines.push(line)
);
this.summaryListText(_('Top drops'), summary.top_drops).forEach((line) =>
lines.push(line)
);
this.summaryListText(_('Top rules'), summary.top_rules).forEach((line) =>
lines.push(line)
);
}
body.textContent = lines.join('\n');
this.updateSummaryUi();
},
updateSummaryUi() {
const card = document.getElementById('fwlive-summary');
const scroll = document.getElementById('fwlive-scroll');
const empty = document.getElementById('fwlive-empty');
const toggle = document.getElementById('fwlive-summary-rows');
if (card) {
if (!card.style) card.style = { display: '' };
card.style.display = this.summaryMode ? 'block' : 'none';
}
if (scroll) {
if (!scroll.style) scroll.style = { display: '' };
scroll.style.display = this.summaryMode && !this.summaryRowsShown ? 'none' : '';
}
if (empty) {
if (!empty.style) empty.style = { display: '' };
empty.style.display = this.summaryMode && !this.summaryRowsShown ? 'none' : '';
}
if (toggle) {
toggle.textContent = this.summaryRowsShown ? _('Hide rows') : _('Show rows');
toggle.setAttribute('aria-pressed', this.summaryRowsShown ? 'true' : 'false');
}
},
enterSummaryMode(summary) {
this.summaryMode = true;
this.summaryRowsShown = false;
this.summaryData = summary && typeof summary === 'object' ? summary : null;
this.renderSummary();
},
leaveSummaryMode() {
if (!this.summaryMode) return;
this.summaryMode = false;
this.summaryRowsShown = false;
this.summaryData = null;
this.updateSummaryUi();
this.renderRows(true);
},
onSummaryRowsToggle() {
if (!this.summaryMode) return;
this.summaryRowsShown = !this.summaryRowsShown;
this.updateSummaryUi();
if (this.summaryRowsShown) this.renderRows(true);
},
scheduleRenderRows(force) { scheduleRenderRows(force) {
const doForce = !!force; const doForce = !!force;
const epoch = this.pollEpoch; const epoch = this.pollEpoch;
@@ -1193,7 +1301,7 @@ return view.extend({
if (!wasPaused && this.paused) { if (!wasPaused && this.paused) {
this.pauseBufferLoading = true; this.pauseBufferLoading = true;
this.updateStatus(); this.updateStatus();
this.fetchEntries() this.requestPoll()
.catch(function () {}) .catch(function () {})
.finally( .finally(
function () { function () {
@@ -1209,7 +1317,7 @@ return view.extend({
/* Merge pause buffer with the first live poll — do not replace. */ /* Merge pause buffer with the first live poll — do not replace. */
this.resumeMerge = true; this.resumeMerge = true;
const epoch = this.pollEpoch; const epoch = this.pollEpoch;
this.fetchEntries() this.requestPoll()
.then(() => { .then(() => {
/* A hide/show bump abandons this epoch; the catch-up poll paints. */ /* A hide/show bump abandons this epoch; the catch-up poll paints. */
if (epoch === this.pollEpoch) this.renderRows(true); if (epoch === this.pollEpoch) this.renderRows(true);
@@ -1232,7 +1340,7 @@ return view.extend({
if (!this.paused) this.renderRows(true); if (!this.paused) this.renderRows(true);
else this.updateStatus(); else this.updateStatus();
const epoch = this.pollEpoch; const epoch = this.pollEpoch;
this.fetchEntries() this.requestPoll()
.then(() => { .then(() => {
/* A hide/show bump abandons this epoch; the catch-up poll paints. */ /* A hide/show bump abandons this epoch; the catch-up poll paints. */
if (epoch !== this.pollEpoch) return; if (epoch !== this.pollEpoch) return;
@@ -1533,6 +1641,7 @@ return view.extend({
{ {
rows: rows.slice(), rows: rows.slice(),
columns: this.activeColumns().slice(), columns: this.activeColumns().slice(),
forceRender: !!force,
viewMode: this.viewMode, viewMode: this.viewMode,
messageLayout: this.messageLayout, messageLayout: this.messageLayout,
expandedRowId: this.expandedRowId, expandedRowId: this.expandedRowId,
@@ -1641,14 +1750,82 @@ return view.extend({
const tintSel = document.getElementById('fwlive-row-tint'); const tintSel = document.getElementById('fwlive-row-tint');
if (tintSel) tintSel.addEventListener('change', this.onRowTintPaletteChange.bind(this)); if (tintSel) tintSel.addEventListener('change', this.onRowTintPaletteChange.bind(this));
const summaryRows = document.getElementById('fwlive-summary-rows');
if (summaryRows) summaryRows.addEventListener('click', this.onSummaryRowsToggle.bind(this));
}, },
async pollData() { async pollData() {
if (this.isTabHidden()) return; return this.requestPoll();
if (this.pollDataInFlight) return; },
requestPoll() {
if (this.isTabHidden()) return Promise.resolve();
const waiter = {};
const promise = new Promise(function (resolve) {
waiter.resolve = resolve;
});
if (this.pollRequestPromise) {
/* Keep one pending refresh intent. The latest view state is read when
* that follow-up begins, so Pause/Resume/Limit changes coalesce safely. */
this.pollRequestQueued = true;
this.pollRequestQueuedWaiters.push(waiter);
return promise;
}
if (this.pollRequestQueued) {
this.pollRequestQueuedWaiters.push(waiter);
this.startPollRequest(this.pollRequestQueuedWaiters);
this.pollRequestQueuedWaiters = [];
this.pollRequestQueued = false;
return promise;
}
this.startPollRequest([waiter]);
return promise;
},
startPollRequest(waiters) {
const epoch = this.pollEpoch; const epoch = this.pollEpoch;
this.pollDataInFlight = true; this.pollDataInFlight = true;
const run = this.runPollRequest(epoch);
this.pollRequestPromise = run;
this.pollRequestWaiters = waiters;
run.then(
function (value) {
this.finishPollRequest(run, value);
}.bind(this),
function () {
/* runPollRequest normally absorbs local failures so the poll loop
* remains alive; settle waiters even if a future change rejects. */
this.finishPollRequest(run);
}.bind(this)
);
},
finishPollRequest(run, value) {
if (this.pollRequestPromise !== run) return;
const waiters = this.pollRequestWaiters;
this.pollRequestWaiters = [];
this.pollRequestPromise = null;
this.pollDataInFlight = false;
for (let i = 0; i < waiters.length; i++) {
waiters[i].resolve(value);
}
/* If visibility changed while the request was active, retain the
* queued intent until the visible catch-up can start it. */
if (this.pollRequestQueued && !this.isTabHidden()) {
const queued = this.pollRequestQueuedWaiters;
this.pollRequestQueuedWaiters = [];
this.pollRequestQueued = false;
this.startPollRequest(queued);
}
},
async runPollRequest(epoch) {
try { try {
try { try {
await this.fetchEntries(); await this.fetchEntries();
@@ -1661,7 +1838,11 @@ return view.extend({
if (epoch !== this.pollEpoch) return; if (epoch !== this.pollEpoch) return;
/* Pause freezes row rendering but polling remains active for health and
* cadence state; summary mode can therefore appear while rows are paused
* and stays behind the explicit Show rows control. */
if (this.paused) this.updateStatus(); if (this.paused) this.updateStatus();
else if (this.summaryMode) this.renderSummary();
else this.scheduleRenderRows(!!this.pendingForceRender); else this.scheduleRenderRows(!!this.pendingForceRender);
try { try {
@@ -1669,11 +1850,10 @@ return view.extend({
} catch (e) { } catch (e) {
/* resolve unavailable — show IPs */ /* resolve unavailable — show IPs */
} }
} finally { } catch (e) {
/* Only the owning epoch clears — a stale hidden-era poll must not /* Keep the coordinator promise settling so a queued refresh cannot
* drop the catch-up guard after resume. Resume clears * be stranded by an unexpected local rendering failure. */
* explicitly before starting the catch-up poll. */ this.lastPollError = true;
if (epoch === this.pollEpoch) this.pollDataInFlight = false;
} }
}, },
@@ -1706,7 +1886,7 @@ return view.extend({
} }
} }
return Promise.all([this.loadRulesMap(), this.loadLoggingStatus()]).then(() => return Promise.all([this.loadRulesMap(), this.loadLoggingStatus()]).then(() =>
this.fetchEntries() this.requestPoll()
); );
}, },
@@ -1842,6 +2022,28 @@ return view.extend({
]), ]),
E('div', { 'id': 'fwlive-flood', 'class': 'fwlive-flood' }, ['']), E('div', { 'id': 'fwlive-flood', 'class': 'fwlive-flood' }, ['']),
E('div', { 'id': 'fwlive-adaptive', 'class': 'fwlive-adaptive' }, ['']), E('div', { 'id': 'fwlive-adaptive', 'class': 'fwlive-adaptive' }, ['']),
E(
'div',
{ 'id': 'fwlive-summary', 'class': 'fwlive-summary', 'style': 'display:none' },
[
E('div', { 'class': 'fwlive-summary-head' }, [
E('strong', {}, [_('Summary mode')]),
E(
'button',
{
'id': 'fwlive-summary-rows',
'class': 'cbi-button fwlive-btn-ghost',
'type': 'button',
'aria-pressed': 'false'
},
[_('Show rows')]
)
]),
E('pre', { 'id': 'fwlive-summary-body', 'class': 'fwlive-summary-body' }, [
''
])
]
),
E('div', { 'id': 'fwlive-display-drawer', 'class': 'fwlive-display-bar' }, [ E('div', { 'id': 'fwlive-display-drawer', 'class': 'fwlive-display-bar' }, [
E('span', { 'class': 'fwlive-display-bar-label' }, [_('Display options')]), E('span', { 'class': 'fwlive-display-bar-label' }, [_('Display options')]),
E('div', { 'class': 'fwlive-display-controls' }, [ E('div', { 'class': 'fwlive-display-controls' }, [
+30
View File
@@ -744,3 +744,33 @@ msgstr "mit iptables"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89
msgid "· %s" msgid "· %s"
msgstr "· %s" msgstr "· %s"
msgid "Hide rows"
msgstr "Zeilen ausblenden"
msgid "none"
msgstr "keine"
msgid "Show rows"
msgstr "Zeilen anzeigen"
msgid "Showing a compact summary of the top of the shown sample."
msgstr "Eine kompakte Zusammenfassung des Anfangs der angezeigten Stichprobe."
msgid "Summary data is unavailable; show rows to inspect the sample."
msgstr "Zusammenfassungsdaten nicht verfügbar; Zeilen anzeigen, um die Stichprobe zu prüfen."
msgid "Summary mode"
msgstr "Zusammenfassungsmodus"
msgid "Summary was bounded before all categories could be included."
msgstr "Die Zusammenfassung wurde begrenzt, bevor alle Kategorien aufgenommen werden konnten."
msgid "Top drops"
msgstr "Häufigste Drops"
msgid "Top rules"
msgstr "Häufigste Regeln"
msgid "Top talkers"
msgstr "Häufigste Absender"
+30
View File
@@ -749,3 +749,33 @@ msgstr "используется iptables"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89
msgid "· %s" msgid "· %s"
msgstr "· %s" msgstr "· %s"
msgid "Hide rows"
msgstr "Скрыть строки"
msgid "none"
msgstr "нет"
msgid "Show rows"
msgstr "Показать строки"
msgid "Showing a compact summary of the top of the shown sample."
msgstr "Показана краткая сводка верхней части отображаемой выборки."
msgid "Summary data is unavailable; show rows to inspect the sample."
msgstr "Данные сводки недоступны; покажите строки для проверки выборки."
msgid "Summary mode"
msgstr "Режим сводки"
msgid "Summary was bounded before all categories could be included."
msgstr "Сводка ограничена до включения всех категорий."
msgid "Top drops"
msgstr "Частые сбросы"
msgid "Top rules"
msgstr "Частые правила"
msgid "Top talkers"
msgstr "Частые источники"
@@ -678,3 +678,44 @@ msgstr ""
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89
msgid "· %s" msgid "· %s"
msgstr "" msgstr ""
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1063
msgid "Hide rows"
msgstr ""
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1012
msgid "none"
msgstr ""
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1039
msgid "Top drops"
msgstr ""
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1042
msgid "Top rules"
msgstr ""
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1036
msgid "Top talkers"
msgstr ""
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1030
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:1032
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:1950
msgid "Summary mode"
msgstr ""
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1034
msgid "Summary was bounded before all categories could be included."
msgstr ""
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1063
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1959
msgid "Show rows"
msgstr ""
@@ -713,3 +713,33 @@ msgstr "使用 iptables"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89 #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89
msgid "· %s" msgid "· %s"
msgstr "· %s" msgstr "· %s"
msgid "Hide rows"
msgstr "隐藏行"
msgid "none"
msgstr "无"
msgid "Show rows"
msgstr "显示行"
msgid "Showing a compact summary of the top of the shown sample."
msgstr "显示当前样本顶部的简要摘要。"
msgid "Summary data is unavailable; show rows to inspect the sample."
msgstr "摘要数据不可用;显示行以检查样本。"
msgid "Summary mode"
msgstr "摘要模式"
msgid "Summary was bounded before all categories could be included."
msgstr "摘要已受限,无法包含所有类别。"
msgid "Top drops"
msgstr "主要丢弃"
msgid "Top rules"
msgstr "主要规则"
msgid "Top talkers"
msgstr "主要通信方"
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> # Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com>
# #
# Layer 1 adaptive poll cap. Sourced by rpcd/fwlive. # Layer 1 adaptive poll cap (#306). Sourced by rpcd/fwlive.
# Always on unless test/triage override (no UCI / no product config): # Always on unless test/triage override (no UCI / no product config):
# FWLIVE_ADAPTIVE=0|false|off|no # FWLIVE_ADAPTIVE=0|false|off|no
# or sentinel ${FWLIVE_ADAPTIVE_OFF_FILE:-<state-dir>/fwlive-adaptive-off} # or sentinel ${FWLIVE_ADAPTIVE_OFF_FILE:-<state-dir>/fwlive-adaptive-off}
@@ -18,10 +18,10 @@
# flock, lock busy, or corrupt state. Lock-busy ⇒ unlocked last-writer-wins is # flock, lock busy, or corrupt state. Lock-busy ⇒ unlocked last-writer-wins is
# acceptable (state stays one valid JSON line; ordering is not guaranteed). # acceptable (state stays one valid JSON line; ordering is not guaranteed).
# Failed ubus log.read must NOT call record() — a ~0 ms failure is not "cold" # Failed ubus log.read must NOT call record() — a ~0 ms failure is not "cold"
# health and must not clear an existing hot/shed cap. # health and must not clear an existing hot/shed cap (#329 Hermes Q1).
# Outside the measured duration interval: plan (pre), record/merge (post). # Outside the measured duration interval: plan (pre), record/merge (post).
# messages_received is 0 in Layer 1 — do not ash-scan the filter JSON after # messages_received is supplied by the filter-side jsonfilter enumeration;
# end_cs; accurate count belongs in the filter or a later layer. # failed reads still use 0 without scanning the response in ash.
FWLIVE_ADAPTIVE_STATE_FILE="${FWLIVE_ADAPTIVE_STATE_FILE:-/var/run/fwlive-state.json}" FWLIVE_ADAPTIVE_STATE_FILE="${FWLIVE_ADAPTIVE_STATE_FILE:-/var/run/fwlive-state.json}"
# Optional overrides; when unset, lock/off paths are siblings of the current state. # Optional overrides; when unset, lock/off paths are siblings of the current state.
@@ -37,6 +37,7 @@ FWLIVE_ADAPTIVE_HOT_FLOOR=250
FWLIVE_ADAPTIVE_WARM_MIN=50 FWLIVE_ADAPTIVE_WARM_MIN=50
FWLIVE_ADAPTIVE_COOLDOWN_WARM_CS=300 FWLIVE_ADAPTIVE_COOLDOWN_WARM_CS=300
FWLIVE_ADAPTIVE_COOLDOWN_HOT_CS=600 FWLIVE_ADAPTIVE_COOLDOWN_HOT_CS=600
FWLIVE_ADAPTIVE_COOLDOWN_PROBE_CS=300
fwlive_adaptive_lock_path() { fwlive_adaptive_lock_path() {
if [ -n "${FWLIVE_ADAPTIVE_LOCK_FILE:-}" ]; then if [ -n "${FWLIVE_ADAPTIVE_LOCK_FILE:-}" ]; then
@@ -273,7 +274,8 @@ fwlive_adaptive_bucket_for_ms() {
fi fi
} }
# Args: requested_limit duration_ms prev_limit prev_bucket prev_warm prev_completed_cs # Args: requested_limit duration_ms prev_limit prev_bucket prev_warm
# prev_completed_cs planning
# Prints next limit. # Prints next limit.
fwlive_adaptive_compute_limit() { fwlive_adaptive_compute_limit() {
_req=$1 _req=$1
@@ -282,6 +284,7 @@ fwlive_adaptive_compute_limit() {
_prev_b=$4 _prev_b=$4
_prev_w=$5 _prev_w=$5
_prev_c=$6 _prev_c=$6
_planning=${7:-0}
_max="${POLL_LINES_MAX:-2000}" _max="${POLL_LINES_MAX:-2000}"
_now=$(fwlive_adaptive_clock_cs) _now=$(fwlive_adaptive_clock_cs)
_bucket=$(fwlive_adaptive_bucket_for_ms "$_ms") _bucket=$(fwlive_adaptive_bucket_for_ms "$_ms")
@@ -292,9 +295,10 @@ fwlive_adaptive_compute_limit() {
hot) _cd=$FWLIVE_ADAPTIVE_COOLDOWN_HOT_CS ;; hot) _cd=$FWLIVE_ADAPTIVE_COOLDOWN_HOT_CS ;;
warm) _cd=$FWLIVE_ADAPTIVE_COOLDOWN_WARM_CS ;; warm) _cd=$FWLIVE_ADAPTIVE_COOLDOWN_WARM_CS ;;
esac esac
_elapsed=$((_now - _prev_c))
if [ "$_cd" -gt 0 ] && [ "$_prev_c" -gt 0 ] && \ if [ "$_cd" -gt 0 ] && [ "$_prev_c" -gt 0 ] && \
[ "$((_now - _prev_c))" -lt "$_cd" ]; then [ "$_elapsed" -lt "$_cd" ]; then
if [ "$_ms" -le "$FWLIVE_ADAPTIVE_HOT_EXIT_MS" ] && \ if [ "$_ms" -le "$FWLIVE_ADAPTIVE_HOT_EXIT_MS" ] && \
[ "$_bucket" != hot ]; then [ "$_bucket" != hot ]; then
: :
@@ -308,9 +312,50 @@ fwlive_adaptive_compute_limit() {
fi fi
fi fi
# Once a hot/warm cooldown expires, deliberately test a larger request.
# Planning and recording use the same pure helper: planning raises the next
# request, while recording raises the retained limit only when that probe
# completed below the hot-exit threshold. A still-hot probe therefore falls
# through to the hot floor below.
if [ "$_cd" -gt 0 ] && [ "$_prev_c" -gt 0 ] && \
[ "$_elapsed" -ge "$_cd" ]; then
if [ "$_planning" = 1 ]; then
_limit=$_prev_l
[ "$_limit" -lt 1 ] && _limit=$_max
_limit=$((_limit * 2))
[ "$_limit" -gt "$_max" ] && _limit=$_max
[ "$_limit" -gt "$_req" ] && _limit=$_req
[ "$_limit" -lt 1 ] && _limit=1
printf '%s\n' "$_limit"
return 0
fi
if [ "$_ms" -le "$FWLIVE_ADAPTIVE_HOT_EXIT_MS" ] && \
[ "$_bucket" != hot ]; then
_limit=$_prev_l
[ "$_limit" -lt 1 ] && _limit=$_max
_limit=$((_limit * 2))
[ "$_limit" -gt "$_max" ] && _limit=$_max
[ "$_limit" -gt "$_req" ] && _limit=$_req
[ "$_limit" -lt 1 ] && _limit=1
printf '%s\n' "$_limit"
return 0
fi
fi
case "$_bucket" in case "$_bucket" in
cold) _limit=$_max ;; cold) _limit=$_max ;;
cool) _limit=$FWLIVE_ADAPTIVE_COOL_CAP ;; cool)
# A healthy cooldown probe may retain its raised limit for the
# short probe window. Ordinary cool samples still use the fixed
# 250-line cap.
if [ "$_prev_l" -gt "$FWLIVE_ADAPTIVE_COOL_CAP" ] && \
[ "$_prev_c" -gt 0 ] && \
[ "$((_now - _prev_c))" -lt "$FWLIVE_ADAPTIVE_COOLDOWN_PROBE_CS" ]; then
_limit=$_prev_l
else
_limit=$FWLIVE_ADAPTIVE_COOL_CAP
fi
;;
warm) warm)
if [ "$_prev_w" = 1 ] && [ "$_prev_b" = warm ]; then if [ "$_prev_w" = 1 ] && [ "$_prev_b" = warm ]; then
_limit=$_prev_l _limit=$_prev_l
@@ -350,7 +395,7 @@ fwlive_adaptive_plan() {
_prev_w=$4 _prev_w=$4
_s=$5 _s=$5
_prev_c=$6 _prev_c=$6
_l=$(fwlive_adaptive_compute_limit "$_req" "$_d" "$_prev_l" "$_b" "$_prev_w" "$_prev_c") _l=$(fwlive_adaptive_compute_limit "$_req" "$_d" "$_prev_l" "$_b" "$_prev_w" "$_prev_c" 1)
_nb=$(fwlive_adaptive_bucket_for_ms "$_d") _nb=$(fwlive_adaptive_bucket_for_ms "$_d")
_shed=0 _shed=0
[ "$_nb" = hot ] && _shed=1 [ "$_nb" = hot ] && _shed=1
@@ -395,7 +440,9 @@ fwlive_adaptive_is_hot() {
return 1 return 1
} }
# Merge adaptive siblings into a JSON object ending with }. # Merge adaptive siblings into a JSON object ending with }. A successful
# filter already carries the exact messages_received count; do not append a
# duplicate key in that case.
# Args: json_body shed_flag limit truncated messages_received # Args: json_body shed_flag limit truncated messages_received
fwlive_adaptive_merge_reply() { fwlive_adaptive_merge_reply() {
_body=$1 _body=$1
@@ -410,34 +457,32 @@ fwlive_adaptive_merge_reply() {
*) printf '%s' "$_body"; return 0 ;; *) printf '%s' "$_body"; return 0 ;;
esac esac
_base=${_body%\}} _base=${_body%\}}
_has_msgs=0
case "$_body" in
*',"messages_received":'*) _has_msgs=1 ;;
esac
if [ "$_adapt" = 0 ]; then if [ "$_adapt" = 0 ]; then
printf '%s,"adaptive":0,"messages_received":%s}' "$_base" "$_msgs" if [ "$_has_msgs" = 1 ]; then
printf '%s,"adaptive":0}' "$_base"
else
printf '%s,"adaptive":0,"messages_received":%s}' "$_base" "$_msgs"
fi
return 0 return 0
fi fi
if [ "$_shed" = 1 ]; then if [ "$_shed" = 1 ]; then
printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s,"shed":{"level":"hot","limit":%s}}' \ if [ "$_has_msgs" = 1 ]; then
"$_base" "$_msgs" "$_trunc" "$_limit" printf '%s,"adaptive":1,"truncated":%s,"shed":{"level":"hot","limit":%s}}' \
"$_base" "$_trunc" "$_limit"
else
printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s,"shed":{"level":"hot","limit":%s}}' \
"$_base" "$_msgs" "$_trunc" "$_limit"
fi
else else
printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s}' \ if [ "$_has_msgs" = 1 ]; then
"$_base" "$_msgs" "$_trunc" printf '%s,"adaptive":1,"truncated":%s}' "$_base" "$_trunc"
else
printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s}' \
"$_base" "$_msgs" "$_trunc"
fi
fi fi
} }
# Count log[*] via "msg" keys — helper for tests / future filter-side count.
# NOT called on the poll hot path: ash-scanning a 2000-entry
# reply after end_cs is unmeasured overhead that cannot shed itself.
fwlive_adaptive_count_log() {
_json=$1
_n=0
_rest=$_json
while :; do
case "$_rest" in
*'"msg"'*)
_n=$((_n + 1))
_rest=${_rest#*\"msg\"}
;;
*) break ;;
esac
done
printf '%s\n' "$_n"
}
@@ -106,11 +106,101 @@ function is_fw(s, action) {
if (has_hint(s) && (has_kv(s, "IN") || has_kv(s, "OUT") || has_kv(s, "SRC") || has_kv(s, "DST") || has_kv(s, "PROTO"))) return 1 if (has_hint(s) && (has_kv(s, "IN") || has_kv(s, "OUT") || has_kv(s, "SRC") || has_kv(s, "DST") || has_kv(s, "PROTO"))) return 1
return 0 return 0
} }
BEGIN { if (MODE != "json") ORS = "" } function utf8_prefix(s, max_bytes, out, i, c, width) {
out = ""
i = 1
while (i <= length(s)) {
c = substr(s, i, 1)
width = 1
if (c ~ ("^[\302-\337]$")) width = 2
else if (c ~ ("^[\340-\357]$")) width = 3
else if (c ~ ("^[\360-\367]$")) width = 4
if (i + width - 1 > max_bytes) break
out = out substr(s, i, width)
i += width
}
return out
}
function summary_kv(s, key, part) {
s = normalize(s)
if (!match(s, "(^|[[:space:]])" key "=[^[:space:]]+")) return ""
part = substr(s, RSTART, RLENGTH)
sub(/^[^=]*=/, "", part)
return part
}
function summary_rule(s, first) {
s = trim(normalize(s))
sub(/^\[[[:space:]]*[0-9.]+\][[:space:]]*/, "", s)
if (tolower(s) ~ /^fw4:[[:space:]]*/) return "fw4"
first = s
sub(/[[:space:]:].*/, "", first)
if (first == "" || first ~ /^(IN|OUT|SRC|DST|PROTO|SPT|DPT|LEN|MAC|TYPE|CODE|TTL|TOS|PREC|DF)=/) return ""
if (tolower(first) == "kernel" || tolower(first) == "iptables") return ""
return utf8_prefix(first, 64)
}
function summary_add_count(counts, key) {
if (key != "") counts[key]++
}
function json_escape(s, out, i, c) {
out = ""
for (i = 1; i <= length(s); i++) {
c = substr(s, i, 1)
if (c == "\\") out = out "\\\\"
else if (c == "\"") out = out "\\\""
else if (c == "\n") out = out "\\n"
else if (c == "\r") out = out "\\r"
else if (c == "\t") out = out "\\t"
else if (c < " ") out = out " "
else out = out c
}
return out
}
function summary_top(counts, limit, out, i, k, best, best_n) {
for (k in summary_picked) delete summary_picked[k]
out = "["
for (i = 0; i < limit; i++) {
best = ""; best_n = 0
for (k in counts) {
if (!(k in summary_picked) && (counts[k] > best_n || (counts[k] == best_n && (best == "" || k < best)))) {
best = k; best_n = counts[k]
}
}
if (best == "") break
summary_picked[best] = 1
if (i) out = out ","
out = out "{\"value\":\"" json_escape(best) "\",\"count\":" best_n "}"
}
return out "]"
}
function summary_add(s, action, src, dst, rule) {
summary_count++
action = tolower(detect_action(normalize(s)))
if (action == "drop" || action == "reject" || action == "deny" || action == "block") summary_add_count(summary_drop_counts, action)
src = summary_kv(s, "SRC")
if (src == "") src = summary_kv(s, "DST")
if (src != "") summary_add_count(summary_talker_counts, utf8_prefix(src, 64))
rule = summary_rule(s)
if (rule != "") summary_add_count(summary_rule_counts, rule)
}
function summary_json(out) {
out = "{\"scope\":\"top of shown sample\",\"top_talkers\":" summary_top(summary_talker_counts, 3)
out = out ",\"top_drops\":" summary_top(summary_drop_counts, 3)
out = out ",\"top_rules\":" summary_top(summary_rule_counts, 3) "}"
# LC_ALL=C makes this a byte budget; utf8_prefix keeps field cuts valid.
if (length(out) > 256) return "{\"scope\":\"top of shown sample\",\"truncated\":true}"
return out
}
BEGIN {
if (MODE == "json_reply") {
ORS = ""
printf "{\"log\":["
} else if (MODE != "json") ORS = ""
}
{ {
if (MODE == "json") { if (MODE == "json" || MODE == "json_reply") {
msg = json_get_msg($0) msg = json_get_msg($0)
if (is_fw(msg)) { if (is_fw(msg)) {
if (MODE == "json_reply" && SUMMARY != "0") summary_add(msg)
if (out_n++) printf "," if (out_n++) printf ","
printf "%s", $0 printf "%s", $0
} }
@@ -119,6 +209,11 @@ BEGIN { if (MODE != "json") ORS = "" }
buf = (NR == 1) ? $0 : buf "\n" $0 buf = (NR == 1) ? $0 : buf "\n" $0
} }
END { END {
if (MODE != "json") if (MODE == "json_reply") {
printf "],\"messages_received\":%d", NR
if (SUMMARY != "0" && summary_count > 0) printf ",\"summary\":%s", summary_json()
printf "}"
}
else if (MODE != "json")
print is_fw(buf) ? 1 : 0 print is_fw(buf) ? 1 : 0
} }
@@ -5,14 +5,18 @@
# source: core/fwlive-log.js CLASSIFY_SPEC # source: core/fwlive-log.js CLASSIFY_SPEC
# Shared isFirewallEvent parity logic (shell). Sourced by fwlive-log-filter.sh and tests. # Shared isFirewallEvent parity logic (shell). Sourced by fwlive-log-filter.sh and tests.
# Sourced library: do not add set -euo here (callers own strict mode, #291 C3). # Sourced library: do not add set -euo here (callers own strict mode, #291 C3).
# One awk process classifies a batch (MODE=json) or one message (default). # One awk process classifies a batch (MODE=json/json_reply) or one message (default).
# The caller sets FILTER_DIR when this file is sourced. Resolve the asset # The caller sets FILTER_DIR when this file is sourced. Resolve the asset
# once, not once per classification call. # once, not once per classification call.
CLASSIFY_AWK="${FILTER_DIR:-${0%/*}}/fwlive-is-firewall-event.awk" CLASSIFY_AWK="${FILTER_DIR:-${0%/*}}/fwlive-is-firewall-event.awk"
_fwlive_run_classify() { _fwlive_run_classify() {
awk -v MODE="${1:-msg}" -f "$CLASSIFY_AWK" if [ "${1:-msg}" = json_reply ]; then
LC_ALL=C awk -v MODE="${1:-msg}" -v SUMMARY="${FWLIVE_SUMMARY:-1}" -f "$CLASSIFY_AWK"
else
awk -v MODE="${1:-msg}" -v SUMMARY="${FWLIVE_SUMMARY:-1}" -f "$CLASSIFY_AWK"
fi
} }
is_firewall_event_msg() { is_firewall_event_msg() {
@@ -23,3 +27,7 @@ is_firewall_event_msg() {
_fwlive_filter_json_entries() { _fwlive_filter_json_entries() {
_fwlive_run_classify json _fwlive_run_classify json
} }
_fwlive_filter_json_reply() {
_fwlive_run_classify json_reply
}
@@ -6,14 +6,13 @@
# Log messages are treated as data (jsonfilter + awk stdin); never interpolated # Log messages are treated as data (jsonfilter + awk stdin); never interpolated
# into shell command strings. Usage: ubus call log read '...' | fwlive-log-filter.sh # into shell command strings. Usage: ubus call log read '...' | fwlive-log-filter.sh
# #
# Perf: one jsonfilter for @.log[*] plus one awk classify. Process # Perf (#219): one jsonfilter for @.log[*] plus one awk classify. The same
# count is constant per poll, not O(entries). # awk pass counts enumerated entries for messages_received; process count is
# constant per poll, not O(entries).
# #
# Entry point (pipeline). The classifier sibling is sourced and must not set # Entry point (pipeline). The classifier sibling is sourced and must not set
# strict mode itself. # strict mode itself (#291 C3).
set -eu set -eu
# shellcheck disable=SC3040
(set -o pipefail) 2>/dev/null && set -o pipefail
if ! command -v jsonfilter >/dev/null 2>&1; then if ! command -v jsonfilter >/dev/null 2>&1; then
command -v logger >/dev/null 2>&1 && logger -t fwlive "jsonfilter not found; cannot filter firewall logs" command -v logger >/dev/null 2>&1 && logger -t fwlive "jsonfilter not found; cannot filter firewall logs"
@@ -26,17 +25,35 @@ FILTER_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$FILTER_DIR/fwlive-is-firewall-event.sh" . "$FILTER_DIR/fwlive-is-firewall-event.sh"
# The classifier is a package asset, not generated at request time. Fail # The classifier is a package asset, not generated at request time. Fail
# closed if an incomplete install or a damaged package removed it. # closed if an incomplete install or a damaged package removed it (#321).
if [ ! -r "$CLASSIFY_AWK" ]; then if [ ! -r "$CLASSIFY_AWK" ]; then
printf '%s' '{"log":[],"error":"classifier_missing"}' printf '%s' '{"log":[],"error":"classifier_missing"}'
exit 1 exit 1
fi fi
printf '%s' '{"log":['
# Prefer stdin over -s: Linux MAX_ARG_STRLEN is 128KiB; a raised logd ring # Prefer stdin over -s: Linux MAX_ARG_STRLEN is 128KiB; a raised logd ring
# (or paused FETCH_LINES_MAX poll) can exceed that and make jsonfilter fail # (or paused FETCH_LINES_MAX poll) can exceed that and make jsonfilter fail
# while this script still printed {"log":[]} and exited 0. # while this script still returns a valid empty result (#234).
# jsonfilter miss / empty @.log is not fatal; must still close JSON. # A valid input with no log entries is not fatal; the classifier closes JSON
# set -e + pipefail cannot apply to this pipeline. # and reports messages_received:0 (#220). A jsonfilter failure is different:
jsonfilter -e '@.log[*]' 2>/dev/null | _fwlive_filter_json_entries || true # it must remain an error so rpcd does not record a fast healthy sample. Keep
printf '%s' ']}' # jsonfilter output in a secure temporary file before classification so a
# partial pipeline cannot produce malformed JSON on failure.
# This script runs from rpcd as root and reopens the path for classification.
# Match _fwlive_mktemp: only use the verified, sticky system /tmp; honoring
# TMPDIR would permit a non-sticky attacker-controlled directory here.
# shellcheck disable=SC3065 # OpenWrt BusyBox test supports -k; match rpcd helper.
if [ ! -d /tmp ] || [ -L /tmp ] || ! [ -k /tmp ]; then
printf '%s' '{"log":[],"error":"filter_tempfile_failed"}'
exit 1
fi
_filter_tmp=$(mktemp /tmp/fwlive-filter.XXXXXX) || {
printf '%s' '{"log":[],"error":"filter_tempfile_failed"}'
exit 1
}
trap 'rm -f "$_filter_tmp"' 0 1 2 3 15
if ! jsonfilter -e '@.log[*]' >"$_filter_tmp" 2>/dev/null; then
printf '%s' '{"log":[],"error":"filter_failed"}'
exit 1
fi
_fwlive_filter_json_reply <"$_filter_tmp"
@@ -27,6 +27,54 @@ NF_LOG_IPV6='/proc/sys/net/netfilter/nf_log/10'
WAN_LOG_LOCK_FILE="${FWLIVE_WAN_LOG_LOCK_FILE:-/etc/fwlive/logging.lock}" WAN_LOG_LOCK_FILE="${FWLIVE_WAN_LOG_LOCK_FILE:-/etc/fwlive/logging.lock}"
WAN_LOG_BASELINE_FILE="${FWLIVE_WAN_LOG_BASELINE_FILE:-/etc/fwlive/wan-log-baseline}" WAN_LOG_BASELINE_FILE="${FWLIVE_WAN_LOG_BASELINE_FILE:-/etc/fwlive/wan-log-baseline}"
weak_device_detected() {
# Path overrides are test hooks; production defaults stay in procfs.
_meminfo=${FWLIVE_MEMINFO_PATH:-/proc/meminfo}
_cpuinfo=${FWLIVE_CPUINFO_PATH:-/proc/cpuinfo}
_mem_total=
if [ -r "$_meminfo" ]; then
while IFS= read -r _line; do
case "$_line" in
MemTotal:*)
_mem_total=${_line#MemTotal:}
while :; do
case "$_mem_total" in
[[:space:]]*) _mem_total=${_mem_total#?} ;;
*) break ;;
esac
done
_mem_total=${_mem_total%%[[:space:]]*}
break
;;
esac
done <"$_meminfo" || :
fi
_cores=0
_cpu_valid=0
if [ -r "$_cpuinfo" ]; then
while IFS= read -r _line; do
case "$_line" in
processor[[:space:]]*:*)
_cores=$((_cores + 1))
_cpu_valid=1
;;
esac
done <"$_cpuinfo" || :
fi
case "$_mem_total" in
''|*[!0-9]*) printf 'false\n'; return 0 ;;
esac
[ "$_cpu_valid" = 1 ] || { printf 'false\n'; return 0; }
# MemTotal is reported in KiB; 256 MiB is 262144 KiB.
if [ "$_mem_total" -lt 262144 ] || [ "$_cores" -le 1 ]; then
printf 'true\n'
else
printf 'false\n'
fi
}
# RFC 8259 string escape. Lives here so prerm can source this file # RFC 8259 string escape. Lives here so prerm can source this file
# standalone. rpcd sources us and must not redefine this. # standalone. rpcd sources us and must not redefine this.
json_escape() { json_escape() {
@@ -470,12 +518,14 @@ build_logging_status_json() {
if [ -n "$zone" ] && [ "$wan_log" = true ] && [ "$nf4" = true ] && [ "$nf6" = true ]; then if [ -n "$zone" ] && [ "$wan_log" = true ] && [ "$nf4" = true ] && [ "$nf6" = true ]; then
ready=true ready=true
fi fi
weak_device=false
[ "$(weak_device_detected)" = true ] && weak_device=true
zone_json=$(json_null_or_string "$zone") zone_json=$(json_null_or_string "$zone")
limit_json=$(json_null_or_string "$limit_val") limit_json=$(json_null_or_string "$limit_val")
printf '{"wan_zone":%s,"wan_log":%s,"wan_log_limit":%s,"nf_log_ipv4":%s,"nf_log_ipv6":%s,"ready":%s,"blockers":%s,"warnings":%s}' \ printf '{"wan_zone":%s,"wan_log":%s,"wan_log_limit":%s,"nf_log_ipv4":%s,"nf_log_ipv6":%s,"ready":%s,"weak_device":%s,"blockers":%s,"warnings":%s}' \
"$zone_json" "$wan_log" "$limit_json" "$nf4" "$nf6" "$ready" "$blockers" "$warnings" "$zone_json" "$wan_log" "$limit_json" "$nf4" "$nf6" "$ready" "$weak_device" "$blockers" "$warnings"
} }
reload_firewall() { reload_firewall() {
+52 -30
View File
@@ -23,7 +23,7 @@ ADAPTIVE_SH="$LIBEXEC_DIR/fwlive-adaptive-cap.sh"
RESOLVE_MAX=32 RESOLVE_MAX=32
# Per-lookup nslookup timeout (integer seconds — BusyBox `timeout` truncates # Per-lookup nslookup timeout (integer seconds — BusyBox `timeout` truncates
# fractional values to 0 on OpenWrt 23.05/1.36, so 0.5 would silently # fractional values to 0 on OpenWrt 23.05/1.36, so 0.5 would silently
# degrade to no timeout.. The wall-clock budget # degrade to no timeout; luna fold 2026-08-10). The wall-clock budget
# below is the real whole-call bound; this per-lookup cap only prevents a # below is the real whole-call bound; this per-lookup cap only prevents a
# single hung resolver from dominating the budget. LAN/local-resolver # single hung resolver from dominating the budget. LAN/local-resolver
# lookups are single-digit ms, so 1s is ample. Passed as argv to # lookups are single-digit ms, so 1s is ample. Passed as argv to
@@ -42,7 +42,7 @@ NFT_TIMEOUT=5
# CLI-only iptables fixture path for __rulesmap_iptables (no arbitrary paths). # CLI-only iptables fixture path for __rulesmap_iptables (no arbitrary paths).
RULESMAP_IPTABLES_FILE='/tmp/rulesmap' RULESMAP_IPTABLES_FILE='/tmp/rulesmap'
# json_escape lives in fwlive-logging.sh so prerm can source that file alone. # json_escape lives in fwlive-logging.sh so prerm can source that file alone (#222).
# shellcheck disable=SC1090,SC1091 # LOGGING_SH resolves to sibling ../fwlive-logging.sh # shellcheck disable=SC1090,SC1091 # LOGGING_SH resolves to sibling ../fwlive-logging.sh
. "$LOGGING_SH" . "$LOGGING_SH"
# shellcheck disable=SC1090,SC1091 # shellcheck disable=SC1090,SC1091
@@ -52,10 +52,10 @@ slug_key() {
echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' _' '--' echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' _' '--'
} }
# Cap rules JSON size so a huge ruleset cannot unbound an rpcd reply. # Cap rules JSON size so a huge ruleset cannot unbound an rpcd reply (#229).
RULES_MAP_MAX_KEYS=512 RULES_MAP_MAX_KEYS=512
RULES_MAP_MAX_BYTES=65536 RULES_MAP_MAX_BYTES=65536
# Same wall-clock bound as nft for iptables-save / ip6tables-save. # Same wall-clock bound as nft for iptables-save / ip6tables-save (#229).
# Overridable for host tests (e.g. FWLIVE_IPTABLES_TIMEOUT=1). # Overridable for host tests (e.g. FWLIVE_IPTABLES_TIMEOUT=1).
IPTABLES_TIMEOUT="${FWLIVE_IPTABLES_TIMEOUT:-5}" IPTABLES_TIMEOUT="${FWLIVE_IPTABLES_TIMEOUT:-5}"
@@ -70,7 +70,7 @@ map_add() {
# ("foo" vs "foobar") and value false positives ("see foo"). # ("foo" vs "foobar") and value false positives ("see foo").
# Quoting "$esc_key" makes the match literal (no glob escaping needed). # Quoting "$esc_key" makes the match literal (no glob escaping needed).
# Callers apply UCI, then labeled (!fw4:) prefixes, then unlabeled # Callers apply UCI, then labeled (!fw4:) prefixes, then unlabeled
# cosmetics so first-wins prefers authoritative names. # cosmetics so first-wins prefers authoritative names (#230).
case "$OUT" in case "$OUT" in
*"\"$esc_key\":"*) return 0 ;; *"\"$esc_key\":"*) return 0 ;;
esac esac
@@ -92,7 +92,7 @@ map_add() {
uci_rule_names() { uci_rule_names() {
# uci missing / empty firewall config is "no names", not fatal. # uci missing / empty firewall config is "no names", not fatal.
# pipefail + set -e cannot apply to this pipeline. # pipefail + set -e cannot apply to this pipeline (#291 C3).
uci -q show firewall 2>/dev/null | sed -n "s/^firewall\.@rule\[[0-9]*\]\.name='\(.*\)'$/\1/p" || true uci -q show firewall 2>/dev/null | sed -n "s/^firewall\.@rule\[[0-9]*\]\.name='\(.*\)'$/\1/p" || true
} }
@@ -142,7 +142,7 @@ run_with_timeout() {
if command -v timeout >/dev/null 2>&1; then if command -v timeout >/dev/null 2>&1; then
timeout "$secs" "$@" 2>/dev/null timeout "$secs" "$@" 2>/dev/null
else else
# Fail closed: never run unbounded when timeout is absent. # Fail closed (#229): never run unbounded when timeout is absent.
return 127 return 127
fi fi
} }
@@ -170,7 +170,7 @@ map_uci_rule_names() {
# Line-wise (here-doc, not an unquoted for-loop) so a name with # Line-wise (here-doc, not an unquoted for-loop) so a name with
# spaces stays one token. Names that fail is_uci_style_name are # spaces stays one token. Names that fail is_uci_style_name are
# skipped: parseRuleHint() cannot match them, and fragments of # skipped: parseRuleHint() cannot match them, and fragments of
# "My Rule" must not shadow a real rule named Rule. # "My Rule" must not shadow a real rule named Rule (#226).
# The while-read stays in the current shell (here-doc, not a # The while-read stays in the current shell (here-doc, not a
# pipeline) so map_add mutations reach OUT. # pipeline) so map_add mutations reach OUT.
while IFS= read -r name || [ -n "$name" ]; do while IFS= read -r name || [ -n "$name" ]; do
@@ -187,7 +187,7 @@ EOF
} }
# pass=labeled|unlabeled|all — labeled before unlabeled so !fw4: beats # pass=labeled|unlabeled|all — labeled before unlabeled so !fw4: beats
# an earlier cosmetic for the same prefix under first-wins. # an earlier cosmetic for the same prefix under first-wins (#230).
map_from_nft_stream() { map_from_nft_stream() {
pass="${1:-all}" pass="${1:-all}"
while IFS= read -r line; do while IFS= read -r line; do
@@ -288,7 +288,7 @@ build_rules_map() {
backend=$(detect_firewall_backend) backend=$(detect_firewall_backend)
# UCI first, then labeled stream prefixes, then unlabeled cosmetics # UCI first, then labeled stream prefixes, then unlabeled cosmetics
# First-wins keeps UCI over nft and !fw4: over cosmetic. # (#230). First-wins keeps UCI over nft and !fw4: over cosmetic.
map_uci_rule_names map_uci_rule_names
case "$backend" in case "$backend" in
@@ -435,32 +435,54 @@ fetch_firewall_logs() {
shed_flag=${2:-0} shed_flag=${2:-0}
# Adaptive plan prints: lines shed bucket # Adaptive plan prints: lines shed bucket
start_cs=$(fwlive_adaptive_clock_cs) start_cs=$(fwlive_adaptive_clock_cs)
# Drop dead oneshot flag — only consumed on logd's stream branch. # Drop dead oneshot flag — only consumed on logd's stream branch (#306).
if ! raw=$(ubus call log read "{\"lines\":$lines,\"stream\":false}" 2>/dev/null); then if ! raw=$(ubus call log read "{\"lines\":$lines,\"stream\":false}" 2>/dev/null); then
# Do not record ~0 ms as cold: failure is not healthy fast processing # Do not record ~0 ms as cold: failure is not healthy fast processing
# and must not clear an existing hot/shed cap. # and must not clear an existing hot/shed cap (#329 Hermes Q1).
out='{"log":[],"error":"log_read_failed"}' out='{"log":[],"error":"log_read_failed"}'
fwlive_adaptive_merge_reply "$out" "$shed_flag" "$lines" 0 0 fwlive_adaptive_merge_reply "$out" "$shed_flag" "$lines" 0 0
return 0 return 0
fi fi
# Do not swallow a filter failure into a silent empty table. # Summary aggregation is part of the adaptive contract. Keep it out of
# adaptive=0 replies, while the classifier still remains the single pass
# over decoded log entries (#306).
if fwlive_adaptive_enabled; then
FWLIVE_SUMMARY=1
else
FWLIVE_SUMMARY=0
fi
export FWLIVE_SUMMARY
# Do not swallow a filter failure into a silent empty table (#220).
# If the filter exits non-zero but still printed JSON, keep it. # If the filter exits non-zero but still printed JSON, keep it.
# Empty success is not valid poll JSON — the shipped filter always # Empty success is not valid poll JSON — the shipped filter always
# prints {"log":[...]}, but a stub/regression must not emit a blank body. # prints {"log":[...]}, but a stub/regression must not emit a blank body.
_filter_ok=1
if ! out=$(printf '%s' "$raw" | "$FILTER_SH"); then if ! out=$(printf '%s' "$raw" | "$FILTER_SH"); then
_filter_ok=0
if [ -z "$out" ]; then if [ -z "$out" ]; then
out='{"log":[],"error":"filter_failed"}' out='{"log":[],"error":"filter_failed"}'
fi fi
fi fi
[ -n "$out" ] || out='{"log":[],"error":"filter_empty"}' if [ -z "$out" ]; then
_filter_ok=0
out='{"log":[],"error":"filter_empty"}'
fi
# A classifier can return a structured error body while exiting zero (for
# example classifier_missing). Preserve the error for the caller, but do
# not let it overwrite a prior hot state as a healthy fast sample.
case "$out" in
*'"error":'*) _filter_ok=0 ;;
esac
end_cs=$(fwlive_adaptive_clock_cs) end_cs=$(fwlive_adaptive_clock_cs)
# Uptime wrap / skew: end < start — do not record as cold. # Uptime wrap / skew: end < start — do not record as cold (Grok #329 P2).
if [ "$end_cs" -ge "$start_cs" ]; then if [ "$_filter_ok" = 1 ] && [ "$end_cs" -ge "$start_cs" ]; then
_dur=$(((end_cs - start_cs) * 10)) _dur=$(((end_cs - start_cs) * 10))
fwlive_adaptive_record "$_dur" "$lines" fwlive_adaptive_record "$_dur" "$lines"
fi fi
# Layer 1: messages_received stays 0 — no post-filter ash count. # The filter carries messages_received from its jsonfilter enumeration;
# failed/read-invalid paths keep the merge fallback at 0.
_trunc=0 _trunc=0
[ "$shed_flag" = 1 ] && _trunc=1 [ "$shed_flag" = 1 ] && _trunc=1
[ "$lines" -lt "$requested" ] && _trunc=1 [ "$lines" -lt "$requested" ] && _trunc=1
@@ -502,7 +524,7 @@ resolve_hostname() {
[ -n "$ip" ] || return 1 [ -n "$ip" ] || return 1
is_resolvable_address "$ip" || return 1 is_resolvable_address "$ip" || return 1
command -v nslookup >/dev/null 2>&1 || return 1 command -v nslookup >/dev/null 2>&1 || return 1
# timeout/nslookup miss is "no PTR", not a plugin abort. # timeout/nslookup miss is "no PTR", not a plugin abort (#291 C3).
out=$(run_with_timeout "$RESOLVE_TIMEOUT" nslookup "$ip") || return 1 out=$(run_with_timeout "$RESOLVE_TIMEOUT" nslookup "$ip") || return 1
[ -n "$out" ] || return 1 [ -n "$out" ] || return 1
name=$(parse_nslookup_name "$out") || return 1 name=$(parse_nslookup_name "$out") || return 1
@@ -510,7 +532,7 @@ resolve_hostname() {
printf '%s' "$name" printf '%s' "$name"
} }
# Strict IPv4/IPv6 validation before nslookup: the old char-class # Strict IPv4/IPv6 validation before nslookup (issue #190): the old char-class
# check admitted hostname-shaped tokens of hex chars + dots/colons # check admitted hostname-shaped tokens of hex chars + dots/colons
# (e.g. "dead.beef.cafe.baad"), letting the resolver fall back to upstream DNS # (e.g. "dead.beef.cafe.baad"), letting the resolver fall back to upstream DNS
# queries chosen by an authenticated session. Validate the real address # queries chosen by an authenticated session. Validate the real address
@@ -523,7 +545,7 @@ is_resolvable_address() {
# Reject anything outside the address alphabet BEFORE awk, so embedded # Reject anything outside the address alphabet BEFORE awk, so embedded
# newlines/whitespace (which awk would treat as record separators) can # newlines/whitespace (which awk would treat as record separators) can
# never let a valid first record mask trailing garbage that still reaches # never let a valid first record mask trailing garbage that still reaches
# nslookup: nslookup only ever sees a single # nslookup (#190, CodeRabbit/luna fold): nslookup only ever sees a single
# token. Note the check intentionally allows only hex/colon/dot. # token. Note the check intentionally allows only hex/colon/dot.
case "$addr" in case "$addr" in
*[!0-9a-fA-F:.]*) return 1 ;; *[!0-9a-fA-F:.]*) return 1 ;;
@@ -596,7 +618,7 @@ is_resolvable_address() {
} }
resolve_addresses() { resolve_addresses() {
# Missing resolver must not look like "no PTR". Check before # Missing resolver must not look like "no PTR" (#218). Check before
# starting the budget clock or reading stdin. # starting the budget clock or reading stdin.
if ! command -v nslookup >/dev/null 2>&1; then if ! command -v nslookup >/dev/null 2>&1; then
command -v logger >/dev/null 2>&1 && logger -t fwlive "nslookup not found; resolve disabled" command -v logger >/dev/null 2>&1 && logger -t fwlive "nslookup not found; resolve disabled"
@@ -604,14 +626,14 @@ resolve_addresses() {
return 0 return 0
fi fi
# Load shed: previous poll was hot. # Load shed: previous poll was hot (#306 Layer 1).
if fwlive_adaptive_is_hot; then if fwlive_adaptive_is_hot; then
printf '{"names":{},"disabled":"load"}' printf '{"names":{},"disabled":"load"}'
return 0 return 0
fi fi
# Wall-clock budget clock starts at the TRUE function entry: the # Wall-clock budget clock starts at the TRUE function entry (luna
# budget must bound the whole call — including # folds 2026-08-10): the budget must bound the whole call — including
# read_rpc_input (stalled stdin must not escape the budget) and json # read_rpc_input (stalled stdin must not escape the budget) and json
# parsing. `date +%s` can jump under NTP sync — accepted (worst case # parsing. `date +%s` can jump under NTP sync — accepted (worst case
# the budget over- or under-runs by the jump; worker starvation is # the budget over- or under-runs by the jump; worker starvation is
@@ -657,12 +679,12 @@ resolve_addresses() {
# never START a lookup past the budget. # never START a lookup past the budget.
now=$(date +%s) now=$(date +%s)
[ $((now - start)) -ge "$RESOLVE_BUDGET" ] && break [ $((now - start)) -ge "$RESOLVE_BUDGET" ] && break
# Missing/empty element: set -u cannot read bare $ip. # Missing/empty element: set -u cannot read bare $ip (#291 C3).
if ! is_resolvable_address "${ip:-}"; then if ! is_resolvable_address "${ip:-}"; then
continue continue
fi fi
# NXDOMAIN / timeout: resolve_hostname returns 1. set -e # NXDOMAIN / timeout: resolve_hostname returns 1. set -e
# cannot apply to this assignment. # cannot apply to this assignment (#291 C3).
name=$(resolve_hostname "$ip") || name= name=$(resolve_hostname "$ip") || name=
if [ -n "$name" ]; then if [ -n "$name" ]; then
map_add "$ip" "$name" map_add "$ip" "$name"
@@ -729,7 +751,7 @@ run_selftest() {
return 1 return 1
fi fi
# hostname-shaped tokens of hex chars + dots/colons passed the # Issue #190: hostname-shaped tokens of hex chars + dots/colons passed the
# old char-class check and made the resolver fall back to upstream DNS. Each case # old char-class check and made the resolver fall back to upstream DNS. Each case
# below must REJECT (and would be ACCEPTED by the reverted char-class code). # below must REJECT (and would be ACCEPTED by the reverted char-class code).
if is_resolvable_address 'ab.cd.ef.01'; then if is_resolvable_address 'ab.cd.ef.01'; then
@@ -757,7 +779,7 @@ run_selftest() {
echo 'is_resolvable_address: expected accept IPv6-mapped ::ffff:192.0.2.1' >&2 echo 'is_resolvable_address: expected accept IPv6-mapped ::ffff:192.0.2.1' >&2
return 1 return 1
fi fi
# Multi-line input must be rejected wholesale: awk # Multi-line input must be rejected wholesale (CodeRabbit/luna fold): awk
# reads newline-separated records, so a valid first record must not mask # reads newline-separated records, so a valid first record must not mask
# trailing garbage that would still reach nslookup. # trailing garbage that would still reach nslookup.
if is_resolvable_address "$(printf '192.0.2.1\nnot-an-address')"; then if is_resolvable_address "$(printf '192.0.2.1\nnot-an-address')"; then
@@ -849,7 +871,7 @@ Address: 127.0.0.1:53")
run_logging_selftest || return 1 run_logging_selftest || return 1
# Strict-mode smoke: status JSON uses soft-fail helpers # Strict-mode smoke (#291 C3): status JSON uses soft-fail helpers
# (uci miss, empty WAN zone). Must not abort under dash/ash set -e. # (uci miss, empty WAN zone). Must not abort under dash/ash set -e.
_status=$(build_logging_status_json) || { _status=$(build_logging_status_json) || {
echo "build_logging_status_json: aborted under set -e" >&2 echo "build_logging_status_json: aborted under set -e" >&2
@@ -950,7 +972,7 @@ case "${1:-}" in
build_rules_map build_rules_map
;; ;;
poll) poll)
# rpcd may omit argv $3 and pass JSON on stdin. # rpcd may omit argv $3 and pass JSON on stdin (#291 C3).
poll_logs "${3:-}" poll_logs "${3:-}"
;; ;;
resolve) resolve)
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2 PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.9.12 PKG_VERSION:=26.9.12
PKG_RELEASE:=123 PKG_RELEASE:=124
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
+28 -25
View File
@@ -2174,23 +2174,8 @@ msgstr "حذف قوانین %s کامل شد."
msgid "%s firewall rules load complete!" msgid "%s firewall rules load complete!"
msgstr "بارگذاری قوانین فایروال %s کامل شد!" msgstr "بارگذاری قوانین فایروال %s کامل شد!"
msgid "Socks switch detection: Unknown error." msgid "main node"
msgstr "تشخیص سوئیچ Socks: خطای ناشناخته." msgstr "گره اصلی"
msgid "Socks switch detection: Unable to connect to the network. Please check if the network is working properly!"
msgstr "تشخیص سوئیچ Socks: عدم توانایی در اتصال به شبکه. لطفاً بررسی کنید که شبکه به درستی کار می‌کند!"
msgid "Socks switch detection: Primary node 【%s: [%s]】 is normal. Switch to the primary node!"
msgstr "تشخیص سوئیچ Socks: گره اولیه 【%s: [%s]】 عادی است. سوئیچ به گره اولیه!"
msgid "Socks switch detection: %s node switch complete!"
msgstr "تشخیص سوئیچ Socks: سوئیچ گره %s کامل شد!"
msgid "Socks switch detection: %s 【%s:[%s]】 normal."
msgstr "تشخیص سوئیچ Socks: %s 【%s:[%s]】 عادی است."
msgid "switch to %s test detect!"
msgstr "سوئیچ به تست تشخیص %s!"
msgid "backup node" msgid "backup node"
msgstr "گره پشتیبان" msgstr "گره پشتیبان"
@@ -2198,17 +2183,35 @@ msgstr "گره پشتیبان"
msgid "next backup node" msgid "next backup node"
msgstr "گره پشتیبان بعدی" msgstr "گره پشتیبان بعدی"
msgid "main node" msgid "Socks switch detection: Port [%s] try %s [%s:%s]."
msgstr "گره اصلی" msgstr "تشخیص تغییر وضعیت سوئیچ: پورت [%s]، تلاش %s [%s:%s]."
msgid "Socks switch detection: Unknown error." msgid "Socks switch detection: Port [%s] [%s:[%s]] normal, switch to this node!"
msgstr "تشخیص سوئیچ Socks: خطای ناشناخته." msgstr "تشخیص تغییر وضعیت سوکت‌ها: پورت [%s] [%s:[%s]] در وضعیت عادی است، تغییر وضعیت به این گره!"
msgid "Socks switch detection: %s 【%s:[%s]】 abnormal, %s" msgid "Socks switch detection: Port [%s] node switch complete!"
msgstr "تشخیص سوئیچ Socks: %s 【%s:[%s]】 غیرعادی، %s" msgstr "تشخیص تغییر وضعیت سوکت‌ها: تغییر وضعیت گره در پورت [%s] تکمیل شد!"
msgid "Socks switch detection: %s 【%s:[%s]】 normal, switch to this node!" msgid "Socks switch detection: Port [%s] [%s:[%s]] abnormal."
msgstr "تشخیص سوئیچ Socks: %s 【%s:[%s]】 عادی، سوئیچ به این گره!" msgstr "تشخیص وضعیت سوئیچ: پورت [%s] [%s:[%s]] غیرعادی است."
msgid "Socks switch detection: Port [%s] all nodes are unavailable!"
msgstr "تشخیص تغییر وضعیت سوئیچ: در پورت [%s]، تمام گره‌ها در دسترس نیستند!"
msgid "Socks switch detection: Port [%s] Unknown error."
msgstr "تشخیص وضعیت سوئیچ جوراب: پورت [%s] خطای ناشناخته."
msgid "Socks switch detection: Port [%s] Unable to connect to the network. Please check if the network is working properly!"
msgstr "تشخیص تغییر وضعیت سوکت: پورت [%s]؛ امکان اتصال به شبکه وجود ندارد. لطفاً بررسی کنید که آیا شبکه به‌درستی کار می‌کند یا خیر."
msgid "Socks switch detection: Port [%s] Primary node [%s: [%s]] is normal. Switch to the primary node!"
msgstr "تشخیص تغییر وضعیت سوکت: وضعیت پورت [%s] و گره اصلی [%s: [%s]] عادی است. تغییر وضعیت به گره اصلی!"
msgid "Socks switch detection: Port [%s] [%s:[%s]] normal."
msgstr "تشخیص وضعیت سوئیچ: پورت [%s] [%s:[%s]] در وضعیت عادی است."
msgid "Socks switch detection: Port [%s] Number of backup nodes: %s"
msgstr "تشخیص سوئیچ سوئیچ شبکه: پورت [%s] تعداد گره‌های پشتیبان: %s"
msgid "Restart dnsmasq service." msgid "Restart dnsmasq service."
msgstr "راه‌اندازی مجدد سرویس dnsmasq." msgstr "راه‌اندازی مجدد سرویس dnsmasq."
+29 -23
View File
@@ -2175,23 +2175,8 @@ msgstr "Удаление правил %s завершено."
msgid "%s firewall rules load complete!" msgid "%s firewall rules load complete!"
msgstr "Загрузка правил фаервола %s завершена!" msgstr "Загрузка правил фаервола %s завершена!"
msgid "Socks switch detection: Unknown error." msgid "main node"
msgstr "Проверка переключения SOCKS: неизвестная ошибка." msgstr "основной сервер"
msgid "Socks switch detection: Unable to connect to the network. Please check if the network is working properly!"
msgstr "Проверка переключения SOCKS: не удалось подключиться к сети. Пожалуйста, проверьте работоспособность сети!"
msgid "Socks switch detection: Primary node 【%s: [%s]】 is normal. Switch to the primary node!"
msgstr "Проверка переключения SOCKS: основной сервер 【%s: [%s]】 работает нормально. Переключение на основной сервер!"
msgid "Socks switch detection: %s node switch complete!"
msgstr "Проверка переключения SOCKS: переключение сервера %s завершено!"
msgid "Socks switch detection: %s 【%s:[%s]】 normal."
msgstr "Проверка переключения SOCKS: %s 【%s:[%s]】 работает нормально."
msgid "switch to %s test detect!"
msgstr "Переключение на %s для проверки!"
msgid "backup node" msgid "backup node"
msgstr "резервный сервер" msgstr "резервный сервер"
@@ -2199,14 +2184,35 @@ msgstr "резервный сервер"
msgid "next backup node" msgid "next backup node"
msgstr "следующий резервный сервер" msgstr "следующий резервный сервер"
msgid "main node" msgid "Socks switch detection: Port [%s] try %s [%s:%s]."
msgstr "основной сервер" msgstr "Обнаружение переключения кабеля: порт [%s], попытка %s [%s:%s]."
msgid "Socks switch detection: %s 【%s:[%s]】 abnormal, %s" msgid "Socks switch detection: Port [%s] [%s:[%s]] normal, switch to this node!"
msgstr "Проверка переключения SOCKS: %s 【%s:[%s]】 не работает, %s" msgstr "Обнаружение переключения Socks: порт [%s] [%s:[%s]] в норме, переключение на этот узел!"
msgid "Socks switch detection: %s 【%s:[%s]】 normal, switch to this node!" msgid "Socks switch detection: Port [%s] node switch complete!"
msgstr "Проверка переключения SOCKS: %s 【%s:[%s]】 работает нормально, переключение на этот сервер!" msgstr "Обнаружение переключения соединений: переключение узла на порту [%s] завершено!"
msgid "Socks switch detection: Port [%s] [%s:[%s]] abnormal."
msgstr "Обнаружено переключение Socks: порт [%s] [%s:[%s]] работает некорректно."
msgid "Socks switch detection: Port [%s] all nodes are unavailable!"
msgstr "Обнаружено переключение коммутатора: порт [%s], все узлы недоступны!"
msgid "Socks switch detection: Port [%s] Unknown error."
msgstr "Обнаружение переключения разъема: порт [%s], неизвестная ошибка."
msgid "Socks switch detection: Port [%s] Unable to connect to the network. Please check if the network is working properly!"
msgstr "Обнаружено переключение Socks: порт [%s] — не удалось подключиться к сети. Пожалуйста, проверьте исправность сети!"
msgid "Socks switch detection: Port [%s] Primary node [%s: [%s]] is normal. Switch to the primary node!"
msgstr "Обнаружено переключение: порт [%s], основной узел [%s: [%s]] — состояние нормальное. Переключение на основной узел!"
msgid "Socks switch detection: Port [%s] [%s:[%s]] normal."
msgstr "Обнаружение переключения Socks: порт [%s] [%s:[%s]] — норма."
msgid "Socks switch detection: Port [%s] Number of backup nodes: %s"
msgstr "Обнаружение переключения коммутатора: порт [%s], количество резервных узлов: %s"
msgid "Restart dnsmasq service." msgid "Restart dnsmasq service."
msgstr "Перезапуск службы dnsmasq." msgstr "Перезапуск службы dnsmasq."
+28 -25
View File
@@ -2160,23 +2160,8 @@ msgstr "删除 %s 规则完成。"
msgid "%s firewall rules load complete!" msgid "%s firewall rules load complete!"
msgstr "%s 防火墙规则加载完成!" msgstr "%s 防火墙规则加载完成!"
msgid "Socks switch detection: Unknown error." msgid "main node"
msgstr "Socks切换检测:未知错误。" msgstr "主节点"
msgid "Socks switch detection: Unable to connect to the network. Please check if the network is working properly!"
msgstr "Socks切换检测:无法连接到网络,请检查网络是否正常!"
msgid "Socks switch detection: Primary node 【%s: [%s]】 is normal. Switch to the primary node!"
msgstr "Socks切换检测:%s 主节点【%s:[%s]】正常,切换到主节点!"
msgid "Socks switch detection: %s node switch complete!"
msgstr "Socks切换检测:%s 节点切换完毕!"
msgid "Socks switch detection: %s 【%s:[%s]】 normal."
msgstr "Socks切换检测:%s 【%s:[%s]】 正常。"
msgid "switch to %s test detect!"
msgstr "切换到 %s 检测!"
msgid "backup node" msgid "backup node"
msgstr "备用节点" msgstr "备用节点"
@@ -2184,17 +2169,35 @@ msgstr "备用节点"
msgid "next backup node" msgid "next backup node"
msgstr "下一个备用节点" msgstr "下一个备用节点"
msgid "main node" msgid "Socks switch detection: Port [%s] try %s [%s:%s]."
msgstr "主节点" msgstr "Socks切换检测:端口 [%s] 尝试 %s 【%s:%s】。"
msgid "Socks switch detection: Unknown error." msgid "Socks switch detection: Port [%s] [%s:[%s]] normal, switch to this node!"
msgstr "Socks切换检测:未知错误。" msgstr "Socks切换检测:端口 [%s] 【%s:[%s]】 正常,切换到此节点!"
msgid "Socks switch detection: %s 【%s:[%s]】 abnormal, %s" msgid "Socks switch detection: Port [%s] node switch complete!"
msgstr "Socks切换检测:%s 【%s:[%s]】 异常,%s" msgstr "Socks切换检测:端口 [%s] 节点切换完毕!"
msgid "Socks switch detection: %s 【%s:[%s]】 normal, switch to this node!" msgid "Socks switch detection: Port [%s] [%s:[%s]] abnormal."
msgstr "Socks切换检测:%s 【%s:[%s]】 正常,切换到此节点!" msgstr "Socks切换检测:端口 [%s] 异常。"
msgid "Socks switch detection: Port [%s] all nodes are unavailable!"
msgstr "Socks切换检测:端口 [%s] 所有节点均不可用!"
msgid "Socks switch detection: Port [%s] Unknown error."
msgstr "Socks切换检测:端口 [%s] 未知错误。"
msgid "Socks switch detection: Port [%s] Unable to connect to the network. Please check if the network is working properly!"
msgstr "Socks切换检测:端口 [%s] 无法连接到网络,请检查网络是否正常!"
msgid "Socks switch detection: Port [%s] Primary node [%s: [%s]] is normal. Switch to the primary node!"
msgstr "Socks切换检测:端口 [%s] 主节点【%s:[%s]】正常,切换到主节点!"
msgid "Socks switch detection: Port [%s] [%s:[%s]] normal."
msgstr "Socks切换检测:端口 [%s] 【%s:[%s]】 正常。"
msgid "Socks switch detection: Port [%s] Number of backup nodes: %s"
msgstr "Socks切换检测:端口 [%s] 后备节点数量:%s"
msgid "Restart dnsmasq service." msgid "Restart dnsmasq service."
msgstr "重启 dnsmasq 服务。" msgstr "重启 dnsmasq 服务。"
+28 -25
View File
@@ -2166,23 +2166,8 @@ msgstr "刪除 %s 規則完成。"
msgid "%s firewall rules load complete!" msgid "%s firewall rules load complete!"
msgstr "%s 防火墙規則加载完成!" msgstr "%s 防火墙規則加载完成!"
msgid "Socks switch detection: Unknown error." msgid "main node"
msgstr "Socks切換檢測:未知錯誤。" msgstr "主節點"
msgid "Socks switch detection: Unable to connect to the network. Please check if the network is working properly!"
msgstr "Socks切換檢測:無法連接到網絡,請檢查網絡是否正常!"
msgid "Socks switch detection: Primary node 【%s: [%s]】 is normal. Switch to the primary node!"
msgstr "Socks切換檢測:%s 主節點【%s:[%s]】正常,切換到主節點!"
msgid "Socks switch detection: %s node switch complete!"
msgstr "Socks切換檢測:%s 節點切換完毕!"
msgid "Socks switch detection: %s 【%s:[%s]】 normal."
msgstr "Socks切換檢測:%s 【%s:[%s]】 正常。"
msgid "switch to %s test detect!"
msgstr "切換到 %s 檢測!"
msgid "backup node" msgid "backup node"
msgstr "備用節點" msgstr "備用節點"
@@ -2190,17 +2175,35 @@ msgstr "備用節點"
msgid "next backup node" msgid "next backup node"
msgstr "下一個備用節點" msgstr "下一個備用節點"
msgid "main node" msgid "Socks switch detection: Port [%s] try %s [%s:%s]."
msgstr "主節點" msgstr "Socks切換檢測:連接埠 [%s] 嘗試 %s 【%s:%s】。"
msgid "Socks switch detection: Unknown error." msgid "Socks switch detection: Port [%s] [%s:[%s]] normal, switch to this node!"
msgstr "Socks切換檢測:未知錯誤。" msgstr "Socks切換偵測:連接埠 [%s] 【%s:[%s]】 正常,切換到此節點!"
msgid "Socks switch detection: %s 【%s:[%s]】 abnormal, %s" msgid "Socks switch detection: Port [%s] node switch complete!"
msgstr "Socks切換檢測:%s 【%s:[%s]】 异常,%s" msgstr "Socks切換偵測:連接埠 [%s] 節點切換完畢!"
msgid "Socks switch detection: %s 【%s:[%s]】 normal, switch to this node!" msgid "Socks switch detection: Port [%s] [%s:[%s]] abnormal."
msgstr "Socks切換檢測:%s 【%s:[%s]】 正常,切換到此節點!" msgstr "Socks切換偵測:連接埠 [%s] 異常。"
msgid "Socks switch detection: Port [%s] all nodes are unavailable!"
msgstr "Socks切換偵測:連接埠 [%s] 所有節點均不可用!"
msgid "Socks switch detection: Port [%s] Unknown error."
msgstr "Socks切換偵測:連接埠 [%s] 未知錯誤。"
msgid "Socks switch detection: Port [%s] Unable to connect to the network. Please check if the network is working properly!"
msgstr "Socks切換偵測:連接埠 [%s] 無法連接到網絡,請檢查網路是否正常!"
msgid "Socks switch detection: Port [%s] Primary node [%s: [%s]] is normal. Switch to the primary node!"
msgstr "Socks切換偵測:連接埠 [%s] 主節點【%s:[%s]】正常,切換到主節點!"
msgid "Socks switch detection: Port [%s] [%s:[%s]] normal."
msgstr "Socks切換檢測:連接埠 [%s] 【%s:[%s]】 正常。"
msgid "Socks switch detection: Port [%s] Number of backup nodes: %s"
msgstr "Socks切換偵測:連接埠 [%s] 後備節點數量:%s"
msgid "Restart dnsmasq service." msgid "Restart dnsmasq service."
msgstr "重啟 dnsmasq 服務。" msgstr "重啟 dnsmasq 服務。"
@@ -331,15 +331,10 @@ load_acl() {
[ -n "$dns_redirect_port" ] && { [ -n "$dns_redirect_port" ] && {
#nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE ip protocol udp udp dport 53 counter accept" #nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE ip protocol udp udp dport 53 counter accept"
#nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE ip protocol tcp tcp dport 53 counter accept" #nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE ip protocol tcp tcp dport 53 counter accept"
if [ "$PROXY_IPV6" == "1" ]; then #nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta l4proto udp udp dport 53 counter accept"
#nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta l4proto udp udp dport 53 counter accept" #nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta l4proto tcp tcp dport 53 counter accept"
#nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta l4proto tcp tcp dport 53 counter accept" nft "add rule $NFTABLE_NAME nat_output oif lo meta l4proto udp udp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\""
nft "add rule $NFTABLE_NAME nat_output oif lo meta l4proto udp udp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\"" nft "add rule $NFTABLE_NAME nat_output oif lo meta l4proto tcp tcp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\""
nft "add rule $NFTABLE_NAME nat_output oif lo meta l4proto tcp tcp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\""
else
nft "add rule $NFTABLE_NAME nat_output oif lo ip protocol udp udp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\""
nft "add rule $NFTABLE_NAME nat_output oif lo ip protocol tcp tcp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\""
fi
log 2 "${msg}$(i18n "DNS will redirected to the dedicated DNS server [%s]." "${dns_redirect_port}")" log 2 "${msg}$(i18n "DNS will redirected to the dedicated DNS server [%s]." "${dns_redirect_port}")"
} }
fi fi
@@ -504,26 +499,16 @@ load_acl() {
} }
if ([ -z "$no_tcp_proxy" ] || [ -z "$no_udp_proxy" ]) && [ -n "$dns_redirect_port" ]; then if ([ -z "$no_tcp_proxy" ] || [ -z "$no_udp_proxy" ]) && [ -n "$dns_redirect_port" ]; then
if [ "$PROXY_IPV6" == "1" ]; then nft "add rule $NFTABLE_NAME PSW2_MANGLE ip protocol udp ${_ipt_source} udp dport 53 counter accept"
nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto udp ${_ipt_source} udp dport 53 counter accept" nft "add rule $NFTABLE_NAME PSW2_MANGLE ip protocol tcp ${_ipt_source} tcp dport 53 counter accept"
nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto tcp ${_ipt_source} tcp dport 53 counter accept" nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto udp ${_ipt_source} udp dport 53 counter accept" 2>/dev/null
nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto udp ${_ipt_source} udp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\"" nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto tcp ${_ipt_source} tcp dport 53 counter accept" 2>/dev/null
nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto tcp ${_ipt_source} tcp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\"" nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto udp ${_ipt_source} udp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\""
else nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto tcp ${_ipt_source} tcp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\""
nft "add rule $NFTABLE_NAME PSW2_MANGLE ip protocol udp ${_ipt_source} udp dport 53 counter accept"
nft "add rule $NFTABLE_NAME PSW2_MANGLE ip protocol tcp ${_ipt_source} tcp dport 53 counter accept"
nft "add rule $NFTABLE_NAME PSW2_DNS ip protocol udp ${_ipt_source} udp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\""
nft "add rule $NFTABLE_NAME PSW2_DNS ip protocol tcp ${_ipt_source} tcp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\""
fi
log 2 "${msg}$(i18n "DNS will redirected to the dedicated DNS server [%s]." "${dns_redirect_port}")" log 2 "${msg}$(i18n "DNS will redirected to the dedicated DNS server [%s]." "${dns_redirect_port}")"
else else
if [ "$PROXY_IPV6" == "1" ]; then nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto udp ${_ipt_source} udp dport 53 counter return comment \"$remarks\"" 2>/dev/null
nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto udp ${_ipt_source} udp dport 53 counter return comment \"$remarks\"" nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto tcp ${_ipt_source} tcp dport 53 counter return comment \"$remarks\"" 2>/dev/null
nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto tcp ${_ipt_source} tcp dport 53 counter return comment \"$remarks\""
else
nft "add rule $NFTABLE_NAME PSW2_DNS ip protocol udp ${_ipt_source} udp dport 53 counter return comment \"$remarks\""
nft "add rule $NFTABLE_NAME PSW2_DNS ip protocol tcp ${_ipt_source} tcp dport 53 counter return comment \"$remarks\""
fi
fi fi
[ -z "$no_tcp_proxy" ] && [ -n "$redir_port" ] && { [ -z "$no_tcp_proxy" ] && [ -n "$redir_port" ] && {
@@ -828,6 +813,7 @@ add_firewall_rule() {
# jump chains # jump chains
# Only TCP, UDP Invalid. # Only TCP, UDP Invalid.
nft "add rule $NFTABLE_NAME mangle_prerouting meta nfproto ipv4 meta l4proto tcp socket transparent 1 mark set ${FWMARK} counter accept comment PSW2_SOCKET" nft "add rule $NFTABLE_NAME mangle_prerouting meta nfproto ipv4 meta l4proto tcp socket transparent 1 mark set ${FWMARK} counter accept comment PSW2_SOCKET"
nft "add rule $NFTABLE_NAME mangle_prerouting ip daddr != @$NFTSET_DIRECT ip protocol udp counter jump PSW2_MANGLE" nft "add rule $NFTABLE_NAME mangle_prerouting ip daddr != @$NFTSET_DIRECT ip protocol udp counter jump PSW2_MANGLE"
[ -n "${is_tproxy}" ] && nft "add rule $NFTABLE_NAME mangle_prerouting ip daddr != @$NFTSET_DIRECT ip protocol tcp counter jump PSW2_MANGLE" [ -n "${is_tproxy}" ] && nft "add rule $NFTABLE_NAME mangle_prerouting ip daddr != @$NFTSET_DIRECT ip protocol tcp counter jump PSW2_MANGLE"
@@ -899,9 +885,9 @@ add_firewall_rule() {
[ "$PROXY_IPV6" == "1" ] && { [ "$PROXY_IPV6" == "1" ] && {
# Only TCP, UDP Invalid. # Only TCP, UDP Invalid.
nft "add rule $NFTABLE_NAME mangle_prerouting meta nfproto ipv6 meta l4proto tcp socket transparent 1 mark set ${FWMARK} counter accept comment PSW2_SOCKET" nft "add rule $NFTABLE_NAME mangle_prerouting meta nfproto ipv6 meta l4proto tcp socket transparent 1 mark set ${FWMARK} counter accept comment PSW2_SOCKET"
nft "add rule $NFTABLE_NAME mangle_prerouting ip6 daddr != @$NFTSET_DIRECT6 meta nfproto {ipv6} counter jump PSW2_MANGLE_V6" nft "add rule $NFTABLE_NAME mangle_prerouting ip6 daddr != @$NFTSET_DIRECT6 meta nfproto {ipv6} counter jump PSW2_MANGLE_V6"
nft "add rule $NFTABLE_NAME mangle_output ip6 daddr != @$NFTSET_DIRECT6 meta nfproto {ipv6} counter jump PSW2_OUTPUT_MANGLE_V6 comment \"PSW2_OUTPUT_MANGLE\"" nft "add rule $NFTABLE_NAME mangle_output ip6 daddr != @$NFTSET_DIRECT6 meta nfproto {ipv6} counter jump PSW2_OUTPUT_MANGLE_V6 comment \"PSW2_OUTPUT_MANGLE\""
nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 ip6 daddr @$NFTSET_WAN6 counter return comment \"WAN6_IP_RETURN\"" nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 ip6 daddr @$NFTSET_WAN6 counter return comment \"WAN6_IP_RETURN\""
ip -6 rule add fwmark ${FWMARK} table 999 priority 999 ip -6 rule add fwmark ${FWMARK} table 999 priority 999
@@ -12,13 +12,11 @@ check_process() {
} }
test_url() { test_url() {
local url=$1 local url="$1"
local try=1 local try="${2:-1}"
[ -n "$2" ] && try=$2 local timeout="${3:-2}"
local timeout=2 local extra_params="$4"
[ -n "$3" ] && timeout=$3 local repeat="$5"
local extra_params=$4
local repeat=$5
if [ -z "$curl_retry_all_errors" ]; then if [ -z "$curl_retry_all_errors" ]; then
if /usr/bin/curl --help all | grep -q "\-\-retry-all-errors"; then if /usr/bin/curl --help all | grep -q "\-\-retry-all-errors"; then
@@ -29,23 +27,15 @@ test_url() {
local max_time=$((timeout * (try + 1) + try + 3)) local max_time=$((timeout * (try + 1) + try + 3))
curl_test() { curl_test() {
/usr/bin/curl -I -o /dev/null -skL ${extra_params} --max-time ${max_time} --connect-timeout ${timeout} --retry ${try} --retry-delay 1 -w "%{http_code}" "$url" /usr/bin/curl -skIL -o /dev/null ${extra_params} --max-time ${max_time} --connect-timeout ${timeout} --retry ${try} --retry-delay 1 -w "%{http_code}" "$url"
} }
local status=$(curl_test) local status=$(curl_test)
case "$status" in [ "$status" = "204" ] && status=200
204)
status=200
;;
esac
if [ "$status" = "200" ] && [ "$repeat" = "1" ]; then if [ "$status" = "200" ] && [ "$repeat" = "1" ]; then
sleep 3s sleep 3s
status=$(curl_test) status=$(curl_test)
case "$status" in [ "$status" = "204" ] && status=200
204)
status=200
;;
esac
fi fi
echo $status echo $status
} }
@@ -75,7 +65,7 @@ test_node() {
local _type=$(echo $(config_n_get ${node_id} type) | tr 'A-Z' 'a-z') local _type=$(echo $(config_n_get ${node_id} type) | tr 'A-Z' 'a-z')
[ -n "${_type}" ] && { [ -n "${_type}" ] && {
check_process check_process
local _tmp_port=$(get_new_port 48800) local _tmp_port=$(get_new_port 48800 tcp,udp)
NO_REC_PROCESS=1 $APP_FILE run_socks flag="test_node_${node_id}" node=${node_id} bind=127.0.0.1 socks_port=${_tmp_port} config_file=test_node_${node_id}.json NO_REC_PROCESS=1 $APP_FILE run_socks flag="test_node_${node_id}" node=${node_id} bind=127.0.0.1 socks_port=${_tmp_port} config_file=test_node_${node_id}.json
sleep 2s sleep 2s
local curlx="socks5h://127.0.0.1:${_tmp_port}" local curlx="socks5h://127.0.0.1:${_tmp_port}"
@@ -92,6 +82,61 @@ test_node() {
return 1 return 1
} }
try_switch_backup() {
local b_nodes="$1"
local now_node="$2"
local total tried
local new_node msg
local first_node found node
# Only one backup node, alternate with the primary node.
if [ "$backup_node_num" -eq 1 ]; then
b_nodes="$b_nodes $main_node"
fi
total=$(printf "%s\n" "$b_nodes" | wc -w)
tried=0
while [ "$tried" -lt "$total" ]; do
new_node=""
first_node=""
found=""
for node in $b_nodes; do
[ -z "$first_node" ] && first_node="$node" # Record first node.
[ "$found" = "1" ] && { new_node="$node"; break; } # Find the current node and then get the next one.
[ "$node" = "$now_node" ] && found=1 # flag the found current node.
done
# If the current node is not found, or if it is the last node, select the first node.
[ -z "$new_node" ] && new_node="$first_node"
local node_role node_type node_remarks
if [ "$new_node" = "$main_node" ]; then
node_role="$(i18n "main node")"
else
node_role="$(i18n "next backup node")"
fi
node_type=$(config_n_get $new_node type)
node_remarks=$(config_n_get $new_node remarks)
log_i18n 0 "Socks switch detection: Port [%s] try %s [%s:%s]." "${socks_port}" "${node_role}" "${node_type}" "${node_remarks}"
if test_node ${new_node}; then
check_process
log_i18n 0 "Socks switch detection: Port [%s] [%s:[%s]] normal, switch to this node!" "${socks_port}" "${node_type}" "${node_remarks}"
NO_REC_PROCESS=1 $APP_FILE socks_node_switch flag=${id} new_node=${new_node}
[ $? -eq 0 ] && {
log_i18n 0 "Socks switch detection: Port [%s] node switch complete!" "${socks_port}"
}
return 0
fi
log_i18n 0 "Socks switch detection: Port [%s] [%s:[%s]] abnormal." "${socks_port}" "${node_type}" "${node_remarks}"
now_node="$new_node"
tried=$((tried + 1))
done
log_i18n 0 "Socks switch detection: Port [%s] all nodes are unavailable!" "${socks_port}"
return 1
}
test_auto_switch() { test_auto_switch() {
flag=$((flag + 1)) flag=$((flag + 1))
local b_nodes=$1 local b_nodes=$1
@@ -100,18 +145,16 @@ test_auto_switch() {
if [ -n "$(get_cache_var "${id}")" ]; then if [ -n "$(get_cache_var "${id}")" ]; then
now_node=$(get_cache_var "${id}") now_node=$(get_cache_var "${id}")
else else
#log_i18n 0 "Socks switch detection: Unknown error." #log_i18n 0 "Socks switch detection: Port [%s] Unknown error." "${socks_port}"
return 1 return 1
fi fi
} }
[ $flag -le 1 ] && { [ $flag -le 1 ] && main_node=$now_node
main_node=$now_node
}
local status=$(test_proxy) local status=$(test_proxy)
if [ "$status" = "2" ]; then if [ "$status" = "2" ]; then
log_i18n 0 "Socks switch detection: Unable to connect to the network. Please check if the network is working properly!" log_i18n 0 "Socks switch detection: Port [%s] Unable to connect to the network. Please check if the network is working properly!" "${socks_port}"
return 2 return 2
fi fi
@@ -121,58 +164,23 @@ test_auto_switch() {
[ $? -eq 0 ] && { [ $? -eq 0 ] && {
check_process check_process
# The main node is working properly; switch to the main node. # The main node is working properly; switch to the main node.
log_i18n 0 "Socks switch detection: Primary node 【%s: [%s]】 is normal. Switch to the primary node!" "${id}" "$(config_n_get $main_node type)" "$(config_n_get $main_node remarks)" log_i18n 0 "Socks switch detection: Port [%s] Primary node [%s: [%s]] is normal. Switch to the primary node!" "${socks_port}" "$(config_n_get $main_node type)" "$(config_n_get $main_node remarks)"
$APP_FILE socks_node_switch flag=${id} new_node=${main_node} NO_REC_PROCESS=1 $APP_FILE socks_node_switch flag=${id} new_node=${main_node}
[ $? -eq 0 ] && { [ $? -eq 0 ] && {
log_i18n 0 "Socks switch detection: %s node switch complete!" "${id}" log_i18n 0 "Socks switch detection: Port [%s] node switch complete!" "${socks_port}"
} }
return 0 return 0
} }
fi fi
if [ "$status" = "0" ]; then [ "$status" = "0" ] && {
#log_i18n 0 "Socks switch detection: %s 【%s:[%s]】 normal." "${id}" "$(config_n_get $now_node type)" "$(config_n_get $now_node remarks)" #log_i18n 0 "Socks switch detection: Port [%s] [%s:[%s]] normal." "${socks_port}" "$(config_n_get $now_node type)" "$(config_n_get $now_node remarks)"
return 0 return 0
elif [ "$status" = "1" ]; then }
local new_node msg
if [ "$backup_node_num" -gt 1 ]; then log_i18n 0 "Socks switch detection: Port [%s] [%s:[%s]] abnormal." "${socks_port}" "$(config_n_get $now_node type)" "$(config_n_get $now_node remarks)"
# When there are multiple backup nodes try_switch_backup "$b_nodes" "$now_node"
local first_node found node return $?
for node in $b_nodes; do
[ -z "$first_node" ] && first_node="$node" # Record the first node.
[ "$found" = "1" ] && { new_node="$node"; break; } # Find the current node and then retrieve the next one.
[ "$node" = "$now_node" ] && found=1 # Mark the current node found.
done
# If the current node is not found, or if the current node is the last node, then take the first node.
[ -z "$new_node" ] && new_node="$first_node"
local msg2="$(i18n "next backup node")"
if [ "$new_node" = "$main_node" ]; then
msg2="$(i18n "main node")"
else
[ "$now_node" = "$main_node" ] && msg2="$(i18n "backup node")"
fi
msg="$(i18n "switch to %s test detect!" "${msg2}")"
else
# When there is only one backup node, poll with the primary node.
new_node=$([ "$now_node" = "$main_node" ] && echo "$b_nodes" || echo "$main_node")
local msg2="$(i18n "main node")"
[ "$now_node" = "$main_node" ] && msg2="$(i18n "backup node")"
msg="$(i18n "switch to %s test detect!" "${msg2}")"
fi
log_i18n 0 "Socks switch detection: %s 【%s:[%s]】 abnormal, %s" "${id}" "$(config_n_get $now_node type)" "$(config_n_get $now_node remarks)" "${msg}"
test_node ${new_node}
if [ $? -eq 0 ]; then
check_process
log_i18n 0 "Socks switch detection: %s 【%s:[%s]】 normal, switch to this node!" "${id}" "$(config_n_get $new_node type)" "$(config_n_get $new_node remarks)"
$APP_FILE socks_node_switch flag=${id} new_node=${new_node}
[ $? -eq 0 ] && {
log_i18n 0 "Socks switch detection: %s node switch complete!" "${id}"
}
return 0
else
test_auto_switch "${b_nodes}" ${new_node}
fi
fi
} }
start() { start() {
@@ -188,6 +196,7 @@ start() {
backup_node=$(lua_api "get_socks_backup_nodes(\"${id}\")") backup_node=$(lua_api "get_socks_backup_nodes(\"${id}\")")
if [ -n "$backup_node" ]; then if [ -n "$backup_node" ]; then
backup_node_num=$(printf "%s\n" "$backup_node" | wc -w) backup_node_num=$(printf "%s\n" "$backup_node" | wc -w)
log_i18n 0 "Socks switch detection: Port [%s] Number of backup nodes: %s" "${socks_port}" "${backup_node_num}"
if [ "$backup_node_num" -eq 1 ]; then if [ "$backup_node_num" -eq 1 ]; then
[ "$main_node" = "$backup_node" ] && return [ "$main_node" = "$backup_node" ] && return
elif [ "$backup_node_num" -gt 1 ]; then elif [ "$backup_node_num" -gt 1 ]; then
@@ -196,6 +205,7 @@ start() {
} }
fi fi
else else
log_i18n 0 "Socks switch detection: Port [%s] Number of backup nodes: %s" "${socks_port}" "0"
return return
fi fi
while [ -n "$backup_node" ]; do while [ -n "$backup_node" ]; do
@@ -46,10 +46,10 @@ url_test_node() {
local node_id=$1 local node_id=$1
local _type=$(echo $(config_n_get ${node_id} type) | tr 'A-Z' 'a-z') local _type=$(echo $(config_n_get ${node_id} type) | tr 'A-Z' 'a-z')
[ -n "${_type}" ] && { [ -n "${_type}" ] && {
local _tmp_port=$(get_new_port 48900) local _tmp_port=$(get_new_port 48900 tcp,udp)
NO_REC_PROCESS=1 /usr/share/${CONFIG}/app.sh run_socks flag="url_test_${node_id}" node=${node_id} bind=127.0.0.1 socks_port=${_tmp_port} config_file=url_test_${node_id}.json NO_REC_PROCESS=1 /usr/share/${CONFIG}/app.sh run_socks flag="url_test_${node_id}" node=${node_id} bind=127.0.0.1 socks_port=${_tmp_port} config_file=url_test_${node_id}.json
sleep 2s
local curlx="socks5h://127.0.0.1:${_tmp_port}" local curlx="socks5h://127.0.0.1:${_tmp_port}"
sleep 2s
local probeUrl=$(config_n_get @global_other[0] url_test_url https://www.google.com/generate_204) local probeUrl=$(config_n_get @global_other[0] url_test_url https://www.google.com/generate_204)
result=$(curl --connect-timeout 3 --max-time 5 -o /dev/null -I -skL -w "%{http_code}:%{time_pretransfer}" -x ${curlx} "${probeUrl}") result=$(curl --connect-timeout 3 --max-time 5 -o /dev/null -I -skL -w "%{http_code}:%{time_pretransfer}" -x ${curlx} "${probeUrl}")
# End the SS plugin process # End the SS plugin process
+1 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?= FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),) ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION) PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=74 PKG_RELEASE:=75
endif endif
LUCI_TITLE:=Footstrap Theme LUCI_TITLE:=Footstrap Theme
@@ -138,6 +138,16 @@ function clearViewIntervals() {
/* Map, not Set: the key is the timer id and the value is what it takes to re-arm it */ /* Map, not Set: the key is the timer id and the value is what it takes to re-arm it */
_viewIntervals.forEach((spec, id) => { if (id !== keep) window.clearInterval(id); }); _viewIntervals.forEach((spec, id) => { if (id !== keep) window.clearInterval(id); });
} }
/* The "Refreshing"/"Paused" pill must not outlive the poll it reports on — see the `poll-stop`
* listener below, which is the only caller in the shipped file. Exported (fs:probe) too, because
* navigate()'s own async chain (require(), staging into a real `.fs-content`) needs more DOM than
* tests/lib's fakes give it; this is the one check that actually matters for the ordering bug and is
* exercised directly, without the listener's deferral, by tests/poll-status.test.mjs. */
function hidePollIndicatorIfEmpty() {
if (!(L.Poll && L.Poll.queue && L.Poll.queue.length === 0)) return;
try { ui.hideIndicator('poll-status'); }
catch (e) { console.error('footstrap: hideIndicator threw on the poll-status teardown', e); }
}
/* one line per document: this runs on every navigation, and a router that cannot read L.Poll /* one line per document: this runs on every navigation, and a router that cannot read L.Poll
* cannot read it on the next click either */ * cannot read it on the next click either */
let _pollWarned = false; let _pollWarned = false;
@@ -1095,6 +1105,11 @@ function navigate(pathname, push, kbd) {
if (L.Poll && L.Poll.queue) { if (L.Poll && L.Poll.queue) {
L.Poll.queue.length = 0; L.Poll.queue.length = 0;
L.Poll.stop(); L.Poll.stop();
/* The "Refreshing"/"Paused" pill's own teardown rides this `stop()`'s `poll-stop` event —
* see the listener below, not a call here. A page can also empty the queue on its own, with
* no navigation at all (`L.Poll.remove()`, e.g. `luci-mod-status`'s graphs.js on unload,
* `luci-app-banip`'s log view), and `stop()` dispatches the same event for that call too;
* a call placed only here would miss it, exactly the finding this once shipped without. */
L.Poll.start(); L.Poll.start();
} }
/* kill the outgoing view's plain setInterval pollers too, as a full load would; L.Poll's own /* kill the outgoing view's plain setInterval pollers too, as a full load would; L.Poll's own
@@ -1401,24 +1416,42 @@ function wireRouter() {
}); });
} }
/* ---- the poll indicator must not outlive the poll ---- /* ---- the poll-status pill must not outlive the poll it reports on ----
* *
* LuCI shows the "Refreshing" pill on `poll-start`, flips it to "Paused" on `poll-stop` and never * LuCI shows the pill on `poll-start`, flips it to "Paused" on `poll-stop`, and never hides it
* hides it again (core calls ui.hideIndicator() only for `uci-changes`). That is invisible on a full * again. Two different callers empty the queue and reach `L.Poll.stop()`: this router's own
* load, since Poll.start() dispatches `poll-start` only for a non-empty queue — but this router * navigate() teardown, and a VIEW emptying its own queue mid-page with no navigation at all
* flushes the queue and calls stop() on every navigation, so walking from a polled page to an * (`L.Poll.remove()` — `luci-mod-status`'s Realtime Graphs on unload, `luci-app-banip`'s log
* unpolled one leaves a "Paused" pill reporting on a poll that does not exist. The pill exists iff * template) — both dispatch the same `poll-stop`, so one listener here covers both, where a call
* there is something to poll. Registered at module eval, i.e. after luci.js's own listener, so this * placed only in navigate()'s teardown covered the first and silently missed the second: reproduced
* runs second. */ * live, owrt2410b and owrt2512b, add-then-remove on Statistics — `{"active":false,"q":0,"pill":
* {"text":"Paused","clickable":true}}`, unchanged by a click, because nothing had ever asked to hide
* a pill for a poll that a `remove()` rather than a navigation had ended.
*
* The HIDE itself is deferred past the synchronous dispatch, one microtask, rather than run inline
* in this listener: `stop()` dispatches `poll-stop` synchronously to every listener registered at
* that moment, including luci.js's own (`showIndicator('poll-status', 'Paused', null, 'inactive')` —
* "Paused" is always shown with `handler: null`), and which of the two ran first here used to decide
* everything — luci.js registers its listener from `setupDOM()`, after an async chain
* (DOMContentLoaded + ui/rpc/form + probeRPCBaseURL), this module at eval, from the inline
* `L.require('menu-footstrap')` in `partials/footer.ut`, so network/cache timing picked the order.
* Run with this listener first, its hide removed the span before luci.js re-created it for "Paused"
* with no handler, and the next `poll-start` found that span already there and only changed its
* text — clickless for the rest of the document. A microtask runs only once the whole synchronous
* turn that dispatched the event has unwound, i.e. after every `poll-stop` listener already fired
* regardless of which ran first, so the check sees the state stock's listener actually left rather
* than racing it. It still reads `L.Poll.queue.length` at that later moment, not at dispatch time, so
* a queue the SAME navigation's incoming page has already refilled by then is left alone — the
* incoming page's own `require()`/render() is real async work (network, RPCs), always later than a
* microtask queued during the synchronous `stop()` call that preceded it. `tests/poll-status.test.mjs`
* drives both paths (a navigation's queue flush and a bare `Poll.remove()` to empty) in both listener
* orders. docs/spa-router.md, "Teardown". */
document.addEventListener('poll-stop', () => { document.addEventListener('poll-stop', () => {
if (L.Poll && L.Poll.queue && L.Poll.queue.length === 0) { queueMicrotask(hidePollIndicatorIfEmpty);
try { ui.hideIndicator('poll-status'); }
catch (e) { console.error('footstrap: hideIndicator threw on poll-stop', e); }
}
}); });
/* At module eval, like the listener above: the session can die during the first view's own data /* At module eval: the session can die during the first view's own data calls, before anything has
* calls, before anything has called wire(), and an interceptor registered later never sees it. */ * called wire(), and an interceptor registered later never sees it. */
watchSession(); watchSession();
/* Pause LuCI's 1 s poll loop while the tab is hidden: LuCI has no visibilitychange handler, so an /* Pause LuCI's 1 s poll loop while the tab is hidden: LuCI has no visibilitychange handler, so an
@@ -1470,6 +1503,10 @@ return baseclass.extend({
* navigate() is the real caller of the first and `_expired` gates the second. */ * navigate() is the real caller of the first and `_expired` gates the second. */
clearViewIntervals, /* fs:probe */ clearViewIntervals, /* fs:probe */
sessionExpired, /* fs:probe */ sessionExpired, /* fs:probe */
/* tests/poll-status.test.mjs: the deferred `poll-stop` listener's own check, called directly so a
* test need not also fake a microtask tick to see it fire (see the comment at its definition and
* at the listener above) */
hidePollIndicatorIfEmpty, /* fs:probe */
/* fs-search warms its recents and the arrow-key-highlighted result, neither of which the /* fs-search warms its recents and the arrow-key-highlighted result, neither of which the
* pointer/focus triggers above can see. The edge points search -> router, because the router * pointer/focus triggers above can see. The edge points search -> router, because the router
* must keep no dependency on the palette. */ * must keep no dependency on the palette. */
@@ -258,13 +258,21 @@
} }
/* The frame is overflow:visible, so nothing clips the square corner cells to its radius and /* The frame is overflow:visible, so nothing clips the square corner cells to its radius and
* they poked past it. Round the four OUTER cells — the frame's corners, not a per-row pill. * they poked past it. Round the four OUTER cells — the frame's corners, not a per-row pill.
* `#view .table[id]` is listed because the frame rule draws the border there too. */ * `#view .table[id]` is listed because the frame rule draws the border there too.
.cbi-section .table[id] .tr.table-titles .th:first-child, *
#view .table[id] .tr.table-titles .th:first-child, * `:is(.tr.table-titles, .thead)` because a header is any of the shapes fs-select.js adopts:
table.fs-dt .tr.table-titles .th:first-child { border-top-left-radius: var(--fs-radius-lg); } * `.tr.table-titles` is the bare-th-row markup, `.thead` covers a real `<thead>` whether it
.cbi-section .table[id] .tr.table-titles .th:last-child, * holds its `<th>`s directly (E()-built, no `<tr>`) or through one — adoptMarkup() never adds
#view .table[id] .tr.table-titles .th:last-child, * `.tr` to that inner row, so a class-only selector has to reach the `.th` by descent, not by
table.fs-dt .tr.table-titles .th:last-child { border-top-right-radius: var(--fs-radius-lg); } * child. Without `.thead` the header kept its square top corners under any real `<thead>`: the
* DHCP and Wi-Fi dashboard tables measured a first/last `.th` radius of 0 against the frame's
* 12px. */
.cbi-section .table[id] :is(.tr.table-titles, .thead) .th:first-child,
#view .table[id] :is(.tr.table-titles, .thead) .th:first-child,
table.fs-dt :is(.tr.table-titles, .thead) .th:first-child { border-top-left-radius: var(--fs-radius-lg); }
.cbi-section .table[id] :is(.tr.table-titles, .thead) .th:last-child,
#view .table[id] :is(.tr.table-titles, .thead) .th:last-child,
table.fs-dt :is(.tr.table-titles, .thead) .th:last-child { border-top-right-radius: var(--fs-radius-lg); }
.cbi-section .table[id] .tr:last-child .td:first-child, .cbi-section .table[id] .tr:last-child .td:first-child,
#view .table[id] .tr:last-child .td:first-child, #view .table[id] .tr:last-child .td:first-child,
table.fs-dt .tr:last-child .td:first-child { border-bottom-left-radius: var(--fs-radius-lg); } table.fs-dt .tr:last-child .td:first-child { border-bottom-left-radius: var(--fs-radius-lg); }
@@ -473,6 +481,19 @@
.table .tr.cbi-section-table-titles::before { border-bottom: var(--fs-hairline); } .table .tr.cbi-section-table-titles::before { border-bottom: var(--fs-hairline); }
.table .tr:not(.table-titles):not(.cbi-section-table-titles):not(:last-child) > .td, .table .tr:not(.table-titles):not(.cbi-section-table-titles):not(:last-child) > .td,
.table .tr.cbi-section-table-row:not(:last-child)::before { border-bottom: var(--fs-hairline); } .table .tr.cbi-section-table-row:not(:last-child)::before { border-bottom: var(--fs-hairline); }
/* Because the separator above is a per-CELL border, an app that nudges one cell with
* `position: relative; top: …` (luci-mod-dashboard's `.devices-info .td:nth-child(3)`) drags
* that cell's border-bottom out of line with its row neighbours — a step, not a slanted line,
* since each cell paints its own hairline. Bootstrap never shows this because its rows are
* `border-collapse: collapse`, with no per-cell border to displace. The app's rule lands in the
* same fenced `theme` layer (fs-sheets.js), so an ID root outranks its 7 classes on specificity
* alone and needs no `!important`; `top` AND `bottom`, not just `top: 0`, so this also cancels
* a `bottom` offset and leaves `position: relative` itself untouched for any
* absolutely-positioned descendant. `top`/`bottom`, not the `inset-block` shorthand: the sheet
* has never used a logical inset property before and `css-floor` has no support decision for
* one, where the physical pair is already on the floor. Measured: offset .td top -3px -> 0, its
* border-bottom back level with its neighbours'. */
:is(#view, #modal_overlay) .fs-dt .td { top: auto; bottom: auto; }
/* descendant combinator (not >) so a real <table>'s browser-inserted <tbody> is covered /* descendant combinator (not >) so a real <table>'s browser-inserted <tbody> is covered
* too — one rule themes every data table, div- or table-based. */ * too — one rule themes every data table, div- or table-based. */
.table .tr.table-titles, .table .tr.cbi-section-table-titles { .table .tr.table-titles, .table .tr.cbi-section-table-titles {
+92 -63
View File
@@ -138,11 +138,19 @@
/* A raw checkbox inside a button-styled label sat flush against its text: base zeroes the /* A raw checkbox inside a button-styled label sat flush against its text: base zeroes the
* checkbox margin (30-forms.css) and only a literal space separated the ✓ from the words * checkbox margin (30-forms.css) and only a literal space separated the ✓ from the words
* (issue #5.1 — attendedsysupgrade's "Keep settings" confirm: label.btn > input[type=checkbox] * (issue #5.1 — attendedsysupgrade's "Keep settings" confirm: label.btn > input[type=checkbox]
* + text). Restore a gap. The toggle-switch .cbi-checkbox below is unaffected — its input is * + text). Restore a gap. The toggle-switch .cbi-checkbox below is unaffected — its own
* opacity:0, so this margin moves nothing there. */ * margin: 0 outranks this rule by specificity ((0,2,1) vs this rule's (0,2,1) on a DIFFERENT
* ancestor class, so the two never both match one element: `.cbi-checkbox` is never `.btn`). */
.btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; } .btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; }
/* checkbox -> toggle switch /* checkbox -> toggle switch, drawn on the INPUT itself.
*
* Was drawn on label[for] with the real input hidden (position:absolute; opacity:0; width/
* height:0) — forum.openwrt.org/t/251930 post #141: luci-app-modemdata (and any third-party CSS
* built the way bootstrap expects, styling the native input) injects an unlayered
* `label[for] { display: none }` flagged important, scoped to its own markup, which left
* NOTHING visible, since the input it left alone was the invisible one. Drawing on the input
* means an app that hides label[for] hides an already-empty placeholder, not the switch.
* *
* The pill, the knob and the knob's travel are all on --fs-density-box. As bare literals they * The pill, the knob and the knob's travel are all on --fs-density-box. As bare literals they
* make LuCI's most common control the only one in the theme that ignores the Density axis, while * make LuCI's most common control the only one in the theme that ignores the Density axis, while
@@ -154,25 +162,19 @@
* *
* flex-shrink: 0 below (task 0145): `.cbi-checkbox` is the flex row, and a caption after the * flex-shrink: 0 below (task 0145): `.cbi-checkbox` is the flex row, and a caption after the
* pill — `label.cbi-checkbox > input + label[for] + TEXT`, package-manager.js's and banip's real * pill — `label.cbi-checkbox > input + label[for] + TEXT`, package-manager.js's and banip's real
* markup, not the caption-less div the gallery had — makes the pill a SECOND, shrinkable flex * markup — makes the input (now the real, sized flex item; label[for] no longer is one, below) a
* item. Unshrunk it stays exactly --sw-w; measured shrunk on the live Install dialog on both * SECOND, shrinkable flex item. Unshrunk it stays exactly --sw-w; measured shrunk on the live
* stands, RU/Normal/390px gave a used width of 30.17px against 40 declared and a knob 6.83px past * Install dialog on both stands when this box was still label[for]'s (task 0145): RU/Normal/390px
* the pill's right edge, EN/Large/360px 36.22 against 46 declared and a 6.47px overhang — * gave a used width of 30.17px against 40 declared and a knob 6.83px past the pill's right edge,
* flex-shrink: 0 alone took both to -3.00px and -3.31px (the knob safely inside the pill, not * EN/Large/360px 36.22 against 46 declared and a 6.47px overhang — flex-shrink: 0 alone took both
* flush with it), so a second guard (e.g. min-width: var(--sw-w), redundant with a non-shrinking * to -3.00px and -3.31px (the knob safely inside the pill, not flush with it). The flex row and
* flex-basis) was not needed and does not ship. * its sibling caption are unchanged by which element carries the pill, so the guard carries over.
* *
* Scoped to `.cbi-checkbox > label[for]`, not a bare `.cbi-checkbox > label`: ui.js's * label[for] stays in the DOM (ui.js and every third-party app that builds this markup emit it)
* Checkbox.render() appends a SECOND label when `tooltip` is set (form.Flag.tooltip — e.g. * but draws and occupies nothing: `[for]` still discriminates it from the SECOND label
* luci-app-firewall's zones.js on `masq`), `label.cbi-tooltip-container` holding the hint icon, * Checkbox.render() appends when `tooltip` is set (form.Flag.tooltip — e.g. luci-app-firewall's
* and the bare selector painted it as a second switch too — measured on the gallery's tooltip * zones.js on `masq`), `label.cbi-tooltip-container` holding the hint icon, which must keep
* fixture, both labels 40x22 at rgb(55,62,71), flush against each other with no gap. `[for]` is * rendering. */
* the discriminator rather than `input[type="checkbox"] + label`: Checkbox.render() always
* builds the switch label as `E('label', { 'for': id })` and the tooltip label with no `for` at
* all, so the attribute alone tells the two apart, at a fifth of the bytes — the sibling
* combinator repeated across five rules (this one, its ::before/::after, and the two
* forced-colors rules in 95-a11y-media.css) put cascade.css 186 B over its budget; rescoping
* dropped it back under. */
/* `overflow-wrap: anywhere`: the caption after the pill is a plain text flex item, which already /* `overflow-wrap: anywhere`: the caption after the pill is a plain text flex item, which already
* wraps at spaces on its own — this is only for the one WORD that does not fit even at the row's * wraps at spaces on its own — this is only for the one WORD that does not fit even at the row's
* own full width. Measured on this exact caption (see the pill's own comment below): pseudo-loc * own full width. Measured on this exact caption (see the pill's own comment below): pseudo-loc
@@ -181,31 +183,45 @@
* is not implausible (the theme already ships an untranslated one), so the row should absorb it * is not implausible (the theme already ships an untranslated one), so the row should absorb it
* by breaking the word, not by escaping its column. */ * by breaking the word, not by escaping its column. */
.cbi-checkbox { display: inline-flex; align-items: center; gap: var(--fs-space-2); overflow-wrap: anywhere; } .cbi-checkbox { display: inline-flex; align-items: center; gap: var(--fs-space-2); overflow-wrap: anywhere; }
.cbi-checkbox > input[type="checkbox"] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; } /* label[for] takes over the input's old trick (position:absolute, zero box, opacity:0): out of
.cbi-checkbox > label[for] { * flow and out of the flex item count, so `.cbi-checkbox`'s `gap` still opens on only the SAME
* two neighbours it always did — see the tooltip-container note below. */
.cbi-checkbox > label[for] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
.cbi-checkbox > input[type="checkbox"] {
--sw-w: calc(40px * var(--fs-density-box)); --sw-w: calc(40px * var(--fs-density-box));
--sw-h: calc(22px * var(--fs-density-box)); --sw-h: calc(22px * var(--fs-density-box));
--sw-knob: calc(16px * var(--fs-density-box)); --sw-knob: calc(16px * var(--fs-density-box));
--sw-pad: calc(2px * var(--fs-density-box)); --sw-pad: calc(2px * var(--fs-density-box));
/* `appearance: none` and this compound's other unstated properties (color, the invalid
* --bd-color/--fg-color custom props) still come from the bare `input[type="checkbox"]`
* rule further down, written for a third-party app's raw markup: harmless to share, since
* this input never reads --bd-color/--fg-color. The parts of that rule that WOULD paint
* over this switch (the drawn box, the mark, the focus ring) are scoped with
* `:not(:where(.cbi-checkbox > input))` at their own selector instead of being undone here —
* cheaper than restating every property this rule already states for the opposite reason,
* and it is one property, not eight, that both rules would otherwise fight over. */
display: inline-block; position: relative; margin: 0; flex-shrink: 0; display: inline-block; position: relative; margin: 0; flex-shrink: 0;
width: var(--sw-w); height: var(--sw-h); border-radius: var(--fs-radius-pill); width: var(--sw-w); height: var(--sw-h); border-radius: var(--fs-radius-pill);
background: var(--fs-track); border: var(--fs-hairline); background: var(--fs-track); border: var(--fs-hairline);
cursor: pointer; transition: background var(--fs-dur), border-color var(--fs-dur); vertical-align: middle; cursor: pointer; transition: background var(--fs-dur), border-color var(--fs-dur); vertical-align: middle;
} }
/* The input is opacity:0 at 0x0, so this label IS the whole hit box of LuCI's most common /* The invisible overlay stretching the hit target to 44x30 (WCAG 2.5.8: the pill is 22px tall,
* control — and at 22px tall it sits under the 24px floor of WCAG 2.5.8. The floor is about the * under the 24px floor) — moved here from label[for] unchanged. position:absolute, so it adds
* CLICKABLE area, not the drawing: an invisible overlay stretches the target to 44x30 without * nothing to layout; the knob keeps ::after.
* fattening the pill (a visual 44x24 was tried and looked bloated). position:absolute, so it *
* adds nothing to layout; the knob keeps ::after. * `content`/`position` come from the raw checkbox's own shared `::before, ::after { position:
* absolute; content: "" }` rule further down — left UNSCOPED and reused here rather than
* restated, since both pseudo-elements need exactly those two values anyway. The two rules
* that would otherwise paint a drawn box and a mark ON this pseudo (their own selectors,
* further down) are scoped with `:not(:where(.cbi-checkbox > input))` instead, so nothing is left to
* override here.
* *
* The inset stays literal while the pill above scales: this is an accessibility floor, and a * The inset stays literal while the pill above scales: this is an accessibility floor, and a
* floor that shrinks with a density setting is not one. At Compact the target is still 38x26.7, * floor that shrinks with a density setting is not one. At Compact the target is still 38x26.7,
* over the minimum. */ * over the minimum. */
.cbi-checkbox > label[for]::before { .cbi-checkbox > input[type="checkbox"]::before { inset: -4px -2px; }
content: ""; position: absolute; inset: -4px -2px; .cbi-checkbox > input[type="checkbox"]::after {
} top: var(--sw-pad); inset-inline-start: var(--sw-pad);
.cbi-checkbox > label[for]::after {
content: ""; position: absolute; top: var(--sw-pad); inset-inline-start: var(--sw-pad);
width: var(--sw-knob); height: var(--sw-knob); border-radius: 50%; width: var(--sw-knob); height: var(--sw-knob); border-radius: 50%;
background: var(--fs-dim); background: var(--fs-dim);
/* transform, not `left`: `left` is a layout property, so the knob re-laid out the row on /* transform, not `left`: `left` is a layout property, so the knob re-laid out the row on
@@ -213,47 +229,58 @@
transform: translateX(0); transform: translateX(0);
transition: transform var(--fs-dur-move), background var(--fs-dur); transition: transform var(--fs-dur-move), background var(--fs-dur);
} }
/* The tooltip-container label (above) is now unstyled by the switch rules, so it falls back to /* The tooltip-container label (above) is unstyled by the switch rules and falls back to the
* the browser's plain inline label plus base's `cursor: help` — the icon glyph, no pill. It still * browser's plain inline label plus base's `cursor: help` — the icon glyph, no pill. It still
* needs a gap from the switch, and `.cbi-checkbox`'s own `gap` above gives it one instead of a * needs a gap from the switch, and `.cbi-checkbox`'s own `gap` above gives it one instead of a
* margin declared here: the checkbox input inside that flex row is `position: absolute`, so it is * margin declared here: label[for] inside that flex row is `position: absolute` (above), so it
* never a flex item, and `gap` only ever opens BETWEEN flex items — an ordinary `.cbi-checkbox` * is never a flex item, and `gap` only ever opens BETWEEN flex items — an ordinary `.cbi-checkbox`
* with just the switch label has one flex item and gets no gap at all (measured: cb1's box is * with just the switch input has one flex item (the input itself) and gets no gap at all, while
* still 40px wide, unchanged), while the tooltip fixture's two labels get exactly one. --fs-space-2 * the tooltip fixture's input+tooltip-label pair gets exactly one. --fs-space-2 (8px), same
* (8px), same number the removed margin used — the gap the theme already uses beside an inline * number the removed margin used — the gap the theme already uses beside an inline checkbox
* checkbox glyph (`.btn > input[type="checkbox"]` above) — measured on the gallery fixture: top * glyph (`.btn > input[type="checkbox"]` above). The flex-item COUNT and the gap token are the
* edges 0.5px apart (the icon glyph's own line box, not this gap) and left-to-right edge 8px, * same as before this rule moved from label[for] to the input; only which element is the
* both unchanged from before this rescope. */ * absolute one swapped. */
.cbi-checkbox > input[type="checkbox"]:checked + label { background: var(--fs-accent); border-color: transparent; } .cbi-checkbox > input[type="checkbox"]:checked { background: var(--fs-accent); border-color: transparent; }
/* The travel is derived rather than hand-written, so it cannot drift out of step with the three /* The travel is derived rather than hand-written, so it cannot drift out of step with the three
* sizes above. The `- 2px` is the pill's two 1px BORDERS and is the whole subtlety: --sw-w is a * sizes above. The `- 2px` is the pill's two 1px BORDERS and is the whole subtlety: --sw-w is a
* border-box width, while an absolutely-positioned ::after is placed against the PADDING box, so * border-box width, while an absolutely-positioned ::after is placed against the PADDING box, so
* the knob has 2px less room and deriving without that term lands it off-centre — the travel * the knob has 2px less room and deriving without that term lands it off-centre — the travel
* comes out 20px where symmetry wants 18. The border does not scale with density, so the term * comes out 20px where symmetry wants 18. The border does not scale with density, so the term
* stays literal. */ * stays literal. */
.cbi-checkbox > input[type="checkbox"]:checked + label::after { .cbi-checkbox > input[type="checkbox"]:checked::after {
transform: translateX(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px)); transform: translateX(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px));
background: var(--fs-on-accent); background: var(--fs-on-accent);
} }
/* The real checkbox is opacity:0 at 0x0 above, so base's `input:focus::before` glow paints on a /* The bare fallback ring further down (`input:focus`, for every untyped/unlisted input, and its
* 0x0 box. The <label> IS the switch, so the ring goes there, or the most common control in LuCI * `input[type="checkbox"]:focus::before` twin) is scoped with `:not(:where(.cbi-checkbox > input))` at
* shows nothing under keyboard focus. * its own selector, so a plain click on this real, visible input rings nothing — as it never
* did before this input was the drawn element, since only :focus-visible below is meant to. */
/* The <input> IS the switch now, so the ring goes there directly, or the most common control in
* LuCI shows nothing under keyboard focus.
* *
* -solo, because on the CHECKED switch — half the switches on any page — neither other channel * -solo, because on the CHECKED switch — half the switches on any page — neither other channel
* exists: the rule above fills the track with --fs-accent and sets `border-color: transparent`, * exists: the rule above fills the track with --fs-accent and sets `border-color: transparent`,
* so the border flip is accent on accent (measured: fill rgb(86,157,245) against a border of the * so the border flip is accent on accent (measured: fill rgb(86,157,245) against a border of the
* same accent at .97 alpha) and the tinted ring is accent at .15 alpha. */ * same accent at .97 alpha) and the tinted ring is accent at .15 alpha. */
.cbi-checkbox > input[type="checkbox"]:focus-visible + label { .cbi-checkbox > input[type="checkbox"]:focus-visible {
border-color: var(--fs-accent); border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring-solo); box-shadow: var(--fs-focus-ring-solo);
} }
.cbi-checkbox > input[type="checkbox"]:disabled + label { opacity: var(--fs-disabled-opacity); cursor: not-allowed; } .cbi-checkbox > input[type="checkbox"]:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
/* Raw checkbox/radio — the drawing, absorbed from base whole: it undoes the generic `input` box /* Raw checkbox/radio — the drawing, absorbed from base whole: it undoes the generic `input` box
* below, and left in base every one of those overrides would lose to that rule by layer. The box * below, and left in base every one of those overrides would lose to that rule by layer. The box
* is 16px on the grid and the tick or dot is inset FROM it rather than restating its own number, * is 16px on the grid and the tick or dot is inset FROM it rather than restating its own number,
* so resizing the box cannot leave the mark off-centre. LuCI's own forms wrap checkboxes in the * so resizing the box cannot leave the mark off-centre. LuCI's own forms wrap checkboxes in the
* .cbi-checkbox toggle above; the raw drawing is what a third-party app's bare markup gets. */ * .cbi-checkbox toggle above; the raw drawing is what a third-party app's bare markup gets.
*
* `.cbi-checkbox > input` matches the switch's own input too: this base rule stays unscoped for
* it, since the switch's own rules above want its `appearance: none` and read neither
* --bd-color nor --fg-color. But the drawn box, the mark and the focus ring below DO paint over
* the switch once its input is a real, visible element rather than 0x0 — each of those three
* selectors carries `:not(:where(.cbi-checkbox > input))` for that reason alone; undoing them instead,
* property by property, on the switch's own rules costs more than the one exclusion clause
* each needs. */
input[type="checkbox"], input[type="checkbox"],
input[type="radio"] { input[type="radio"] {
--bd-color: var(--fs-border); --bd-color: var(--fs-border);
@@ -307,7 +334,7 @@
position: absolute; position: absolute;
content: ""; content: "";
} }
input[type="checkbox"]::before, input[type="checkbox"]:not(:where(.cbi-checkbox > input))::before,
input[type="radio"]::before { input[type="radio"]::before {
/* centred in the 24px target, and direction-agnostic: `inset: 0; margin: auto` on a box with /* centred in the 24px target, and direction-agnostic: `inset: 0; margin: auto` on a box with
* a fixed size is the one centring that needs no translate and therefore behaves the same in * a fixed size is the one centring that needs no translate and therefore behaves the same in
@@ -333,7 +360,7 @@
* of ringing a clicked box. Both states (plain and invalid) are declared once, further * of ringing a clicked box. Both states (plain and invalid) are declared once, further
* down this file, in the selector lists every other same-body ring joins — see the two * down this file, in the selector lists every other same-body ring joins — see the two
* `--fs-focus-ring`/`--fs-focus-ring-invalid` rules below the text-input group. */ * `--fs-focus-ring`/`--fs-focus-ring-invalid` rules below the text-input group. */
input[type="checkbox"]::after, input[type="checkbox"]:not(:where(.cbi-checkbox > input))::after,
input[type="radio"]::after { input[type="radio"]::after {
/* the mark is inset from the DRAWING, and the drawing is centred in the target, so the mark /* the mark is inset from the DRAWING, and the drawing is centred in the target, so the mark
* centres the same way rather than measuring from the target's own edge */ * centres the same way rather than measuring from the target's own edge */
@@ -342,7 +369,7 @@
width: calc(var(--box) - var(--mark-inset) * 2); width: calc(var(--box) - var(--mark-inset) * 2);
height: calc(var(--box) - var(--mark-inset) * 2); height: calc(var(--box) - var(--mark-inset) * 2);
} }
input[type="checkbox"]:checked::after, input[type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
input[type="radio"]:checked::after { input[type="radio"]:checked::after {
/* OURS. Drawn as a STROKE, which is this theme's icon language (see --spinner-icon in /* OURS. Drawn as a STROKE, which is this theme's icon language (see --spinner-icon in
* base/95-luci.css and --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid). * base/95-luci.css and --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid).
@@ -626,15 +653,16 @@
textarea:hover { border-color: var(--fs-dim); } textarea:hover { border-color: var(--fs-dim); }
/* bare `input:focus` absorbed from base's generic glow onto the same ring: it is the fallback /* bare `input:focus` absorbed from base's generic glow onto the same ring: it is the fallback
* for the types the list does not name and for a typeless <input>; on checkbox/radio it lands * for the types the list does not name and for a typeless <input>; on a bare checkbox/radio it
* on the invisible element box UNDER the drawn ::before ring above — coincident boxes, one * lands on the drawn box UNDER the ::before ring below — coincident boxes, one visible ring, as
* visible ring, as the old glow behaved. `select:focus` and the keyboard-removable dynlist * the old glow behaved. `:not(:where(.cbi-checkbox > input))` excludes the toggle switch, whose input is
* chip (ui.js gives it a tabindex) join here rather than repeating the same three * a real, visible element with its own :focus-visible-only ring (above) — a plain click on it
* declarations under their own selector: one shared body instead of three, 149 B off * must ring nothing. `select:focus` and the keyboard-removable dynlist chip (ui.js gives it a
* cascade.css. */ * tabindex) join here rather than repeating the same three declarations under their own
* selector: one shared body instead of three, 149 B off cascade.css. */
.cbi-input-text:focus, .cbi-input-password:focus, .cbi-input-textarea:focus, .cbi-input-text:focus, .cbi-input-password:focus, .cbi-input-textarea:focus,
.cbi-section-create-name:focus, .cbi-section-create-name:focus,
input:focus, input:focus:not(:where(.cbi-checkbox > input)),
input[type="text"]:focus, input[type="password"]:focus, input[type="number"]:focus, input[type="text"]:focus, input[type="password"]:focus, input[type="number"]:focus,
input[type="email"]:focus, input[type="url"]:focus, input[type="search"]:focus, input[type="email"]:focus, input[type="url"]:focus, input[type="search"]:focus,
input[type="tel"]:focus, input[type="date"]:focus, input[type="time"]:focus, input[type="tel"]:focus, input[type="date"]:focus, input[type="time"]:focus,
@@ -651,8 +679,9 @@
* off cascade.css. `:is()` around the `:has()` compound keeps the list forgiving: unwrapped, a * off cascade.css. `:is()` around the `:has()` compound keeps the list forgiving: unwrapped, a
* `:has()` compound sharing a selector list with plain ones fails the WHOLE rule below the * `:has()` compound sharing a selector list with plain ones fails the WHOLE rule below the
* floor (Firefox < 121; docs/css.md "Two shapes fail it"), instead of just losing that one * floor (Firefox < 121; docs/css.md "Two shapes fail it"), instead of just losing that one
* selector. */ * selector. `:not(:where(.cbi-checkbox > input))` on the checkbox part for the same reason as the plain
input[type="checkbox"]:focus::before, input[type="radio"]:focus::before, * ring above: the toggle switch's own :focus-visible rule is the only one allowed to ring it. */
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):focus::before, input[type="radio"]:focus::before,
.cbi-select.focus, .cbi-select.focus,
.cbi-dynlist > .add-item:focus-within, .cbi-dynlist > .add-item:focus-within,
.control-group:is(:has(> .cbi-input-password)):focus-within { .control-group:is(:has(> .cbi-input-password)):focus-within {
@@ -116,17 +116,18 @@
padding-inline-start: calc(var(--fs-space-3) - 3px); padding-inline-start: calc(var(--fs-space-3) - 3px);
} }
/* the toggle switch is a bare rounded box in forced colors — give the knob and /* the toggle switch is a bare rounded box in forced colors — give the knob and
* the track an edge so on/off is distinguishable at all. Scoped via `label[for]`, matching * the track an edge so on/off is distinguishable at all. Scoped via `input[type="checkbox"]`,
* 60-inputs.css: a bare `.cbi-checkbox > label` also reaches ui.Checkbox's `tooltip` label * matching 60-inputs.css, which now draws the switch there: an `<input>` selector cannot
* (the hint-icon one, unstyled otherwise, and built with no `for` attribute), which would * reach ui.Checkbox's `tooltip` label (the hint-icon one, unstyled otherwise, built with no
* grow the same border a switch does not own. */ * `for` attribute) by construction, so the old `[for]` discriminator is no longer needed to
.cbi-checkbox > label[for] { border: 1px solid ButtonBorder; } * keep that label from growing a border it does not own. */
.cbi-checkbox > input[type="checkbox"]:checked + label { .cbi-checkbox > input[type="checkbox"] { border: 1px solid ButtonBorder; }
.cbi-checkbox > input[type="checkbox"]:checked {
background: Highlight; background: Highlight;
forced-color-adjust: none; forced-color-adjust: none;
} }
.cbi-checkbox > label[for]::after { background: ButtonBorder; } .cbi-checkbox > input[type="checkbox"]::after { background: ButtonBorder; }
.cbi-checkbox > input[type="checkbox"]:checked + label::after { background: HighlightText; } .cbi-checkbox > input[type="checkbox"]:checked::after { background: HighlightText; }
/* icon-only buttons (rail toggle, search/appearance, logout) lose their hover fill */ /* icon-only buttons (rail toggle, search/appearance, logout) lose their hover fill */
.fs-railtoggle, .fs-themerow { border-color: ButtonBorder; } .fs-railtoggle, .fs-themerow { border-color: ButtonBorder; }
/* the focus ring is a box-shadow everywhere in this theme, and box-shadow is /* the focus ring is a box-shadow everywhere in this theme, and box-shadow is
+2 -2
View File
@@ -5,12 +5,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=rtl8189es PKG_NAME:=rtl8189es
PKG_RELEASE:=5 PKG_RELEASE:=6
PKG_SOURCE_URL:=https://github.com/jwrdegoede/rtl8189ES_linux.git PKG_SOURCE_URL:=https://github.com/jwrdegoede/rtl8189ES_linux.git
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_DATE:=2025-09-27 PKG_SOURCE_DATE:=2025-09-27
PKG_SOURCE_VERSION:=f4712611578312fcd67795a0d43d78eb70378238 PKG_SOURCE_VERSION:=0a5d04114fac3c9f48a343cb905fbb6a3f9f5df5
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_BUILD_PARALLEL:=1 PKG_BUILD_PARALLEL:=1