Compare commits

..
6 Commits
Author SHA1 Message Date
github-actions[bot] 70d35bab31 🗽 Sync 2026-09-13 02:49:28
Merge-upstream / merge (push) Canceled after 0s
2026-09-13 02:49:28 +08:00
kiddin9 bdd9cea5d7 Update upstream.yml 2026-09-13 02:47:28 +08:00
github-actions[bot] 795028f9ea 💐 Sync 2026-09-13 00:41:32 2026-09-13 00:41:32 +08:00
github-actions[bot] cdcf288975 🎨 Sync 2026-09-12 22:40:36 2026-09-12 22:40:36 +08:00
kiddin9 83bcd4708f Update ssr-plus.patch 2026-09-12 22:38:23 +08:00
kiddin9 07891fa751 Update upstream.yml 2026-09-12 20:57:13 +08:00
244 changed files with 13102 additions and 1620 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
@@ -33,6 +33,13 @@ LUCI_DEPENDS:= \
+libuci-lua +lua +luci-compat +coreutils +coreutils-base64 +dns2tcp +dnsmasq-full \
+jq +ip-full +lua-neturl +libuci-lua +microsocks +ipt2socks +lyaml \
+resolveip +curl +nping +unzip +xz-utils +xz \
+resolveip +bind-dig +curl +nping +unzip +xz-utils +xz \
+ +PACKAGE_$(PKG_NAME)_INCLUDE_Xray:xray-core \
+ +PACKAGE_$(PKG_NAME)_INCLUDE_Xray:coreutils-timeout \
+ +PACKAGE_$(PKG_NAME)_INCLUDE_Http_Proxy:3proxy \
+2 -1
View File
@@ -245,6 +245,7 @@ jobs:
git_sparse_clone master "https://github.com/coolsnowwolf/lede" package/lean package/network/services/shellsync package/qca/shortcut-fe
git_clone https://github.com/linkease/istore-packages
git clone https://github.com/laipeng668/luci-app-gecoosac gecoosac1 && mvdir gecoosac1
git_clone https://github.com/eamonxg/luci-theme-shadcn
) &
(
git_clone https://github.com/muink/luci-app-dnsproxy
@@ -468,7 +469,7 @@ jobs:
-H "Authorization: Bearer ${{ secrets.TOKEN_KIDDIN9 }}" \
-X POST -d '{ "query": "query {repository(owner: \"'"$1"'\", name: \"'"$2"'\"){latestRelease{tagName tagCommit{oid}}refs(refPrefix:\"refs/tags/\",last:1,orderBy:{field:TAG_COMMIT_DATE,direction:ASC}){edges{node{name target{oid}}}}defaultBranchRef{target{...on Commit {oid}}}}}"}' https://api.github.com/graphql)
}
for pkg in $(ls !(luci-*|nikki|mihomo*|dockermanager|kaiplus|baidudrive|openwrt-einat-ebpf|qBittorrent-Enhanced-Edition|tuic-client|glorytun|dae|daed|shadowsocks-libev|mosdns|spotifyd|miniupnpd|openwrt-Toolkit|libdouble-conversion|wxbase|3proxy|ucode|mergerfs|openwrt-nezhav1|homebox|sub-web|tcptraceroute|frp|openwrt-caddy|mentohust|brlaser|rapidjson|smartdns|hysteria|gecoosac|libcryptopp|naiveproxy|rustdesk-server|shadowsocksr-libev|tuic-server|joker|ps3netsrv|natter|netmaker|openwrt-ympd|subconverter|sms-tool)/Makefile); do
for pkg in $(ls !(luci-*|nikki|mihomo*|dockermanager|kaiplus|fastnet|baidudrive|openwrt-einat-ebpf|qBittorrent-Enhanced-Edition|tuic-client|glorytun|dae|daed|shadowsocks-libev|mosdns|spotifyd|miniupnpd|openwrt-Toolkit|libdouble-conversion|wxbase|3proxy|ucode|mergerfs|openwrt-nezhav1|homebox|sub-web|tcptraceroute|frp|openwrt-caddy|mentohust|brlaser|rapidjson|smartdns|hysteria|gecoosac|libcryptopp|naiveproxy|rustdesk-server|shadowsocksr-libev|tuic-server|joker|ps3netsrv|natter|netmaker|openwrt-ympd|subconverter|sms-tool)/Makefile); do
repo="$(grep ^PKG_SOURCE_URL $pkg | grep github | cut -f 4-5 -d '/' | sed -e 's/.git//' | grep -E '[0-9a-zA-Z_-]+$')" || true
if [ "$repo" ]; then
owner="$(echo $repo | cut -f 1 -d '/')"
+2 -2
View File
@@ -9,8 +9,8 @@ include $(TOPDIR)/rules.mk
PKG_ARCH_FASTNET:=$(ARCH)
PKG_NAME:=fastnet
PKG_VERSION:=kaiplus-runtime-v1.0.9
PKG_RELEASE:=3
PKG_VERSION:=0.7.7
PKG_RELEASE:=4
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/fastnet-runtime-v$(PKG_VERSION)/
@@ -1422,7 +1422,7 @@ return view.extend({
});
});
return E('div', { class: 'cbi-map' },
return E('div', {},
E('fieldset', { class: 'cbi-section' }, [
E('p', { id: 'service_status' }, _('Collecting data ...'))
])
@@ -982,7 +982,7 @@ return view.extend({
});
});
return E('div', { class: 'cbi-map' },
return E('div', {},
E('fieldset', { class: 'cbi-section' }, [
E('p', { id: 'service_status' }, _('Collecting data ...'))
])
+1 -1
View File
@@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter
LUCI_PKGARCH:=all
PKG_VERSION:=0.1.41
PKG_RELEASE:=12
PKG_RELEASE:=13
# Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI).
ifdef SOURCE_DATE_EPOCH
@@ -360,6 +360,26 @@ return baseclass.extend({
].join('|');
},
extractAddrs: function (kv) {
return {
src: kv.SRC || '',
dst: kv.DST || '',
sport: kv.SPT || '',
dport: kv.DPT || ''
};
},
extractIfaces: function (kv) {
const ifaceIn = kv.IN || '';
const ifaceOut = kv.OUT || '';
return {
ifaceIn: ifaceIn,
ifaceOut: ifaceOut,
iface: ifaceIn || ifaceOut || '',
dir: ifaceIn && ifaceOut ? 'forward' : ifaceIn ? 'in' : ifaceOut ? 'out' : 'unknown'
};
},
normalizeEntry: function (entry) {
const kv = this.parseKeyValueLog(entry.msg || '');
const tsUnix = this.timestampUnix(entry);
@@ -371,14 +391,8 @@ return baseclass.extend({
this.detectAction(entry.msg || '')
);
const action = this.normalizeAction(actionRaw);
const src = kv.SRC || '';
const dst = kv.DST || '';
const sport = kv.SPT || '';
const dport = kv.DPT || '';
const ifaceIn = kv.IN || '';
const ifaceOut = kv.OUT || '';
const iface = ifaceIn || ifaceOut || '';
const dir = ifaceIn && ifaceOut ? 'forward' : ifaceIn ? 'in' : ifaceOut ? 'out' : 'unknown';
const addrs = this.extractAddrs(kv);
const ifs = this.extractIfaces(kv);
const flags = this.parseFlags(entry.msg || '', kv);
const length = this.parseLength(kv);
const ruleHint = this.parseRuleHint(entry.msg || '');
@@ -389,13 +403,13 @@ return baseclass.extend({
entry,
tsUnix,
action,
src,
dst,
sport,
dport,
addrs.src,
addrs.dst,
addrs.sport,
addrs.dport,
proto,
ifaceIn,
ifaceOut
ifs.ifaceIn,
ifs.ifaceOut
),
log_id: entry && entry.id != null ? Number(entry.id) : null,
timestamp: tsUnix,
@@ -404,15 +418,15 @@ return baseclass.extend({
rule_label: ruleLabel,
action: action,
action_raw: actionRaw,
interface: iface,
interface_in: ifaceIn,
interface_out: ifaceOut,
direction: dir,
interface: ifs.iface,
interface_in: ifs.ifaceIn,
interface_out: ifs.ifaceOut,
direction: ifs.dir,
proto: proto,
src: src,
sport: sport,
dst: dst,
dport: dport,
src: addrs.src,
sport: addrs.sport,
dst: addrs.dst,
dport: addrs.dport,
flags: flags,
length: length,
message: entry.msg || ''
@@ -467,42 +481,42 @@ return baseclass.extend({
return p.negate ? !hit : hit;
},
matchesFilter: function (row, filters) {
if (filters.q) {
const p = this.parseFilterValue(filters.q);
if (p.value) {
matchesQueryField: function (row, spec) {
const p = this.parseFilterValue(spec);
if (!p.value) return true;
const keys = Object.keys(row);
const parts = [];
for (let i = 0; i < keys.length; i++) parts.push(row[keys[i]]);
const blob = parts.join(' ').toLowerCase();
const hit = blob.indexOf(p.value.toLowerCase()) !== -1;
if (p.negate ? hit : !hit) return false;
}
}
return p.negate ? !hit : hit;
},
matchesActionField: function (row, spec) {
const p = this.parseFilterValue(spec);
if (!p.value) return true;
if (filters.action) {
const p = this.parseFilterValue(filters.action);
if (p.value) {
const want = p.value.toLowerCase();
const hit =
row.action === want ||
(row.action_raw || '').toUpperCase() === p.value.toUpperCase();
if (p.negate ? hit : !hit) return false;
}
}
row.action === want || (row.action_raw || '').toUpperCase() === p.value.toUpperCase();
return p.negate ? !hit : hit;
},
matchesInterfaceField: function (row, spec) {
const p = this.parseFilterValue(spec);
if (!p.value) return true;
if (filters.interface) {
const p = this.parseFilterValue(filters.interface);
if (p.value) {
const iface = p.value;
const hit =
row.interface === iface ||
row.interface_in === iface ||
row.interface_out === iface;
if (p.negate ? hit : !hit) return false;
}
}
row.interface === iface || row.interface_in === iface || row.interface_out === iface;
return p.negate ? !hit : hit;
},
matchesFilter: function (row, filters) {
if (filters.q && !this.matchesQueryField(row, filters.q)) return false;
if (filters.action && !this.matchesActionField(row, filters.action)) return false;
if (filters.interface && !this.matchesInterfaceField(row, filters.interface)) return false;
if (filters.proto && !this.matchesExactField(row.proto, filters.proto)) return false;
if (filters.src && !this.matchesTextField(row.src, filters.src)) return false;
if (filters.dst && !this.matchesTextField(row.dst, filters.dst)) return false;
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-mosdns
PKG_VERSION:=1.7.14
PKG_RELEASE:=23
PKG_RELEASE:=24
LUCI_TITLE:=LuCI Support for mosdns
LUCI_PKGARCH:=all
@@ -160,8 +160,18 @@ const injectStyles = () => {
'.btn-allow:hover { background: #059669 !important; color: #fff !important; }',
'.btn-block { background: rgba(239, 68, 68, 0.12) !important; color: #dc2626 !important; border: 1px solid rgba(239, 68, 68, 0.3) !important; }',
'.btn-block:hover { background: #dc2626 !important; color: #fff !important; }',
'.mosdns-pagination-bar { display: flex !important; justify-content: space-between !important; align-items: center !important; margin-top: 0.5rem !important; padding: 0.75rem 0.5rem 0.25rem 0.5rem !important; border-top: 1px solid var(--border-color-medium, rgba(125,125,125,0.15)) !important; flex-wrap: wrap !important; gap: 0.5rem !important; box-sizing: border-box !important; width: 100% !important; }',
'.mosdns-pagination-bar::after, .mosdns-pagination-bar::before { display: none !important; content: none !important; }',
'.mosdns-pagination-info { display: inline-flex !important; align-items: center !important; font-size: 0.85rem !important; opacity: 0.85 !important; }',
'.mosdns-pagination-btns { display: flex !important; gap: 0.5rem !important; margin-left: auto !important; }',
'.mosdns-page-input { height: 24px !important; line-height: 22px !important; text-align: center !important; font-size: 0.82rem !important; font-weight: 600 !important; padding: 0 4px !important; margin: 0 4px !important; border: 1px solid rgba(125,125,125,0.3) !important; border-radius: 4px !important; background: var(--cbi-section-bg, #fff) !important; color: inherit !important; display: inline-block !important; vertical-align: middle !important; box-sizing: border-box !important; transition: all 0.2s ease !important; }',
'.mosdns-page-input:focus { border-color: #3b82f6 !important; box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25) !important; outline: none !important; }',
'.mosdns-page-input::-webkit-outer-spin-button, .mosdns-page-input::-webkit-inner-spin-button { -webkit-appearance: none !important; margin: 0 !important; }',
'.mosdns-page-input[type=number] { -moz-appearance: textfield !important; }',
'@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }',
'@media (prefers-color-scheme: dark) {',
' .mosdns-pagination-bar { border-top-color: rgba(255,255,255,0.08) !important; }',
' .mosdns-page-input { background: rgba(255,255,255,0.06) !important; border-color: rgba(255,255,255,0.18) !important; color: #fff !important; }',
' .mosdns-stat-card, .mosdns-rank-panel, .mosdns-modal-meta-item, .mosdns-answer-row { background: rgba(255,255,255,0.03); border-color: rgba(255,255,255,0.08); box-shadow: none; }',
' .mosdns-sparkline-tooltip { background: #1e242b; border-color: rgba(255,255,255,0.15); box-shadow: 0 4px 12px rgba(0,0,0,0.5); }',
' .mosdns-log-row:hover { background: rgba(255,255,255,0.04); }',
@@ -864,7 +874,7 @@ const renderLogsTable = logsData => {
E('td', { class: 'td col-time', style: 'font-size: 0.82rem; opacity: 0.7; white-space: nowrap;' }, formatTimestamp(item.timestamp)),
E('td', {
class: 'td col-client mosdns-mono',
style: 'font-size: 0.82rem; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;',
style: 'font-size: 0.82rem; max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;',
title: clientInfo.title
}, clientInfo.display),
E('td', { class: 'td col-domain', style: 'max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', title: item.domain || '-' }, [
@@ -887,22 +897,74 @@ const renderLogsTable = logsData => {
]));
}
const inputWidth = Math.max(46, (String(totalPages).length * 8 + 18)) + 'px';
const pageInput = E('input', {
type: 'number',
class: 'cbi-input-text mosdns-page-input',
min: 1,
max: totalPages,
value: pageIdx + 1,
style: `width: ${inputWidth};`,
title: _('Enter page number and press Enter to jump')
});
const doJump = () => {
const val = parseInt(pageInput.value, 10);
if (isNaN(val)) {
pageInput.value = pageIdx + 1;
return;
}
const targetPage = Math.max(1, Math.min(totalPages, val));
pageInput.value = targetPage;
if (targetPage - 1 !== pageIdx) {
pageIdx = targetPage - 1;
updateLiveStatusBadge();
refreshLogs();
}
};
pageInput.addEventListener('keydown', ev => {
if (ev.key === 'Enter') {
ev.preventDefault();
pageInput.blur();
}
});
pageInput.addEventListener('focus', () => {
pageInput.select();
});
pageInput.addEventListener('blur', () => {
doJump();
});
const pageParts = _('Page %d / %d (%d entries)').format(999999, totalPages, total).split('999999');
const paginationLabel = E('div', {
class: 'mosdns-pagination-info'
}, [
pageParts[0] || '',
pageInput,
pageParts[1] || ''
]);
return E('div', {}, [
E('table', { class: 'table cbi-section-table mosdns-table', style: 'margin-top: 0.25rem;' }, [
E('table', { class: 'table cbi-section-table mosdns-table', style: 'margin-top: 0.25rem; margin-bottom: 0;' }, [
E('tr', { class: 'tr table-titles' }, [
E('th', { class: 'th col-time', style: 'width: 85px;' }, _('Time')),
E('th', { class: 'th col-client', style: 'width: 125px;' }, _('Client IP')),
E('th', { class: 'th col-domain' }, _('Domain & Record')),
E('th', { class: 'th col-status', style: 'width: 90px;' }, _('Status')),
E('th', { class: 'th col-answers' }, _('Answers')),
E('th', { class: 'th col-latency', style: 'width: 80px; text-align: right;' }, _('Elapsed'))
E('th', { class: 'th col-latency', style: 'width: 95px; text-align: right;' }, _('Elapsed'))
]),
...rows
]),
E('div', { style: 'display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem;' }, [
E('span', { style: 'font-size: 0.85rem; opacity: 0.7;' }, _('Page %d / %d (%d entries)').format(pageIdx + 1, totalPages, total)),
E('div', { style: 'display: flex; gap: 0.5rem;' }, [
E('div', {
class: 'mosdns-pagination-bar'
}, [
paginationLabel,
E('div', { class: 'mosdns-pagination-btns' }, [
E('button', {
class: 'btn cbi-button cbi-button-action',
disabled: pageIdx === 0 ? 'disabled' : null,
+77 -73
View File
@@ -17,19 +17,19 @@ msgstr ""
msgid "API Options"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:718
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:728
msgid "Add domain to the blocklist to block DNS resolution:"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:717
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:727
msgid "Add domain to the whitelist to permit DNS resolution:"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:710
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:720
msgid "Add to Blocklist"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:710
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:720
msgid "Add to Whitelist"
msgstr ""
@@ -90,7 +90,7 @@ msgstr ""
msgid "Aliyun Public DNS (DNS over QUIC)"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1026
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1088
msgid "All Queries"
msgstr ""
@@ -99,8 +99,8 @@ msgstr ""
msgid "Another update is already in progress."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:805
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:897
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:815
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:957
msgid "Answers"
msgstr ""
@@ -108,7 +108,7 @@ msgstr ""
msgid "Apple domains optimization"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1053
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1115
msgid ""
"Are you sure you want to clear all real-time query logs and top rankings?"
msgstr ""
@@ -127,11 +127,11 @@ msgid ""
"rules through scheduled tasks."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:478
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:488
msgid "Average Processing Time"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:538
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:548
msgid "Avg Processing"
msgstr ""
@@ -155,8 +155,8 @@ msgid ""
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:31
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:633
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:635
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:643
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:645
msgid "Block Lists"
msgstr ""
@@ -164,11 +164,11 @@ msgstr ""
msgid "Block PTR"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1027
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1089
msgid "Blocked Only"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:456
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:466
msgid "Blocked by Filters"
msgstr ""
@@ -190,16 +190,16 @@ msgstr ""
msgid "Cache Prefetching"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1028
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1090
msgid "Cached Only"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:467
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:477
msgid "Cached Queries"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:730
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1058
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:740
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1120
msgid "Cancel"
msgstr ""
@@ -229,7 +229,7 @@ msgstr ""
msgid "Cisco Public DNS (208.67.222.222)"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1078
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1140
msgid "Clear"
msgstr ""
@@ -237,22 +237,22 @@ msgstr ""
msgid "Clear logs"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1049
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1052
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1111
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1114
msgid "Clear query logs"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:861
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:878
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:871
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:888
msgid "Click to view full details"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:787
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:894
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:797
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:954
msgid "Client IP"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:832
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:842
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:35
msgid "Close"
msgstr ""
@@ -292,7 +292,7 @@ msgstr ""
msgid "Configuration Editor"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:734
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:744
msgid "Confirm"
msgstr ""
@@ -330,7 +330,7 @@ msgstr ""
msgid "DNS Forward"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:444
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:454
msgid "DNS Queries Total"
msgstr ""
@@ -374,20 +374,20 @@ msgstr ""
msgid "DoH/TCP/DoT Connection Multiplexing idle timeout (default 30 seconds)"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:895
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:955
msgid "Domain & Record"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:627
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:655
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:637
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:665
msgid "Domain name cannot be empty."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:720
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:730
msgid "Domain:"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:898
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:958
msgid "Elapsed"
msgstr ""
@@ -425,6 +425,10 @@ msgstr ""
msgid "Enabled"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:908
msgid "Enter page number and press Enter to jump"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:505
msgid "Enter the GeoIP.dat category to be exported, Allow add multiple tags"
msgstr ""
@@ -478,7 +482,7 @@ msgstr ""
msgid "Failed to clean logs."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1072
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1134
msgid "Failed to clear query logs:"
msgstr ""
@@ -486,15 +490,15 @@ msgstr ""
msgid "Failed to start update."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:970
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1032
msgid "Failed to update query logs:"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:699
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:709
msgid "Failed to update rule file: %s"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1044
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1106
msgid "First Page / Resume"
msgstr ""
@@ -595,10 +599,10 @@ msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:410
msgid ""
"IPv4 CIDR: <a href=\"https://www.cloudflare.com/ips-v4\" "
"target=\"_blank\">https://www.cloudflare.com/ips-v4</a> <br /> IPv6 CIDR: <a "
"href=\"https://www.cloudflare.com/ips-v6\" target=\"_blank\">https://www."
"cloudflare.com/ips-v6</a>"
"IPv4 CIDR: <a href=\"https://www.cloudflare.com/ips-v4\" target=\"_blank"
"\">https://www.cloudflare.com/ips-v4</a> <br /> IPv6 CIDR: <a href=\"https://"
"www.cloudflare.com/ips-v6\" target=\"_blank\">https://www.cloudflare.com/ips-"
"v6</a>"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:190
@@ -621,7 +625,7 @@ msgstr ""
msgid "Info"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:828
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:838
msgid "Intercept"
msgstr ""
@@ -631,7 +635,7 @@ msgid ""
"seconds)."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:479
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:489
msgid "Latency"
msgstr ""
@@ -716,12 +720,12 @@ msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:33
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:106
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:70
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1094
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1156
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:3
msgid "MosDNS"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:510
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:520
msgid ""
"MosDNS API is unreachable. Please ensure MosDNS is running and stats_api "
"plugin is enabled."
@@ -739,15 +743,15 @@ msgstr ""
msgid "Netflix, Disney+, Hulu and streaming media rules list will use this DNS"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:927
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:989
msgid "Next"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:768
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:778
msgid "No DNS answer records returned."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:560
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:570
msgid "No data available"
msgstr ""
@@ -755,19 +759,19 @@ msgstr ""
msgid "No log data."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:886
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:896
msgid "No query log entries found."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:904
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:941
msgid "Page %d / %d (%d entries)"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:438
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:448
msgid "Per-query speed"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:821
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:831
msgid "Permit"
msgstr ""
@@ -802,7 +806,7 @@ msgstr ""
msgid "Prevent DNS Leaks"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:916
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:978
msgid "Previous"
msgstr ""
@@ -821,7 +825,7 @@ msgstr ""
msgid "Quad9 Public DNS (9.9.9.9)"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:811
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:821
msgid "Query Log Details"
msgstr ""
@@ -831,7 +835,7 @@ msgid ""
"consume more memory)"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1067
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1129
msgid "Query logs cleared successfully."
msgstr ""
@@ -839,7 +843,7 @@ msgstr ""
msgid "RUNNING"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1098
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1160
msgid "Real-time Query Logs"
msgstr ""
@@ -869,7 +873,7 @@ msgstr ""
msgid "Ring Buffer Capacity"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:799
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:809
msgid "Rule Hit"
msgstr ""
@@ -889,7 +893,7 @@ msgstr ""
msgid "Save the cache locally and reload the cache dump on the next startup"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1015
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1077
msgid "Search domain or client IP..."
msgstr ""
@@ -901,7 +905,7 @@ msgstr ""
msgid "Starting update..."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1094
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1156
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:38
msgid "Statistics"
msgstr ""
@@ -910,7 +914,7 @@ msgstr ""
msgid "Stats Dump"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:896
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:956
msgid "Status"
msgstr ""
@@ -930,7 +934,7 @@ msgstr ""
msgid "TCP/DoT Connection Multiplexing"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:723
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:733
msgid "Target file: %s (supports domain matching rules)"
msgstr ""
@@ -966,20 +970,20 @@ msgid ""
"content in yaml format."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:791
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:893
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:801
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:953
msgid "Time"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:599
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:609
msgid "Top Blocked Domains"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:606
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:616
msgid "Top Clients"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:592
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:602
msgid "Top Queried Domains"
msgstr ""
@@ -1029,7 +1033,7 @@ msgstr ""
msgid "Updating Database..."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:795
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:805
msgid "Upstream"
msgstr ""
@@ -1051,8 +1055,8 @@ msgid ""
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:23
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:633
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:635
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:643
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:645
msgid "White Lists"
msgstr ""
@@ -1070,15 +1074,15 @@ msgstr ""
msgid "https://gh-proxy.com"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:427
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:540
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:437
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:550
msgid "● Live"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:223
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:233
msgid "● Live Auto-refresh"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:227
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:237
msgid "❚❚ Paused (Page %d)"
msgstr ""
+81 -86
View File
@@ -27,19 +27,19 @@ msgstr "API 监听端口"
msgid "API Options"
msgstr "API 选项"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:718
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:728
msgid "Add domain to the blocklist to block DNS resolution:"
msgstr "将域名添加到黑名单以拦截 DNS 解析:"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:717
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:727
msgid "Add domain to the whitelist to permit DNS resolution:"
msgstr "将域名添加到白名单以放行 DNS 解析:"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:710
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:720
msgid "Add to Blocklist"
msgstr "添加到黑名单"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:710
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:720
msgid "Add to Whitelist"
msgstr "添加到白名单"
@@ -105,7 +105,7 @@ msgstr "阿里云公共 DNSDNS over HTTPS"
msgid "Aliyun Public DNS (DNS over QUIC)"
msgstr "阿里云公共 DNSDNS over QUIC"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1026
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1088
msgid "All Queries"
msgstr "所有查询"
@@ -114,8 +114,8 @@ msgstr "所有查询"
msgid "Another update is already in progress."
msgstr "另一个更新正在进行中。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:805
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:897
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:815
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:957
msgid "Answers"
msgstr "应答结果"
@@ -123,7 +123,7 @@ msgstr "应答结果"
msgid "Apple domains optimization"
msgstr "Apple 域名解析优化"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1053
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1115
msgid ""
"Are you sure you want to clear all real-time query logs and top rankings?"
msgstr "确定要清空所有实时查询日志和排行数据吗?"
@@ -142,11 +142,11 @@ msgid ""
"rules through scheduled tasks."
msgstr "通过定时任务自动更新 GeoIP 和 GeoSite 数据库以及广告过滤规则。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:478
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:488
msgid "Average Processing Time"
msgstr "平均延迟"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:538
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:548
msgid "Avg Processing"
msgstr "平均处理"
@@ -172,8 +172,8 @@ msgstr ""
"解析。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:31
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:633
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:635
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:643
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:645
msgid "Block Lists"
msgstr "黑名单"
@@ -181,11 +181,11 @@ msgstr "黑名单"
msgid "Block PTR"
msgstr "PTR 黑名单"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1027
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1089
msgid "Blocked Only"
msgstr "仅拦截"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:456
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:466
msgid "Blocked by Filters"
msgstr "拦截总数"
@@ -207,16 +207,16 @@ msgstr "自动保存缓存"
msgid "Cache Prefetching"
msgstr "缓存预取"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1028
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1090
msgid "Cached Only"
msgstr "仅缓存"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:467
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:477
msgid "Cached Queries"
msgstr "缓存命中"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:730
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1058
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:740
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1120
msgid "Cancel"
msgstr "取消"
@@ -246,7 +246,7 @@ msgstr "思科公共 DNS208.67.220.220"
msgid "Cisco Public DNS (208.67.222.222)"
msgstr "思科公共 DNS208.67.222.222"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1078
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1140
msgid "Clear"
msgstr "清空"
@@ -254,22 +254,22 @@ msgstr "清空"
msgid "Clear logs"
msgstr "清空日志"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1049
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1052
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1111
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1114
msgid "Clear query logs"
msgstr "清除查询日志"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:861
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:878
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:871
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:888
msgid "Click to view full details"
msgstr "点击查看完整详情"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:787
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:894
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:797
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:954
msgid "Client IP"
msgstr "客户端 IP"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:832
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:842
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:35
msgid "Close"
msgstr "关闭"
@@ -309,7 +309,7 @@ msgstr "配置文件"
msgid "Configuration Editor"
msgstr "配置编辑器"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:734
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:744
msgid "Confirm"
msgstr "确认"
@@ -348,7 +348,7 @@ msgstr "DNS 缓存大小"
msgid "DNS Forward"
msgstr "DNS 转发"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:444
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:454
msgid "DNS Queries Total"
msgstr "DNS 查询总数"
@@ -393,20 +393,20 @@ msgstr ""
msgid "DoH/TCP/DoT Connection Multiplexing idle timeout (default 30 seconds)"
msgstr "DoH/TCP/DoT 连接复用空闲保持时间(默认 30 秒)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:895
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:955
msgid "Domain & Record"
msgstr "域名与类型"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:627
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:655
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:637
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:665
msgid "Domain name cannot be empty."
msgstr "域名不能为空"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:720
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:730
msgid "Domain:"
msgstr "域名:"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:898
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:958
msgid "Elapsed"
msgstr "耗时"
@@ -444,6 +444,10 @@ msgstr "启用此选项 fallback 策略会强制转发到远程 DNS"
msgid "Enabled"
msgstr "启用"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:908
msgid "Enter page number and press Enter to jump"
msgstr "输入页码并按回车跳转"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:505
msgid "Enter the GeoIP.dat category to be exported, Allow add multiple tags"
msgstr "输入需要导出的 GeoIP.dat 类别条目,允许添加多个标签"
@@ -497,7 +501,7 @@ msgstr "导出目录:/var/mosdns"
msgid "Failed to clean logs."
msgstr "清理日志失败:%s"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1072
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1134
msgid "Failed to clear query logs:"
msgstr "清除查询日志失败:"
@@ -505,15 +509,15 @@ msgstr "清除查询日志失败:"
msgid "Failed to start update."
msgstr "启动更新失败"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:970
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1032
msgid "Failed to update query logs:"
msgstr "更新查询日志失败:"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:699
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:709
msgid "Failed to update rule file: %s"
msgstr "更新规则文件失败: %s"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1044
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1106
msgid "First Page / Resume"
msgstr "重置并恢复"
@@ -614,15 +618,15 @@ msgstr "IP 地址"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:410
msgid ""
"IPv4 CIDR: <a href=\"https://www.cloudflare.com/ips-v4\" "
"target=\"_blank\">https://www.cloudflare.com/ips-v4</a> <br /> IPv6 CIDR: <a "
"href=\"https://www.cloudflare.com/ips-v6\" target=\"_blank\">https://www."
"cloudflare.com/ips-v6</a>"
"IPv4 CIDR: <a href=\"https://www.cloudflare.com/ips-v4\" target=\"_blank"
"\">https://www.cloudflare.com/ips-v4</a> <br /> IPv6 CIDR: <a href=\"https://"
"www.cloudflare.com/ips-v6\" target=\"_blank\">https://www.cloudflare.com/ips-"
"v6</a>"
msgstr ""
"IPv4 CIDR: <a href=\"https://www.cloudflare.com/ips-v4\" "
"target=\"_blank\">https://www.cloudflare.com/ips-v4</a> <br /> IPv6 CIDR: <a "
"href=\"https://www.cloudflare.com/ips-v6\" target=\"_blank\">https://www."
"cloudflare.com/ips-v6</a>"
"IPv4 CIDR: <a href=\"https://www.cloudflare.com/ips-v4\" target=\"_blank"
"\">https://www.cloudflare.com/ips-v4</a> <br /> IPv6 CIDR: <a href=\"https://"
"www.cloudflare.com/ips-v6\" target=\"_blank\">https://www.cloudflare.com/ips-"
"v6</a>"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:190
msgid ""
@@ -644,7 +648,7 @@ msgstr "空闲超时"
msgid "Info"
msgstr "信息"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:828
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:838
msgid "Intercept"
msgstr "拦截"
@@ -654,7 +658,7 @@ msgid ""
"seconds)."
msgstr "后台线程扫描缓存以进行预取的时间间隔(单位:秒)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:479
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:489
msgid "Latency"
msgstr "延迟"
@@ -742,12 +746,12 @@ msgstr "修改 DNS 应答结果的最小 TTL 值 (秒),0 表示不修改"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:33
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:106
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:70
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1094
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1156
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:3
msgid "MosDNS"
msgstr "MosDNS"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:510
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:520
msgid ""
"MosDNS API is unreachable. Please ensure MosDNS is running and stats_api "
"plugin is enabled."
@@ -767,15 +771,15 @@ msgstr "未运行"
msgid "Netflix, Disney+, Hulu and streaming media rules list will use this DNS"
msgstr "自定义 Netflix、Disney+、Hulu 以及 “流媒体” 规则列表的 DNS 服务器"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:927
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:989
msgid "Next"
msgstr "下一页"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:768
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:778
msgid "No DNS answer records returned."
msgstr "无 DNS 应答记录"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:560
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:570
msgid "No data available"
msgstr "暂无数据"
@@ -783,19 +787,19 @@ msgstr "暂无数据"
msgid "No log data."
msgstr "无日志数据。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:886
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:896
msgid "No query log entries found."
msgstr "未找到查询日志记录。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:904
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:941
msgid "Page %d / %d (%d entries)"
msgstr "第 %d / %d 页(%d 条记录)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:438
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:448
msgid "Per-query speed"
msgstr "单次查询速度"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:821
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:831
msgid "Permit"
msgstr "放行"
@@ -832,7 +836,7 @@ msgstr "当剩余 TTL 小于此值(秒)时触发预取"
msgid "Prevent DNS Leaks"
msgstr "防止 DNS 泄漏"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:916
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:978
msgid "Previous"
msgstr "上一页"
@@ -851,7 +855,7 @@ msgstr "Quad9 公共 DNS149.112.112.112"
msgid "Quad9 Public DNS (9.9.9.9)"
msgstr "Quad9 公共 DNS9.9.9.9"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:811
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:821
msgid "Query Log Details"
msgstr "查询日志详情"
@@ -861,7 +865,7 @@ msgid ""
"consume more memory)"
msgstr "查询日志环形缓冲区容量(FIFO 覆盖,默认 2000,数值越大占用内存越多)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1067
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1129
msgid "Query logs cleared successfully."
msgstr "查询日志清除成功。"
@@ -869,7 +873,7 @@ msgstr "查询日志清除成功。"
msgid "RUNNING"
msgstr "运行中"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1098
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1160
msgid "Real-time Query Logs"
msgstr "实时查询日志"
@@ -901,7 +905,7 @@ msgstr "远程 DNS 服务器"
msgid "Ring Buffer Capacity"
msgstr "环形缓冲区容量"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:799
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:809
msgid "Rule Hit"
msgstr "命中规则"
@@ -921,7 +925,7 @@ msgstr "保存统计数据与查询日志在本地,并在下次启动时重新
msgid "Save the cache locally and reload the cache dump on the next startup"
msgstr "保存缓存到本地文件,以供下次启动时重新载入使用"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1015
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1077
msgid "Search domain or client IP..."
msgstr "搜索域名或客户端 IP..."
@@ -933,7 +937,7 @@ msgstr "设置日志文件的最大容量(单位:MB)。"
msgid "Starting update..."
msgstr "正在启动更新..."
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1094
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:1156
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:38
msgid "Statistics"
msgstr "统计"
@@ -942,7 +946,7 @@ msgstr "统计"
msgid "Stats Dump"
msgstr "自动保存统计"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:896
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:956
msgid "Status"
msgstr "状态"
@@ -962,7 +966,7 @@ msgstr "支持本地文件,例如:file:///var/mosdns/example.txt"
msgid "TCP/DoT Connection Multiplexing"
msgstr "TCP/DoT 连接复用"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:723
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:733
msgid "Target file: %s (supports domain matching rules)"
msgstr "目标文件: %s (支持域名匹配规则)"
@@ -1002,20 +1006,20 @@ msgstr ""
"这是文件 “/etc/mosdns/config_custom.yaml” 的内容,您的 MosDNS 配置将从此文件"
"生成。仅接受 yaml 格式的配置内容。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:791
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:893
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:801
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:953
msgid "Time"
msgstr "时间"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:599
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:609
msgid "Top Blocked Domains"
msgstr "拦截域名 (Top 10)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:606
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:616
msgid "Top Clients"
msgstr "活跃客户端 (Top 10)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:592
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:602
msgid "Top Queried Domains"
msgstr "查询域名 (Top 10)"
@@ -1065,7 +1069,7 @@ msgstr "更新成功"
msgid "Updating Database..."
msgstr "更新数据库..."
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:795
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:805
msgid "Upstream"
msgstr "上游服务器"
@@ -1091,8 +1095,8 @@ msgstr ""
"则)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:23
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:633
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:635
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:643
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:645
msgid "White Lists"
msgstr "白名单"
@@ -1110,24 +1114,15 @@ msgstr "信风公共 DNS114.114.115.115"
msgid "https://gh-proxy.com"
msgstr "https://gh-proxy.com"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:427
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:540
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:437
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:550
msgid "● Live"
msgstr "● 实时"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:223
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:233
msgid "● Live Auto-refresh"
msgstr "● 实时刷新"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:227
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:237
msgid "❚❚ Paused (Page %d)"
msgstr "❚❚ 暂停 (第 %d 页)"
#~ msgid "Action"
#~ msgstr "操作"
#~ msgid "Add %s to Blocklist"
#~ msgstr "将 %s 添加到黑名单"
#~ msgid "Add %s to Whitelist"
#~ msgstr "将 %s 添加到白名单"
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.9.9
PKG_RELEASE:=268
PKG_RELEASE:=269
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -61,14 +61,11 @@ function getOption(config, section, opt) {
const id = `cbid.${config}.${section}.${opt}`;
obj = document.getElementsByName(id)[0] || document.getElementById(id);
if (obj) {
//lua luci combobox
const combobox = document.getElementById('cbi.combobox.' + id);
if (combobox) {
obj.combobox = combobox;
}
const div = document.getElementById(id);
if (div && div.getElementsByTagName("li").length > 0) {
obj = div;
}
return obj;
} else {
return null;
@@ -620,6 +620,7 @@ if singbox_tags:find("with_utls") then
o:depends({ protocol = "shadowsocks", tls = true })
o:depends({ protocol = "trojan", tls = true })
o:depends({ protocol = "anytls", tls = true })
o:depends({ protocol = "http", tls = true })
o = s:option(Value, "reality_publicKey", translate("Public Key"))
o:depends({ reality = true })
@@ -401,6 +401,7 @@ o = s:option(Flag, "tls", translate("TLS"))
o.default = 0
o:depends({ protocol = "vmess" })
o:depends({ protocol = "vless" })
o:depends({ protocol = "http" })
o:depends({ protocol = "trojan" })
o:depends({ protocol = "shadowsocks" })
@@ -719,6 +720,8 @@ o:depends({ protocol = "vless", transport = "raw" })
o:depends({ protocol = "vless", transport = "ws" })
o:depends({ protocol = "vless", transport = "grpc" })
o:depends({ protocol = "vless", transport = "httpupgrade" })
o:depends({ protocol = "http" })
o:depends({ protocol = "socks" })
o:depends({ protocol = "shadowsocks" })
o:depends({ protocol = "trojan" })
@@ -206,13 +206,18 @@ o.validate = function(self, value, t)
end
o:depends({ protocol = "vmess" })
o:depends({ protocol = "vless" })
o:depends({ protocol = "http" })
o:depends({ protocol = "shadowsocks" })
o:depends({ protocol = "trojan" })
-- [[ REALITY部分 ]] --
o = s:option(Flag, "reality", translate("REALITY"))
o.default = 0
o:depends({ tls = true })
o:depends({ tls = true, transport = "raw" })
o:depends({ tls = true, transport = "ws" })
o:depends({ tls = true, transport = "grpc" })
o:depends({ tls = true, transport = "httpupgrade" })
o:depends({ tls = true, transport = "xhttp" })
o = s:option(Value, "reality_private_key", translate("Private Key"))
o:depends({ reality = true })
@@ -481,9 +481,9 @@ function gen_outbound(flag, node, tag, proxy_table)
obfs = node.hysteria_obfs,
auth = (node.hysteria_auth_type == "base64") and node.hysteria_auth_password or nil,
auth_str = (node.hysteria_auth_type == "string") and node.hysteria_auth_password or nil,
recv_window_conn = tonumber(node.hysteria_recv_window_conn), --1.14 将变更为 stream_receive_window
recv_window = tonumber(node.hysteria_recv_window), --1.14 将变更为 connection_receive_window
disable_mtu_discovery = (node.hysteria_disable_mtu_discovery == "1") and true or false, --1.14 将变更为 disable_path_mtu_discovery
stream_receive_window = tonumber(node.hysteria_recv_window_conn),
connection_receive_window = tonumber(node.hysteria_recv_window),
disable_path_mtu_discovery = (node.hysteria_disable_mtu_discovery == "1") and true or false,
tls = tls
}
end
@@ -910,10 +910,10 @@ function gen_config_server(node)
down_mbps = tonumber(node.hysteria_down_mbps),
obfs = node.hysteria_obfs,
users = users,
recv_window_conn = node.hysteria_recv_window_conn and tonumber(node.hysteria_recv_window_conn) or nil, --1.14 to stream_receive_window
recv_window_client = node.hysteria_recv_window_client and tonumber(node.hysteria_recv_window_client) or nil, --1.14 to connection_receive_window
max_conn_client = node.hysteria_max_conn_client and tonumber(node.hysteria_max_conn_client) or nil, --1.14 to max_concurrent_streams
disable_mtu_discovery = (node.hysteria_disable_mtu_discovery == "1") and true or false, --1.14 to disable_path_mtu_discover
stream_receive_window = node.hysteria_recv_window_conn and tonumber(node.hysteria_recv_window_conn) or nil,
connection_receive_window = node.hysteria_recv_window_client and tonumber(node.hysteria_recv_window_client) or nil,
max_concurrent_streams = node.hysteria_max_conn_client and tonumber(node.hysteria_max_conn_client) or nil,
disable_path_mtu_discover = (node.hysteria_disable_mtu_discovery == "1") and true or false,
tls = tls
}
end
@@ -120,6 +120,10 @@ function gen_outbound(flag, node, tag, proxy_table)
node.stream_security = "tls"
end
if node.protocol == "http" and node.stream_security == "tls" then
node.transport = "raw"
end
if remarks then
tag = tag .. ":" .. remarks
end
@@ -138,7 +142,7 @@ function gen_outbound(flag, node, tag, proxy_table)
xudpConcurrency = (node.mux == "1" and ((node.xudp_concurrency) and tonumber(node.xudp_concurrency) or 8)) or nil
} or nil,
-- 底层传输配置
streamSettings = (node.streamSettings or dialer_proxy_tag or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan" or node.protocol == "hysteria") and {
streamSettings = (node.streamSettings or dialer_proxy_tag or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan" or node.protocol == "hysteria" or node.protocol == "http") and {
sockopt = {
mark = 255,
domainStrategy = node.domain_strategy or "UseIP",
@@ -544,7 +548,7 @@ function gen_config_server(node)
allowTransparent = false,
users = users
}
node.transport = "tcp"
node.transport = "raw"
node.tcp_guise = "none"
elseif node.protocol == "shadowsocks" then
settings = {
@@ -26,12 +26,18 @@ local config = map.config
update_config(node_id, {
remarks: getOption("<%=config%>", "<%=sid%>", "remarks").value,
group: getOption("<%=config%>", "<%=sid%>", "group").value,
type: getOption("<%=config%>", "<%=sid%>", "type").value,
type: el.value,
//protocol: ''
}, node_config_url);
}
}
});
const fromUrlCache = JSON.parse(sessionStorage.getItem("fromUrl"));
if (fromUrlCache && fromUrlCache.savetime && (Date.now() - fromUrlCache.timestamp) < fromUrlCache.savetime) {
fromUrl(fromUrlCache.link, true)
} else {
sessionStorage.removeItem("fromUrl");
}
});
waitForElement('select[name*="<%=config%>"][name*="protocol"]', function(el) {
let o_val = el.value;
@@ -56,7 +62,7 @@ local config = map.config
update_config(node_id, {
remarks: getOption("<%=config%>", "<%=sid%>", "remarks").value,
group: getOption("<%=config%>", "<%=sid%>", "group").value,
protocol: getOption("<%=config%>", "<%=sid%>", name).value,
protocol: el.value,
}, node_config_url);
*/
}
@@ -1,8 +1,8 @@
<%+cbi/valueheader%>
<%
local api = require "luci.passwall.api"
local appname = api.appname
local map = self.map
local api = map.api
local config = map.config
local has_ss_rust = api.is_finded("sslocal")
local has_singbox = api.finded_com("sing-box")
@@ -17,7 +17,7 @@ local hysteria2_type = api.get_core("hysteria2_type", {{has_hysteria2,"hysteria2
local current_node = map:get(section)
-%>
<script src="<%=resource%>/view/<%=appname%>/qrcode.min.js"></script>
<script src="<%=resource%>/view/<%=api.appname%>/qrcode.min.js"></script>
<script type="text/javascript">//<![CDATA[
let has_singbox = "<%=has_singbox%>"
let has_xray = "<%=has_xray%>"
@@ -112,8 +112,7 @@ local current_node = map:get(section)
function buildUrl(btn, urlname, sid) {
var opt = {
base: "cbid.<%=appname%>",
client: true,
base: "cbid.<%=config%>",
get: function (opt) {
var id = this.base + "." + opt;
var obj = document.getElementsByName(id)[0] || document.getElementsByClassName(id)[0] || document.getElementById(id)
@@ -158,7 +157,6 @@ local current_node = map:get(section)
}
}
opt.base = "cbid." + urlname + "." + sid;
opt.client = urlname.indexOf("server") === -1;
var v_type = opt.get("type").value;
var v_alias = opt.get("remarks");
@@ -753,34 +751,41 @@ local current_node = map:get(section)
return server.replace(/^\[/, '').replace(/\]$/, '');
}
function fromUrl(btn, urlname, sid, cacheData) {
var opt = {
base: 'cbid.<%=appname%>',
client: true,
function tofromUrl(btn) {
let link = prompt('<%:Paste Share URL Here%>', '');
if (link === null || link === "") {
return false;
}
return fromUrl(link);
}
function fromUrl(link, from_cache) {
if (!link) {
return false;
}
const s = document.getElementById("<%=self.option%>-status");
if (!s) {
alert("Never");
return false;
}
s.innerHTML = "";
const current_type = '<%=current_node.type or ""%>';
let switch_type = null;
const opt = {
base: "cbid.<%=self.option%>.<%=self.value%>",
get: function (opt) {
var obj;
var id = this.base + '.' + opt;
obj = document.getElementsByName(id)[0] || document.getElementById(id);
if (obj) {
var combobox = document.getElementById('cbi.combobox.' + id);
if (combobox) {
obj.combobox = combobox;
}
var div = document.getElementById(id);
if (div && div.getElementsByTagName("li").length > 0) {
obj = div;
}
return obj;
} else {
return null;
}
return getOption("<%=config%>", "<%=self.value%>", opt);
},
set: function (opt, val) {
if (!switch_type && opt == "type" && current_type != val) {
switch_type = val;
return;
}
var obj;
obj = this.get(opt);
if (obj) {
var event = document.createEvent("HTMLEvents");
event.initEvent("change", true, true);
if (obj.type === 'checkbox') {
obj.checked = val;
} else {
@@ -814,17 +819,17 @@ local current_node = map:get(section)
}
}
try {
obj.dispatchEvent(event);
obj.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
} catch (err) {}
} else {
//alert('<%:Faltal on set option, please help in debug: %>' + opt + ' = ' + val);
// 处理 DynamicList
// Processing DynamicList
var fullName = this.base + '.' + opt;
var lists = document.querySelectorAll('.cbi-dynlist');
for (var i = 0; i < lists.length; i++) {
var parent = lists[i].closest('.cbi-value');
if (!parent) continue;
// 尝试从 label for 属性中提取 fullName
// Try to extract fullName from the label's for attribute.
var label = parent.querySelector('label.cbi-value-title');
var labelFor = label?.getAttribute('for');
if (labelFor === fullName) {
@@ -838,58 +843,20 @@ local current_node = map:get(section)
}
}
}
},
setlist: function (opt, vlist) {
var id = this.base + "." + opt;
var objs = document.getElementsByName(id) || document.getElementsByClassName(id);
if (objs) {
var values = "";
for (var i = 0; i < vlist.length; i++) {
values += vlist[i] + ", ";
}
alert("Manually input the option:\n" + opt + "s:\n[" + values + "]");
} else {
//alert("<%:Faltal on set option, please help in debug: %>" + opt);
}
}
}
var s = document.getElementById(urlname + '-status');
if (!s) {
alert("Never");
return false;
}
opt.base = "cbid." + urlname + "." + sid;
opt.client = urlname.indexOf("server") === -1;
var ssrurl = null;
if (cacheData) {
ssrurl = cacheData.ssrurl
} else {
ssrurl = prompt('<%:Paste Share URL Here%>', '');
if (ssrurl === null || ssrurl === "") {
return false;
}
sessionStorage.setItem("fromUrl", JSON.stringify({
timestamp: Date.now(),
savetime: 60 * 1000,
urlname: urlname,
sid: sid,
ssrurl: ssrurl
}));
}
ssrurl = ssrurl.split(/\r\n|\r|\n/, 1)[0];
ssrurl = ssrurl.replace(/&amp;/gi, '&').replace(/\s*#\s*/, '#').trim(); //一些奇葩的链接用"&amp;"当做"&""#"前后带空格
s.innerHTML = "";
var ssu = ssrurl.split('://');
var event = document.createEvent("HTMLEvents");
event.initEvent("change", true, true);
link = link.split(/\r\n|\r|\n/, 1)[0];
link = link.replace(/&amp;/gi, '&').replace(/\s*#\s*/, '#').trim(); //一些奇葩的链接用"&amp;"当做"&""#"前后带空格
const link_split = link.split('://');
const link_type = link_split[0];
var dom_prefix = null
let dom_prefix = null;
if (ssu[0] === "ssr") {
if (link_type === "ssr") {
dom_prefix = "ssr_"
//var b64c = ssu[1].match(/([A-Za-z0-9_-]+)/);
var sstr = b64decsafe((ssu[1] || "").replace(/#.*/, "").trim());
//var b64c = link_split[1].match(/([A-Za-z0-9_-]+)/);
var sstr = b64decsafe((link_split[1] || "").replace(/#.*/, "").trim());
var ploc = sstr.indexOf("/?");
var url0 = "", param = "";
if (ploc > 0) {
@@ -924,8 +891,8 @@ local current_node = map:get(section)
if (typeof (rem) !== 'undefined' && rem !== '' && rem.length > 0)
opt.set('remarks', b64decutf8safe(rem));
}
if (ssu[0] === "ss") {
var url0 = ssu[1] || "";
if (link_type === "ss") {
var url0 = link_split[1] || "";
param = "";
var ploc = url0.indexOf("#");
@@ -1000,15 +967,15 @@ local current_node = map:get(section)
queryParam.type && queryParam.type !== "tcp" &&
queryParam.headerType && queryParam.headerType !== "none";
if (has_xray && (ss_type == "xray" || needUpgrade || queryParam.type === "xhttp")) {
dom_prefix = "xray_"
dom_prefix = "xray_";
opt.set('type', "Xray");
opt.set(dom_prefix + 'protocol', "shadowsocks");
} else if (has_singbox && (ss_type == "sing-box" || needUpgrade)) {
dom_prefix = "singbox_"
dom_prefix = "singbox_";
opt.set('type', "sing-box");
opt.set(dom_prefix + 'protocol', "shadowsocks");
} else {
dom_prefix = "ssrust_"
dom_prefix = "ssrust_";
opt.set('type', "SS-Rust");
}
@@ -1208,17 +1175,17 @@ local current_node = map:get(section)
}
}
}
if (ssu[0] === "trojan") {
if (link_type === "trojan") {
if (trojan_type == "sing-box" && has_singbox) {
dom_prefix = "singbox_"
dom_prefix = "singbox_";
opt.set('type', "sing-box");
opt.set(dom_prefix + 'protocol', "trojan");
} else if (trojan_type == "xray" && has_xray) {
dom_prefix = "xray_"
dom_prefix = "xray_";
opt.set('type', "Xray");
opt.set(dom_prefix + 'protocol', "trojan");
}
var m = parseNodeUrl(ssrurl);
var m = parseNodeUrl(link);
var password = m.passwd;
if (password === "") {
s.innerHTML = "<font color='red'><%:Invalid Share URL Format%></font>";
@@ -1346,14 +1313,14 @@ local current_node = map:get(section)
opt.set('remarks', decodeURIComponent(m.hash.substr(1)));
}
}
if (ssu[0] === "vmess") {
var sstr = b64DecodeUnicode((ssu[1] || "").replace(/#.*/, "").trim());
if (link_type === "vmess") {
var sstr = b64DecodeUnicode((link_split[1] || "").replace(/#.*/, "").trim());
var ploc = sstr.indexOf("/?");
if (vmess_type == "sing-box" && has_singbox) {
dom_prefix = "singbox_"
dom_prefix = "singbox_";
opt.set('type', "sing-box");
} else if (has_xray) {
dom_prefix = "xray_"
dom_prefix = "xray_";
opt.set('type', "Xray");
}
opt.set(dom_prefix + 'protocol', "vmess");
@@ -1447,14 +1414,14 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'use_finalmask', !!ssm.fm);
opt.set(dom_prefix + 'finalmask', ssm.fm || "");
}
if (ssu[0] === "vless") {
if (link_type === "vless") {
if (vless_type == "sing-box" && has_singbox) {
dom_prefix = "singbox_";
} else if (has_xray) {
dom_prefix = "xray_";
}
var m = parseNodeUrl(ssrurl);
var m = parseNodeUrl(link);
var password = m.passwd;
if (password === "") {
s.innerHTML = "<font color='red'><%:Invalid Share URL Format%></font>";
@@ -1476,7 +1443,7 @@ local current_node = map:get(section)
if (["xhttp", "kcp", "mkcp"].includes(queryParam.type) && vless_type !== "xray" && has_xray) {
dom_prefix = "xray_";
}
opt.set("type", dom_prefix === "singbox_" ? "sing-box" : "Xray");
opt.set('type', dom_prefix == "singbox_" ? "sing-box" : "Xray");
opt.set(dom_prefix + 'protocol', "vless");
opt.set(dom_prefix + 'uuid', password);
@@ -1606,8 +1573,8 @@ local current_node = map:get(section)
opt.set('remarks', decodeURIComponent(m.hash.substr(1)));
}
}
if (ssu[0] === "hysteria2" || ssu[0] === "hy2") {
var m = parseNodeUrl(ssrurl);
if (link_type === "hysteria2" || link_type === "hy2") {
var m = parseNodeUrl(link);
var password = m.passwd;
if (password === "") {
s.innerHTML = "<font color='red'><%:Invalid Share URL Format%></font>";
@@ -1672,13 +1639,13 @@ local current_node = map:get(section)
opt.set('remarks', decodeURIComponent(m.hash.substr(1)));
}
}
if (ssu[0] === "tuic") {
if (link_type === "tuic") {
if (has_singbox) {
dom_prefix = "singbox_"
dom_prefix = "singbox_";
opt.set('type', "sing-box");
}
opt.set(dom_prefix + 'protocol', "tuic");
var _parsedUrl = new URL("http://" + ssu[1]);
var _parsedUrl = new URL("http://" + link_split[1]);
var username = _parsedUrl.username;
var password = _parsedUrl.password;
var hostname = _parsedUrl.hostname;
@@ -1721,13 +1688,13 @@ local current_node = map:get(section)
opt.set('remarks', decodeURIComponent(hash.substr(1)));
}
}
if (ssu[0] === "anytls") {
if (link_type === "anytls") {
if (has_singbox) {
dom_prefix = "singbox_"
dom_prefix = "singbox_";
opt.set('type', "sing-box");
}
opt.set(dom_prefix + 'protocol', "anytls");
var m = parseNodeUrl(ssrurl);
var m = parseNodeUrl(link);
var password = m.passwd;
if (password === "") {
s.innerHTML = "<font color='red'><%:Invalid Share URL Format%></font>";
@@ -1777,13 +1744,13 @@ local current_node = map:get(section)
opt.set('remarks', decodeURIComponent(m.hash.substr(1)));
}
}
if (ssu[0] === "naive+https" || ssu[0] === "naive+quic") {
if (link_type === "naive+https" || link_type === "naive+quic") {
if (has_singbox) {
dom_prefix = "singbox_"
dom_prefix = "singbox_";
opt.set('type', "sing-box");
}
opt.set(dom_prefix + 'protocol', "naive");
var _parsedUrl = new URL("http://" + ssu[1]);
var _parsedUrl = new URL("http://" + link_split[1]);
var username = _parsedUrl.username;
var password = _parsedUrl.password;
var hostname = _parsedUrl.hostname;
@@ -1818,7 +1785,7 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || '');
opt.set(dom_prefix + 'ech', !!queryParam.ech);
opt.set(dom_prefix + 'ech_config', queryParam.ech || '');
if (ssu[0] === "naive+quic") {
if (link_type === "naive+quic") {
opt.set(dom_prefix + 'naive_quic', true);
opt.set(dom_prefix + 'naive_congestion_control', queryParam.congestion_control || 'bbr');
}
@@ -1826,20 +1793,25 @@ local current_node = map:get(section)
opt.set('remarks', decodeURIComponent(hash.substr(1)));
}
}
if (dom_prefix && dom_prefix != null) {
const port = opt.get(dom_prefix + 'port');
if (!port) {
return false;
if (switch_type) {
sessionStorage.setItem("fromUrl", JSON.stringify({
timestamp: Date.now(),
savetime: 60 * 1000,
link: link
}));
opt.set("type", switch_type);
return
}
if (port.value) {
port.focus();
port.blur();
}
} else {
if (!dom_prefix) {
s.innerHTML = "<font color='red'><%:Invalid Share URL Format%></font>";
sessionStorage.removeItem("fromUrl");
return false;
}
if (opt.get(dom_prefix + 'port')) {
opt.get(dom_prefix + 'port').focus();
opt.get(dom_prefix + 'port').blur();
}
s.innerHTML = "<font color='green'><%:Import Finished %></font>";
sessionStorage.removeItem("fromUrl");
return true;
@@ -1849,17 +1821,6 @@ local current_node = map:get(section)
window.open('<%=api.url("gen_client_config")%>?id=' + sid, "_blank")
}
document.addEventListener("DOMContentLoaded", function () {
setTimeout(function () {
const fromUrlCache = JSON.parse(sessionStorage.getItem("fromUrl"));
if (fromUrlCache && fromUrlCache.savetime && (Date.now() - fromUrlCache.timestamp) < fromUrlCache.savetime) {
fromUrl(null, fromUrlCache.urlname, fromUrlCache.sid, fromUrlCache)
} else {
sessionStorage.removeItem("fromUrl");
}
}, 500);
})
//]]></script>
<style>
.share-toolbar {
@@ -1892,7 +1853,7 @@ local current_node = map:get(section)
<div class="share-toolbar">
<div class="toolbar-buttons">
<% if not current_node.protocol or not current_node.protocol:find("^_") then -%>
<input id="fromUrlBtn" type="button" class="btn cbi-button cbi-button-apply" value='<%:From Share URL%>' onclick="return fromUrl(this, '<%=self.option%>', '<%=self.value%>')" />
<input id="fromUrlBtn" type="button" class="btn cbi-button cbi-button-apply" value='<%:From Share URL%>' onclick="return tofromUrl()" />
<input id="exportUrlBtn" type="button" class="btn cbi-button cbi-button-apply" value='<%:Build Share URL%>' onclick="return exportUrl(this, '<%=self.option%>', '<%=self.value%>')" />
<input id="genQrcodeBtn" type="button" class="btn cbi-button cbi-button-apply" value='<%:Generate QRCode%>' onclick="return genQrcode(this, '<%=self.option%>', '<%=self.value%>')" />
<% end -%>
@@ -5,20 +5,21 @@ local appname = map.config
local is_js_luci = api.is_js_luci()
local section_tag = is_js_luci and "div" or "fieldset"
local default_node_type = ""
local shunt_rule_list = {}
local node = map:get("@global[0]", "node")
if node then
local node_type = map:get(node, "type")
local node_protocol = map:get(node, "protocol")
if (node_type == "Xray" or node_type == "sing-box") and node_protocol == "_shunt" then
default_node_type = node_protocol
if node_protocol == "_shunt" then
local node_shunt_group = map:get(node, "shunt_group")
map:foreach("shunt_rules", function(e)
if e[".name"] and e.remarks and e.group == node_shunt_group then
shunt_rule_list[#shunt_rule_list + 1] = e
end
end)
shunt_rule_list[#shunt_rule_list + 1] = {
[".name"] = "default_node",
remarks = translate("Default")
}
end
end
-%>
@@ -1671,14 +1672,13 @@ table td, .table .td {
<input class="btn cbi-button" type="button" onclick="set_node_div.set('@global[0]', 'node')" value="<%:OK%>" />
</div>
</div>
<% if default_node_type == "_shunt" then %>
<% if #shunt_rule_list > 0 then %>
<div id="set_node_shunt_div" style="margin: 0.5em 0; padding: 0.5em; background: #f7f7f7; border-radius: 4px;">
<div class="cbi-section-descr" style="text-align: left;"><a style="color:#FF8C00"><%:Set as the routing rule node:%></a></div>
<div>
<% for i, v in ipairs(shunt_rule_list) do %>
<input class="btn cbi-button" type="button" onclick="set_node_div.set('<%=node%>', '<%=v[".name"]%>')" value="<%=v.remarks%>" />
<% end %>
<input class="btn cbi-button" type="button" onclick="set_node_div.set('<%=node%>', 'default_node')" value="<%:Default%>" />
</div>
</div>
<% end %>
+2 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.9.9
PKG_RELEASE:=118
PKG_VERSION:=26.9.12
PKG_RELEASE:=119
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -668,6 +668,7 @@ if singbox_tags:find("with_utls") then
o:depends({ protocol = "socks", tls = true })
o:depends({ protocol = "trojan", tls = true })
o:depends({ protocol = "anytls", tls = true })
o:depends({ protocol = "http", tls = true })
o = s:option(Value, "reality_publicKey", translate("Public Key"))
o:depends({ reality = true })
@@ -213,7 +213,11 @@ o:depends({ protocol = "trojan" })
-- [[ REALITY ]] --
o = s:option(Flag, "reality", translate("REALITY"))
o.default = 0
o:depends({ tls = true })
o:depends({ tls = true, transport = "raw" })
o:depends({ tls = true, transport = "ws" })
o:depends({ tls = true, transport = "grpc" })
o:depends({ tls = true, transport = "httpupgrade" })
o:depends({ tls = true, transport = "xhttp" })
o = s:option(Value, "reality_private_key", translate("Private Key"))
o:depends({ reality = true })
@@ -502,9 +502,9 @@ function gen_outbound(flag, node, tag, proxy_table)
obfs = node.hysteria_obfs,
auth = (node.hysteria_auth_type == "base64") and node.hysteria_auth_password or nil,
auth_str = (node.hysteria_auth_type == "string") and node.hysteria_auth_password or nil,
recv_window_conn = tonumber(node.hysteria_recv_window_conn), --1.14 将变更为 stream_receive_window
recv_window = tonumber(node.hysteria_recv_window), --1.14 将变更为 connection_receive_window
disable_mtu_discovery = (node.hysteria_disable_mtu_discovery == "1") and true or false, --1.14 将变更为 disable_path_mtu_discovery
stream_receive_window = tonumber(node.hysteria_recv_window_conn),
connection_receive_window = tonumber(node.hysteria_recv_window),
disable_path_mtu_discovery = (node.hysteria_disable_mtu_discovery == "1") and true or false,
tls = tls
}
end
@@ -931,10 +931,10 @@ function gen_config_server(node)
down_mbps = tonumber(node.hysteria_down_mbps),
obfs = node.hysteria_obfs,
users = users,
recv_window_conn = node.hysteria_recv_window_conn and tonumber(node.hysteria_recv_window_conn) or nil, --1.14 to stream_receive_window
recv_window_client = node.hysteria_recv_window_client and tonumber(node.hysteria_recv_window_client) or nil, --1.14 to connection_receive_window
max_conn_client = node.hysteria_max_conn_client and tonumber(node.hysteria_max_conn_client) or nil, --1.14 to max_concurrent_streams
disable_mtu_discovery = (node.hysteria_disable_mtu_discovery == "1") and true or false, --1.14 to disable_path_mtu_discover
stream_receive_window = node.hysteria_recv_window_conn and tonumber(node.hysteria_recv_window_conn) or nil,
connection_receive_window = node.hysteria_recv_window_client and tonumber(node.hysteria_recv_window_client) or nil,
max_concurrent_streams = node.hysteria_max_conn_client and tonumber(node.hysteria_max_conn_client) or nil,
disable_path_mtu_discover = (node.hysteria_disable_mtu_discovery == "1") and true or false,
tls = tls
}
end
@@ -131,6 +131,10 @@ function gen_outbound(flag, node, tag, proxy_table)
node.stream_security = "tls"
end
if node.protocol == "http" and node.stream_security == "tls" then
node.transport = "raw"
end
if remarks then
tag = tag .. ":" .. remarks
end
@@ -148,7 +152,7 @@ function gen_outbound(flag, node, tag, proxy_table)
concurrency = (node.mux == "1" and ((node.mux_concurrency) and tonumber(node.mux_concurrency) or -1)) or nil,
xudpConcurrency = (node.mux == "1" and ((node.xudp_concurrency) and tonumber(node.xudp_concurrency) or 8)) or nil
} or nil,
streamSettings = (node.streamSettings or dialer_proxy_tag or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan" or node.protocol == "hysteria") and {
streamSettings = (node.streamSettings or dialer_proxy_tag or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan" or node.protocol == "hysteria" or node.protocol == "http") and {
sockopt = {
mark = 255,
domainStrategy = node.domain_strategy or "UseIP",
@@ -552,7 +556,7 @@ function gen_config_server(node)
allowTransparent = false,
users = users
}
node.transport = "tcp"
node.transport = "raw"
node.tcp_guise = "none"
elseif node.protocol == "shadowsocks" then
settings = {
@@ -818,7 +818,7 @@ add_firewall_rule() {
# jump chains
# 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"
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"
[ -n "${is_tproxy}" ] && nft "add rule $NFTABLE_NAME mangle_prerouting ip daddr != @$NFTSET_DIRECT ip protocol tcp counter jump PSW2_MANGLE"
@@ -889,7 +889,7 @@ add_firewall_rule() {
# jump chains
[ "$PROXY_IPV6" == "1" ] && {
# 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"
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_output ip6 daddr != @$NFTSET_DIRECT6 meta nfproto {ipv6} counter jump PSW2_OUTPUT_MANGLE_V6 comment \"PSW2_OUTPUT_MANGLE\""
+2 -2
View File
@@ -4,7 +4,7 @@ LUCI_TITLE:=luci-app-ssr-plus
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-ssr-plus
PKG_VERSION:=196
PKG_RELEASE:=68
PKG_RELEASE:=70
PKG_CONFIG_DEPENDS:= \
CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \
@@ -32,7 +32,7 @@ LUCI_PKGARCH:=all
LUCI_DEPENDS:= \
+libuci-lua +lua +luci-compat +coreutils +coreutils-base64 +dns2tcp +dnsmasq-full \
+jq +ip-full +lua-neturl +libuci-lua +microsocks +ipt2socks +lyaml \
+resolveip +curl +nping +unzip +xz-utils +xz \
+resolveip +bind-dig +curl +nping +unzip +xz-utils +xz \
+PACKAGE_$(PKG_NAME)_INCLUDE_Xray:xray-core \
+PACKAGE_$(PKG_NAME)_INCLUDE_Xray:coreutils-timeout \
+PACKAGE_$(PKG_NAME)_INCLUDE_Http_Proxy:3proxy \
@@ -5,24 +5,27 @@
local m, s, sec, o
local uci = require "luci.model.uci".cursor()
local URL = require "url"
local sys = require "luci.sys"
local util = require "luci.util"
local datatypes = require "luci.cbi.datatypes"
-- 获取 LAN IP 地址
function lanip()
local lan_ip
-- 尝试从 UCI 直接读取
lan_ip = luci.sys.exec("uci -q get network.lan.ipaddr 2>/dev/null | awk -F'/' '{print $1}' | tr -d '\\n'")
lan_ip = sys.exec("uci -q get network.lan.ipaddr 2>/dev/null | awk -F'/' '{print $1}' | tr -d '\\n'")
-- 尝试从 LAN 接口信息中读取(优先 ifname,再 fallback 到 device
if not lan_ip or lan_ip == "" then
lan_ip = luci.sys.exec([[
lan_ip = sys.exec([[
ip -4 addr show $(uci -q -p /tmp/state get network.lan.device || uci -q -p /tmp/state get network.lan.device) 2>/dev/null \
| grep -w 'inet' | awk '{print $2}' | cut -d'/' -f1 | grep -v '^127\.' | head -n1 | tr -d '\n']])
end
-- 取任意一个 global IPv4 地址
if not lan_ip or lan_ip == "" then
lan_ip = luci.sys.exec([[
lan_ip = sys.exec([[
ip -4 addr show scope global 2>/dev/null \
| grep -w 'inet' | awk '{print $2}' | cut -d'/' -f1 | grep -v '^127\.' | head -n1 | tr -d '\n']])
end
@@ -31,10 +34,9 @@ ip -4 addr show scope global 2>/dev/null \
end
local lan_ip = lanip()
local validation = require "luci.cbi.datatypes"
local clash_nodes = {}
local function is_finded(e)
return luci.sys.exec(string.format('type -t -p "%s" -p "/usr/libexec/%s" 2>/dev/null', e, e)) ~= ""
return sys.exec(string.format('type -t -p "%s" -p "/usr/libexec/%s" 2>/dev/null', e, e)) ~= ""
end
local function clash_display_name(s)
@@ -48,6 +50,28 @@ local function clash_display_name(s)
return "[CLASH]"
end
local function is_valid_dns(str)
if datatypes.ip4addrport(str) then
return true
end
local scheme, target = str:match("^([a-zA-Z0-9%+%-%.]+)://(.+)$")
if scheme and target and #target > 0 then
return true
end
local host = str:match("^([^:]+):%d+$") or str
if host:match("[a-zA-Z]")
and not host:match("[^%w%.%-]")
and not host:match("^[%.%-]")
and not host:match("[%.%-]$")
and not host:match("%.%.") then
return true
end
return false
end
m = Map("shadowsocksr", translate("ShadowSocksR Plus+ Settings"), translate("<h3>Support SS/SSR/V2RAY/XRAY/TROJAN/TUIC/HYSTERIA2/NAIVEPROXY/SOCKS5/CLASH etc.</h3>"))
m:section(SimpleSection).template = "shadowsocksr/status"
@@ -131,7 +155,7 @@ o:value("7", translate("Prefer module built-in DNS"))
o:value("0", translate("Use Local DNS Service listen port 5335"))
o.default = 1
o = s:option(Value, "tunnel_forward", translate("Anti-pollution DNS Server"))
o = s:option(Value, "dns2tcp_tunnel_forward", translate("Anti-pollution DNS Server"))
o:value("8.8.4.4:53", translate("Google Public DNS (8.8.4.4)"))
o:value("8.8.8.8:53", translate("Google Public DNS (8.8.8.8)"))
o:value("208.67.222.222:53", translate("OpenDNS (208.67.222.222)"))
@@ -144,11 +168,55 @@ o:value("4.2.2.3:53", translate("Level 3 Public DNS (4.2.2.3)"))
o:value("4.2.2.4:53", translate("Level 3 Public DNS (4.2.2.4)"))
o:value("1.1.1.1:53", translate("Cloudflare DNS (1.1.1.1)"))
o:depends("pdnsd_enable", "1")
o:depends("pdnsd_enable", "7")
o.description = translate("Custom DNS Server format as IP:PORT (default: 8.8.4.4:53)")
o.datatype = "ip4addrport"
o.default = "8.8.4.4:53"
o = s:option(Value, "tunnel_forward", translate("Anti-pollution DNS Server"))
o:value("8.8.4.4:53", translate("Google Public DNS (8.8.4.4)"))
o:value("8.8.8.8:53", translate("Google Public DNS (8.8.8.8)"))
o:value("208.67.222.222:53", translate("OpenDNS (208.67.222.222)"))
o:value("208.67.220.220:53", translate("OpenDNS (208.67.220.220)"))
o:value("209.244.0.3:53", translate("Level 3 Public DNS (209.244.0.3)"))
o:value("209.244.0.4:53", translate("Level 3 Public DNS (209.244.0.4)"))
o:value("4.2.2.1:53", translate("Level 3 Public DNS (4.2.2.1)"))
o:value("4.2.2.2:53", translate("Level 3 Public DNS (4.2.2.2)"))
o:value("4.2.2.3:53", translate("Level 3 Public DNS (4.2.2.3)"))
o:value("4.2.2.4:53", translate("Level 3 Public DNS (4.2.2.4)"))
o:value("1.1.1.1:53", translate("Cloudflare DNS (1.1.1.1)"))
o:depends("pdnsd_enable", "7")
o.datatype = nil
o.description =
"<ul>" ..
"<li>" .. translate("Custom DNS Server (supports format IP:PORT, domain name, or scheme://...).") .. "</li>" ..
"<li>" .. translate("Muitiple DNS server can saperate with ','") .. "</li>" ..
"<li>" .. translate("Note: IP addresses MUST specify a port.") .. "</li>" ..
"</ul>"
o.default = "8.8.4.4:53"
o.validate = function(self, value, section)
if section and value and value ~= "" then
local parts = {}
for part in (value .. ","):gmatch("(.-),") do
part = part:gsub("^%s*", ""):gsub("%s*$", "")
if part ~= "" then
if not is_valid_dns(part) then
return nil, translate("Expecting: %s"):format(translate("IP:PORT, Domain, or Protocol URL (e.g. 8.8.8.8:53, dns.google, https://...).") ..
"<br>".. translate("Note: Pure IP without port is not allowed."))
end
table.insert(parts, part)
end
end
if #parts > 0 then
return table.concat(parts, ",")
end
return nil, translate("Expecting: %s"):format(translate("valid address:port")) -- 有效的地址:端口
end
return value
end
o = s:option(Value, "tunnel_forward_mosdns", translate("Anti-pollution DNS Server"))
o:value("tcp://8.8.4.4:53,tcp://8.8.8.8:53", translate("Google Public DNS"))
o:value("tcp://208.67.222.222:53,tcp://208.67.220.220:53", translate("OpenDNS"))
@@ -225,7 +293,7 @@ if is_finded("chinadns-ng") then
local parts = {}
for part in string.gmatch(value, "[^,]+") do
part = part:gsub("^%s*", ""):gsub("%s*$", "")
if not validation.ip4addrport(part) then
if not datatypes.ip4addrport(part) then
return nil, translate("Expecting: %s"):format(translate("valid address:port (comma separated)"))
end
table.insert(parts, part)
@@ -236,7 +304,7 @@ if is_finded("chinadns-ng") then
return table.concat(parts, ",")
end
if validation.ip4addrport(value) then
if datatypes.ip4addrport(value) then
return value
end
@@ -1,8 +1,11 @@
require "luci.ip"
require "nixio.fs"
require "luci.sys"
local ucic = luci.model.uci.cursor()
local m, s, o
local enable_mihomo = ucic:get_first('shadowsocksr', 'server_subscribe', 'enable_mihomo') or ''
m = Map("shadowsocksr")
s = m:section(TypedSection, "access_control")
@@ -116,7 +119,7 @@ o.rows = 13
o.wrap = "off"
o.rmempty = true
o.cfgvalue = function(self, section)
return nixio.fs.readfile(denydomainconf) or " "
return nixio.fs.readfile(denydomainconf) or ""
end
o.write = function(self, section, value)
nixio.fs.writefile(denydomainconf, value:gsub("\r\n", "\n"))
@@ -125,6 +128,54 @@ o.remove = function(self, section, value)
nixio.fs.writefile(denydomainconf, "")
end
if enable_mihomo == "1" then
s:tab("fake_ip_filter", translate("Fake-IP Filter and Fallback Lists"))
local fake_ip_filter_conf = "/etc/ssrplus/fake_ip_filter.list"
o = s:taboption("fake_ip_filter", TextValue, "fake_ip_filter_conf", "", "<font style=color:red>" .. translate("Specifically for editing the Fake-IP filter list.") .. "</font>") -- 专用于编辑 Fake-IP 过滤规则文件。
o.rows = 13
o.wrap = "off"
o.rmempty = true
o.cfgvalue = function(self, section)
return nixio.fs.readfile(fake_ip_filter_conf) or ""
end
o.write = function(self, section, value)
nixio.fs.writefile(fake_ip_filter_conf, value:gsub("\r\n", "\n"))
end
o.remove = function(self, section, value)
nixio.fs.writefile(fake_ip_filter_conf, "")
end
local fallback_ipcidr_conf = "/etc/ssrplus/fallback_ipcidr.list"
o = s:taboption("fake_ip_filter", TextValue, "fallback_ipcidr_conf", "", "<font style=color:red>" .. translate("Specifically for editing the Fallback IP-CIDR list.") .. "</font>") -- 专用于编辑 Fallback IP-CIDR 规则文件。
o.rows = 13
o.wrap = "off"
o.rmempty = true
o.cfgvalue = function(self, section)
return nixio.fs.readfile(fallback_ipcidr_conf) or ""
end
o.write = function(self, section, value)
nixio.fs.writefile(fallback_ipcidr_conf, value:gsub("\r\n", "\n"))
end
o.remove = function(self, section, value)
nixio.fs.writefile(fallback_ipcidr_conf, "")
end
local fallback_domain_conf = "/etc/ssrplus/fallback_domain.list"
o = s:taboption("fake_ip_filter", TextValue, "fallback_domain_conf", "", "<font style=color:red>" .. translate("Specifically for editing the Fallback domain filter list.") .. "</font>") -- 专用于编辑 Fallback 域名过滤规则文件。
o.rows = 13
o.wrap = "off"
o.rmempty = true
o.cfgvalue = function(self, section)
return nixio.fs.readfile(fallback_domain_conf) or ""
end
o.write = function(self, section, value)
nixio.fs.writefile(fallback_domain_conf, value:gsub("\r\n", "\n"))
end
o.remove = function(self, section, value)
nixio.fs.writefile(fallback_domain_conf, "")
end
end
if luci.sys.call('[ -f "/www/luci-static/resources/uci.js" ]') == 0 then
m.apply_on_parse = true
function m.on_apply(self)
@@ -188,9 +188,10 @@ local function save_uploaded_clash_node(upload_name, final_path)
local alias
if not sid then
sid = uci:add("shadowsocksr", "servers")
local sid_output = luci.sys.exec("uci add shadowsocksr servers 2>/dev/null")
sid = trim(sid_output)
end
if not sid then
if not sid or sid == "" then
return nil
end
@@ -200,20 +201,50 @@ local function save_uploaded_clash_node(upload_name, final_path)
alias = upload_alias(upload_name)
end
uci:set("shadowsocksr", sid, "type", "clash")
uci:set("shadowsocksr", sid, "alias", alias)
uci:set("shadowsocksr", sid, "server", "127.0.0.1")
uci:set("shadowsocksr", sid, "server_port", "0")
uci:delete("shadowsocksr", sid, "clash_url")
uci:set("shadowsocksr", sid, "clash_path", final_path)
uci:set("shadowsocksr", sid, "clash_user_agent", uci:get("shadowsocksr", sid, "clash_user_agent") or "clash")
if not uci:get("shadowsocksr", sid, "switch_enable") then
uci:set("shadowsocksr", sid, "switch_enable", uci:get_first("shadowsocksr", "server_subscribe", "switch", "1") or "1")
local switch_enable = uci:get("shadowsocksr", sid, "switch_enable")
if not switch_enable or switch_enable == "" then
switch_enable = uci:get_first("shadowsocksr", "server_subscribe", "switch", "1") or "1"
end
local clash_user_agent = uci:get("shadowsocksr", sid, "clash_user_agent")
if not clash_user_agent or clash_user_agent == "" then
clash_user_agent = "clash"
end
local safe_sid = luci.util.shellquote(sid)
local safe_alias = luci.util.shellquote(alias)
local safe_path = luci.util.shellquote(final_path)
local safe_upload_name = luci.util.shellquote(upload_name)
local safe_switch_enable = luci.util.shellquote(switch_enable)
local safe_user_agent = luci.util.shellquote(clash_user_agent)
local cmd = string.format(
"uci set shadowsocksr.%s.type=clash && " ..
"uci set shadowsocksr.%s.alias=%s && " ..
"uci set shadowsocksr.%s.server=127.0.0.1 && " ..
"uci set shadowsocksr.%s.server_port=0 && " ..
"uci delete shadowsocksr.%s.clash_url 2>/dev/null; " ..
"uci set shadowsocksr.%s.clash_path=%s && " ..
"uci set shadowsocksr.%s.clash_user_agent=%s && " ..
"uci set shadowsocksr.%s.switch_enable=%s && " ..
"uci set shadowsocksr.%s.yaml_upload=1 && " ..
"uci set shadowsocksr.%s.yaml_upload_name=%s && " ..
"uci commit shadowsocksr",
safe_sid,
safe_sid, safe_alias,
safe_sid,
safe_sid,
safe_sid,
safe_sid, safe_path,
safe_sid, safe_user_agent,
safe_sid, safe_switch_enable,
safe_sid,
safe_sid, safe_upload_name
)
if luci.sys.call(cmd) ~= 0 then
return nil
end
uci:set("shadowsocksr", sid, "yaml_upload", "1")
uci:set("shadowsocksr", sid, "yaml_upload_name", upload_name)
uci:save("shadowsocksr")
uci:commit("shadowsocksr")
cleanup_old_clash_path(old_path, final_path, sid)
luci.sys.call(string.format("/etc/init.d/shadowsocksr clash_cache %s >/dev/null 2>&1 &", luci.util.shellquote(sid)))
@@ -267,33 +298,49 @@ if has_mihomo then
local tmp_output = string.format("%s/.upload-%d-%d.yaml", CLASH_YAML_DIR, nixio.getpid(), os.time())
local sid
local alias
local redirect_message_url
nixio.fs.mkdirr(CLASH_YAML_DIR)
nixio.fs.remove(tmp_output)
if preprocess_clash_yaml(upload_tmp_path, tmp_output) then
hash = hash_file(tmp_output)
if hash == "" then
nixio.fs.remove(tmp_output)
if not hash or hash == "" then
upload_errmessage = translate("Uploaded YAML validation or preprocessing failed.")
else
final_path = string.format("%s/%s.yaml", CLASH_YAML_DIR, hash)
luci.sys.call(string.format("mv -f %s %s", luci.util.shellquote(tmp_output), luci.util.shellquote(final_path)))
local mv_ret = luci.sys.call(string.format("mv -f %s %s", luci.util.shellquote(tmp_output), luci.util.shellquote(final_path)))
if mv_ret == 0 and nixio.fs.access(final_path) then
sid, alias = save_uploaded_clash_node(upload_filename, final_path)
if sid then
upload_message = string.format(translate("Custom YAML imported successfully: %s"), alias or sid)
local message = string.format(translate("Custom YAML imported successfully: %s"), alias or sid)
local redirect_url = luci.dispatcher.build_url("admin", "services", "shadowsocksr", "servers")
redirect_message_url = redirect_url .. "?upload_message=" .. luci.http.urlencode(message)
else
upload_errmessage = translate("Uploaded YAML validation or preprocessing failed.")
if final_path and nixio.fs.access(final_path) then
nixio.fs.remove(final_path)
end
end
else
nixio.fs.remove(tmp_output)
upload_errmessage = translate("Uploaded YAML validation or preprocessing failed.")
upload_errmessage = translate("Failed to move uploaded YAML to target directory.")
end
end
else
upload_errmessage = upload_errmessage or translate("Uploaded YAML validation or preprocessing failed.")
end
if upload_tmp_path then
if upload_tmp_path and nixio.fs.access(upload_tmp_path) then
nixio.fs.remove(upload_tmp_path)
end
if tmp_output and nixio.fs.access(tmp_output) then
nixio.fs.remove(tmp_output)
end
if redirect_message_url then
luci.http.redirect(redirect_message_url)
return
end
end
end
end
@@ -407,8 +454,11 @@ end
m = Map("shadowsocksr", translate("Servers subscription and manage"))
if upload_errmessage then
m.errmessage = upload_errmessage
elseif upload_message then
m.message = upload_message
else
local message = luci.http.formvalue("upload_message")
if message and message ~= "" then
m.message = message
end
end
local style_section = m:section(SimpleSection)
@@ -566,6 +616,29 @@ if has_mihomo then
o.rmempty = true
o.description = translate("Disabled means use Xray or ShadowSocks-Rust core.")
o = s:option(Flag, "enable_fake_ip", string.format("<b><span style='color:red;'>%s</span></b>", translate("Enable Fake-IP Mode")))
o.default = "0"
o.rmempty = true
o:depends("enable_mihomo", "1")
o.description = translate(
"<ul>" ..
"<li>" .. translate("Enable Fake-IP mode; disable to use redir-host mode.") .. "</li>" ..
"<li>" .. translate("Enable Fake-IP mode for better performance.") .. "</li>" ..
"<li>" .. translate("If disabled, Redir-Host mode will be used which returns real IP addresses.") .. "</li>" ..
"<li>" .. translate("Tips: Fake-IP Filter and Fallback Lists:") .. " " ..
"<span id='fake_ip_filter_control_link'></span>" ..
"<script type='text/javascript'>" ..
"(function() {" ..
"var url = '" .. luci.dispatcher.build_url("admin", "services", "shadowsocksr", "control") .. "?tab=fake_ip_filter#fake_ip_filter';" ..
"var linkText = '<font style=\"color:green\"><b>" .. translate("Click here to manage") .. "</b></font>';" ..
"var elem = document.getElementById('fake_ip_filter_control_link');" ..
"if (elem) elem.innerHTML = '<a href=\"' + url + '\">' + linkText + '</a>';" ..
"})();" ..
"</script>" ..
"</li>" ..
"</ul>"
)
o = s:option(Flag, "sub_convert", translate("Subscribe Convert Online"))
o.description = translate("Convert subscriptions to Clash/Mihomo YAML with a subscription template URL.")
o.default = "0"
@@ -1,4 +1,4 @@
<%+cbi/valueheader%>
<input class="cbi-input-file" style="width: 400px" type="file" id="clash_yaml_file" name="clash_yaml_file" accept=".yaml,.yml" />
<input type="submit" class="cbi-button cbi-input-apply" name="upload_clash_yaml" value="<%:Upload%>" />
<input type="submit" class="bt cbi-button cbi-input-apply" name="upload_clash_yaml" value="<%:Upload%>" />
<%+cbi/valuefooter%>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+62 -6
View File
@@ -608,6 +608,10 @@ prepare_clash_runtime_config() {
local dns_mode
local client_policy_stats
local client_rules
local enable_fake_ip
local socks5_auth
local socks5_user
local socks5_pass
cache_file="$(get_clash_cache_file "$sid")"
workdir="$(get_clash_workdir "$sid")"
@@ -623,6 +627,17 @@ prepare_clash_runtime_config() {
dns_mode="$(uci_get_by_type global pdnsd_enable 0)"
client_policy_stats=""
client_rules=0
enable_fake_ip="$(uci_get_by_type server_subscribe enable_fake_ip "")"
socks5_auth="$(uci_get_by_type socks5_proxy socks5_auth noauth)"
socks5_user="$(uci_get_by_type socks5_proxy socks5_user "")"
socks5_pass="$(uci_get_by_type socks5_proxy socks5_pass "")"
if [ "$socks5_auth" = "password" ]; then
if [ -z "$socks5_user" ] || [ -z "$socks5_pass" ]; then
echolog "警告:SOCKS5 代理未完整配置用户名或密码,已自动降级为无认证模式 (noauth)。"
socks5_auth="noauth"
fi
fi
[ -s "$cache_file" ] || return 1
@@ -640,17 +655,41 @@ prepare_clash_runtime_config() {
routing-mark: $CLASH_OUTBOUND_MARK
profile:
store-selected: true
store-fake-ip: true
tun:
enable: false
EOF
# 根据 dns_mode 添加不同的 dns 配置
if [ "$dns_mode" = "7" ]; then
# 根据 enable_fake_ip 决定 enhanced-mode
if [ "$enable_fake_ip" = "1" ]; then
ENHANCED_MODE="fake-ip"
else
ENHANCED_MODE="redir-host"
fi
cat >> "$overlay_file" <<-EOF
dns:
enable: $( [ "$dns_mode" = "7" ] && echo "true" || echo "false" )
enhanced-mode: redir-host
enable: true
enhanced-mode: $ENHANCED_MODE
listen: 127.0.0.1:$dns_port
ipv6: $( [ "$dns_ipv4_only" = "1" ] && echo "false" || echo "true" )
EOF
else
cat >> "$overlay_file" <<-EOF
dns:
enable: false
EOF
fi
if [ -n "$socks_port" ] && [ "$socks_port" != "0" ]; then
echo "socks-port: $socks_port" >>"$overlay_file"
if [ "$socks5_auth" = "password" ]; then
cat >>"$overlay_file" <<-EOF
authentication:
- "$socks5_user:$socks5_pass"
EOF
fi
fi
merge_stats="$(/usr/bin/lua "$CLASH_YAML_HELPER" merge "$raw_file" "$overlay_file" "$runtime_file" 2>/dev/null)" || return 1
@@ -1230,14 +1269,24 @@ start_dns() {
ssrplus_dns="$(uci_get_by_type global pdnsd_enable 0)"
global_server_type="$(uci_get_by_name "$GLOBAL_SERVER" type)"
builtin_dns_enabled=0
dnsserver="$(uci_get_by_type global tunnel_forward 8.8.4.4:53)"
if [ "$ssrplus_dns" = "5" ]; then
ssrplus_dns="$(force_dns2tcp_fallback "旧版 DNSPROXY 模式已移除")" || return 1
uci_set_by_type global pdnsd_enable "$ssrplus_dns"
elif [ "$ssrplus_dns" = "6" ]; then
dnsserver="$(uci_get_by_type global chinadns_ng_tunnel_forward 8.8.4.4:53)"
fi
case "$ssrplus_dns" in
1)
dnsserver="$(uci_get_by_type global dns2tcp_tunnel_forward 8.8.4.4:53)"
;;
6)
dnsserver="$(uci_get_by_type global chinadns_ng_tunnel_forward 8.8.4.4:53)"
;;
7)
dnsserver="$(uci_get_by_type global tunnel_forward 8.8.4.4:53)"
;;
*)
;;
esac
run_mode="$(normalize_run_mode)"
if [ "$ssrplus_dns" = "4" ] && ! is_finded "mosdns"; then
@@ -2887,12 +2936,18 @@ start_rules() {
local ac_ips
local custom_ports
local proxyport
local enable_fake_ip
server=$(get_host_ip "$GLOBAL_SERVER")
local_port=$(get_default_node_local_port)
lan_ac_ips=$(uci_get_by_type access_control lan_ac_ips)
lan_ac_mode=$(uci_get_by_type access_control lan_ac_mode)
enable_fake_ip=$(uci_get_by_type server_subscribe enable_fake_ip "")
if [ -z "$enable_fake_ip" ]; then
enable_fake_ip="0"
fi
if [ "$kcp_enable_flag" = "0" ] && [ "$redir_udp" = "1" ]; then
udp_server=$(get_host_ip "$UDP_RELAY_SERVER")
udp_local_port=$tmp_udp_port
@@ -2953,10 +3008,11 @@ start_rules() {
-G "$(uci_get_by_type access_control lan_gm_ips)" \
-m "$(uci_get_by_type access_control Interface)" \
-D "$proxyport" \
-E "$enable_fake_ip" \
"$(get_arg_out)" "$(gfwmode)" "$ARG_UDP" "$ARG_UDP_RULES" "$ARG_A"
return $?
}
}
start() {
local run_mode
@@ -0,0 +1,78 @@
*.lan
*.localdomain
*.example
*.invalid
*.localhost
*.test
*.local
*.home.arpa
time.*.com
time.*.gov
time.*.edu.cn
time.*.apple.com
time1.*.com
time2.*.com
time3.*.com
time4.*.com
time5.*.com
time6.*.com
time7.*.com
ntp.*.com
ntp1.*.com
ntp2.*.com
ntp3.*.com
ntp4.*.com
ntp5.*.com
ntp6.*.com
ntp7.*.com
*.time.edu.cn
*.ntp.org.cn
+.pool.ntp.org
time1.cloud.tencent.com
stun.*.*
stun.*.*.*
swscan.apple.com
mesu.apple.com
music.163.com
*.music.163.com
*.126.net
musicapi.taihe.com
music.taihe.com
songsearch.kugou.com
trackercdn.kugou.com
*.kuwo.cn
api-jooxtt.sanook.com
api.joox.com
y.qq.com
*.y.qq.com
streamoc.music.tc.qq.com
mobileoc.music.tc.qq.com
isure.stream.qqmusic.qq.com
dl.stream.qqmusic.qq.com
aqqmusic.tc.qq.com
amobile.music.tc.qq.com
localhost.ptlogin2.qq.com
*.msftconnecttest.com
*.msftncsi.com
*.xiami.com
*.music.migu.cn
music.migu.cn
+.wotgame.cn
+.wggames.cn
+.wowsgame.cn
+.wargaming.net
*.*.*.srv.nintendo.net
*.*.stun.playstation.net
xbox.*.*.microsoft.com
*.*.xboxlive.com
*.ipv6.microsoft.com
teredo.*.*.*
teredo.*.*
speedtest.cros.wr.pvp.net
+.jjvip8.com
www.douyu.com
activityapi.huya.com
activityapi.huya.com.w.cdngslb.com
www.bilibili.com
api.bilibili.com
a.w.bilicdn1.com
@@ -0,0 +1,9 @@
+.google.com
+.facebook.com
+.youtube.com
+.twitter.com
+.telegram.org
+.githubusercontent.com
+.googlevideo.com
+.msftconnecttest.com
+.msftncsi.com
@@ -0,0 +1,15 @@
0.0.0.0/8
10.0.0.0/8
100.64.0.0/10
127.0.0.0/8
169.254.0.0/16
172.16.0.0/12
192.0.0.0/24
192.0.2.0/24
192.88.99.0/24
192.168.0.0/16
198.51.100.0/24
203.0.113.0/24
224.0.0.0/4
240.0.0.0/4
255.255.255.255/32
@@ -47,6 +47,18 @@ if [ -s "/etc/config/shadowsocksr" ]; then
uci -q set shadowsocksr.@server_subscribe[0].auto_update_min_time='0'
fi
if ! uci -q get shadowsocksr.@server_subscribe[0].enable_mihomo > /dev/null; then
uci -q set shadowsocksr.@server_subscribe[0].enable_mihomo='1'
fi
if ! uci -q get shadowsocksr.@server_subscribe[0].enable_fake_ip > /dev/null; then
uci -q set shadowsocksr.@server_subscribe[0].enable_fake_ip='1'
fi
if ! uci -q get shadowsocksr.@server_subscribe[0].sub_convert > /dev/null; then
uci -q set shadowsocksr.@server_subscribe[0].sub_convert='1'
fi
if ! uci -q get shadowsocksr.@server_subscribe[0].config_auto_update_mode > /dev/null; then
uci -q set shadowsocksr.@server_subscribe[0].config_auto_update_mode='0'
fi
+247 -44
View File
@@ -46,6 +46,7 @@ CHECK_STATUS=0
RESTORE_RULES=0
FLUSH_RULES=0
CLEANUP_PERSISTENCE=0
ENABLE_FAKE_IP=0
# ASCII code for SSRP.Use whatever,just not the same.
FWMARK="0x53535250"
@@ -83,6 +84,7 @@ usage() {
-m <Interface> Interface name
-I <ip_list_file> a file content is bypassed shunt ip list
-e <extra_options> extra options for iptables
-E <0|1> enable_fake_ip status
-o apply the rules to the OUTPUT chain
-O apply the global rules to the OUTPUT chain
-u enable udprelay mode, TPROXY is required
@@ -232,6 +234,12 @@ cleanup_persistence_files() {
loger 5 "Removed xhttp hash file: /tmp/.last_xhttp_hash"
fi
# Remove Fake_IP Mode state file
if [ -f "$ENABLE_FAKE_IP_STATE_FILE" ]; then
rm -f "$ENABLE_FAKE_IP_STATE_FILE" 2>/dev/null
loger 5 "Removed Fake_IP Mode state file: $ENABLE_FAKE_IP_STATE_FILE"
fi
loger 5 "Persistence cleanup completed"
return 0
}
@@ -315,25 +323,64 @@ flush_nftables() {
flush_iptables_legacy() {
flush_iptables() {
local ipt="iptables -t $1"
local DAT=$(iptables-save -t $1)
eval $(echo "$DAT" | grep "$TAG" | sed -e 's/^-A/$ipt -D/' -e 's/$/;/')
for chain in $(echo "$DAT" | awk '/^:SS_SPEC/{print $1}'); do
$ipt -F ${chain:1} 2>/dev/null && $ipt -X ${chain:1}
local table=$1
local ipt="iptables -t $table"
local DAT
DAT=$(iptables-save -t $table 2>/dev/null)
[ -z "$DAT" ] && return 0
# 1. 解除所有引用 SS_SPEC* 链的规则
echo "$DAT" | grep -E "^-A .*SS_SPEC" | while read -r line; do
local chain rule
chain=$(echo "$line" | awk '{print $2}')
rule=$(echo "$line" | sed 's/^-A [^ ]* //')
[ -n "$chain" ] && [ -n "$rule" ] && \
eval "$ipt -D \"$chain\" $rule 2>/dev/null"
done
# 2. 删除带 TAG 或 mark 的残留规则(同样加上 eval)
echo "$DAT" | grep -E "^-A " | grep -E "$TAG|-m mark|--mark" | while read -r line; do
local chain rule
chain=$(echo "$line" | awk '{print $2}')
rule=$(echo "$line" | sed 's/^-A [^ ]* //')
[ -n "$chain" ] && [ -n "$rule" ] && \
eval "$ipt -D \"$chain\" $rule 2>/dev/null"
done
# 3. 提取所有以 SS_SPEC 开头的自定义链
local chains
chains=$(echo "$DAT" | awk '/^:SS_SPEC/{sub(/^:/,"",$1); print $1}')
# 4. 清空并彻底删除 SS_SPEC* 自定义链
for chain in $chains; do
$ipt -F "$chain" 2>/dev/null
$ipt -X "$chain" 2>/dev/null
done
return 0
}
# 依次对 nat 和 mangle 表执行清理
flush_iptables nat
flush_iptables mangle
if ip rule show | grep -Eq "fwmark ${FWMARK}.*lookup 999"; then
# 清理策略路由与路由表
if ip rule show | grep -Eiq "fwmark ${FWMARK}.*lookup 999"; then
ip rule del fwmark ${FWMARK} table 999 2>/dev/null
fi
if ip route show table 999 | grep -Eq "^local.*dev lo"; then
ip route del local 0.0.0.0/0 dev lo table 999 2>/dev/null
fi
# 清理 IPSET 集合(先 -F 清空,后 -X 销毁)
for setname in ss_spec_lan_ac ss_spec_wan_ac ssr_gen_router \
china fplan bplan gmlan whitelist blacklist gfwlist music; do
ipset -F $setname 2>/dev/null
ipset -X $setname 2>/dev/null
done
[ -n "$FWI" ] && echo '#!/bin/sh' >$FWI
return 0
}
@@ -524,6 +571,11 @@ fw_rule() {
}
fw_rule_nft() {
# 优先将 Fake-IP (198.18.0.0/16) 的 TCP 流量重定向至代理端口
if [ "$ENABLE_FAKE_IP" = "1" ]; then
$NFT add rule inet ss_spec ss_spec_wan_fw ip daddr 198.18.0.0/16 meta l4proto tcp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
fi
# redirect/translation: when PROXY_PORTS present, redirect those tcp ports to local_port
if [ -n "$PROXY_PORTS" ]; then
PORTS_ARGS=$(echo "$PROXY_PORTS" | sed 's/-m multiport --dports //')
@@ -559,6 +611,10 @@ fw_rule_iptables() {
$IPT -A SS_SPEC_WAN_FW -d "$net" -j RETURN
done
if [ "$ENABLE_FAKE_IP" = "1" ]; then
$IPT -A SS_SPEC_WAN_FW -d 198.18.0.0/16 -p tcp -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
fi
$IPT -A SS_SPEC_WAN_FW -p tcp $PROXY_PORTS -j REDIRECT --to-ports "$local_port" 2>/dev/null || {
loger 3 "Can't redirect TCP, please check the iptables."
exit 1
@@ -611,6 +667,33 @@ ac_rule_nft() {
fi
$NFT flush chain inet ss_spec ss_spec_prerouting 2>/dev/null
# Fake-IP 重定向处理 (引入 Interface 匹配,防止 WAN 口越权)
if [ "$ENABLE_FAKE_IP" = "1" ]; then
if [ -z "$Interface" ]; then
if [ -n "$MATCH_SET" ]; then
$NFT add rule inet ss_spec ss_spec_prerouting ip daddr 198.18.0.0/16 $MATCH_SET meta l4proto tcp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
$NFT add rule inet ss_spec ss_spec_prerouting ip daddr 198.18.0.0/16 $MATCH_SET meta l4proto udp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
else
$NFT add rule inet ss_spec ss_spec_prerouting ip daddr 198.18.0.0/16 meta l4proto tcp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
$NFT add rule inet ss_spec ss_spec_prerouting ip daddr 198.18.0.0/16 meta l4proto udp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
fi
else
for name in $Interface; do
local IFNAME=$(uci -P /var/state get network."$name".device 2>/dev/null)
[ -z "$IFNAME" ] && IFNAME=$(uci -P /var/state get network."$name".device 2>/dev/null)
if [ -n "$IFNAME" ]; then
if [ -n "$MATCH_SET" ]; then
$NFT add rule inet ss_spec ss_spec_prerouting meta iifname "$IFNAME" ip daddr 198.18.0.0/16 $MATCH_SET meta l4proto tcp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
$NFT add rule inet ss_spec ss_spec_prerouting meta iifname "$IFNAME" ip daddr 198.18.0.0/16 $MATCH_SET meta l4proto udp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
else
$NFT add rule inet ss_spec ss_spec_prerouting meta iifname "$IFNAME" ip daddr 198.18.0.0/16 meta l4proto tcp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
$NFT add rule inet ss_spec ss_spec_prerouting meta iifname "$IFNAME" ip daddr 198.18.0.0/16 meta l4proto udp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
fi
fi
done
fi
fi
# Exclude special local addresses
if $NFT list chain inet ss_spec ss_spec_prerouting >/dev/null 2>&1; then
for net in 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4; do
@@ -625,16 +708,6 @@ ac_rule_nft() {
# done
#fi
# Build a rule in the prerouting hook chain that jumps to business chain with conditions
if [ -n "$PROXY_PORTS" ]; then
PORTS_ARGS=$(echo "$PROXY_PORTS" | sed 's/-m multiport --dports //')
if [ -n "$PORTS_ARGS" ]; then
TCP_EXT_ARGS="meta l4proto tcp tcp dport { $PORTS_ARGS }"
fi
else
TCP_EXT_ARGS="meta l4proto tcp"
fi
# Block UDP port 443 when TPROXY not Enable
if [ -z "$TPROXY" ] && [ "$DISABLE_UDP_RULES" != "1" ]; then
# Add UDP 443 block rule
@@ -658,6 +731,17 @@ ac_rule_nft() {
done
fi
fi
# Build a rule in the prerouting hook chain that jumps to business chain with conditions
if [ -n "$PROXY_PORTS" ]; then
PORTS_ARGS=$(echo "$PROXY_PORTS" | sed 's/-m multiport --dports //')
if [ -n "$PORTS_ARGS" ]; then
TCP_EXT_ARGS="meta l4proto tcp tcp dport { $PORTS_ARGS }"
fi
else
TCP_EXT_ARGS="meta l4proto tcp"
fi
if [ -z "$Interface" ]; then
# generic prerouting jump already exists (see ipset_nft), but if we have MATCH_SET_CONDITION we add a more specific rule
if [ -n "$MATCH_SET" ]; then
@@ -689,6 +773,11 @@ ac_rule_nft() {
$NFT flush chain inet ss_spec ss_spec_output 2>/dev/null
$NFT add rule inet ss_spec ss_spec_output meta mark 255 return 2>/dev/null
if [ "$ENABLE_FAKE_IP" = "1" ]; then
$NFT add rule inet ss_spec ss_spec_output ip daddr 198.18.0.0/16 meta l4proto tcp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
$NFT add rule inet ss_spec ss_spec_output ip daddr 198.18.0.0/16 meta l4proto udp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
fi
# Exclude special local addresses
if $NFT list chain inet ss_spec ss_spec_output >/dev/null 2>&1; then
for net in 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4; do
@@ -714,6 +803,11 @@ ac_rule_nft() {
$NFT flush chain inet ss_spec ss_spec_output 2>/dev/null
$NFT add rule inet ss_spec ss_spec_output meta mark 255 return 2>/dev/null
if [ "$ENABLE_FAKE_IP" = "1" ]; then
$NFT add rule inet ss_spec ss_spec_output ip daddr 198.18.0.0/16 meta l4proto tcp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
$NFT add rule inet ss_spec ss_spec_output ip daddr 198.18.0.0/16 meta l4proto udp redirect to :$local_port comment "\"$TAG\"" 2>/dev/null
fi
# Exclude special local addresses
if $NFT list chain inet ss_spec ss_spec_output >/dev/null 2>&1; then
for net in 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4; do
@@ -754,11 +848,38 @@ ac_rule_iptables() {
return 2
;;
esac
local ip_list="${LAN_AC_IP#?}"
ipset -! create ss_spec_lan_ac hash:net 2>/dev/null
ipset flush ss_spec_lan_ac 2>/dev/null
if [ -n "$ip_list" ]; then
for ip in $ip_list; do
ipset add ss_spec_lan_ac "$ip" 2>/dev/null
done
fi
fi
# 2. PREROUTING 链处理
if [ -z "$Interface" ]; then
# Global rules
if [ -n "$MATCH_SET" ]; then
$IPT -I PREROUTING 1 -p tcp $EXT_ARGS $MATCH_SET -m comment --comment "$TAG" -j SS_SPEC_WAN_AC
else
$IPT -I PREROUTING 1 -p tcp $EXT_ARGS -m comment --comment "$TAG" -j SS_SPEC_WAN_AC
fi
else
for name in $Interface; do
local IFNAME=$(uci -P /var/state get network."$name".device 2>/dev/null)
[ -z "$IFNAME" ] && IFNAME=$(uci -P /var/state get network."$name".device 2>/dev/null)
if [ -n "$IFNAME" ]; then
if [ -n "$MATCH_SET" ]; then
$IPT -I PREROUTING 1 ${IFNAME:+-i $IFNAME} -p tcp $EXT_ARGS $MATCH_SET -m comment --comment "$TAG" -j SS_SPEC_WAN_AC
else
$IPT -I PREROUTING 1 ${IFNAME:+-i $IFNAME} -p tcp $EXT_ARGS -m comment --comment "$TAG" -j SS_SPEC_WAN_AC
fi
fi
done
fi
ipset -! -R <<-EOF || return 1
create ss_spec_lan_ac hash:net
$(for ip in ${LAN_AC_IP#?}; do echo "add ss_spec_lan_ac $ip"; done)
EOF
# Block UDP port 443 when TPROXY not Enable
if [ -z "$TPROXY" ] && [ "$DISABLE_UDP_RULES" != "1" ]; then
@@ -784,12 +905,16 @@ ac_rule_iptables() {
done
fi
fi
# 拦截 Fake-IP 流量 (198.18.0.0/16),受 Interface 和 LAN_AC 控制
if [ "$ENABLE_FAKE_IP" = "1" ]; then
if [ -z "$Interface" ]; then
# Global rules
if [ -n "$MATCH_SET" ]; then
$IPT -I PREROUTING 1 -p tcp $EXT_ARGS $MATCH_SET -m comment --comment "$TAG" -j SS_SPEC_WAN_AC
$IPT -I PREROUTING 1 -p tcp -d 198.18.0.0/16 $MATCH_SET -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
$IPT -I PREROUTING 2 -p udp -d 198.18.0.0/16 $MATCH_SET -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
else
$IPT -I PREROUTING 1 -p tcp $EXT_ARGS -m comment --comment "$TAG" -j SS_SPEC_WAN_AC
$IPT -I PREROUTING 1 -p tcp -d 198.18.0.0/16 -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
$IPT -I PREROUTING 2 -p udp -d 198.18.0.0/16 -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
fi
else
for name in $Interface; do
@@ -797,17 +922,29 @@ ac_rule_iptables() {
[ -z "$IFNAME" ] && IFNAME=$(uci -P /var/state get network."$name".device 2>/dev/null)
if [ -n "$IFNAME" ]; then
if [ -n "$MATCH_SET" ]; then
$IPT -I PREROUTING 1 ${IFNAME:+-i $IFNAME} -p tcp $EXT_ARGS $MATCH_SET -m comment --comment "$TAG" -j SS_SPEC_WAN_AC
$IPT -I PREROUTING 1 ${IFNAME:+-i $IFNAME} -p tcp -d 198.18.0.0/16 $MATCH_SET -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
$IPT -I PREROUTING 2 ${IFNAME:+-i $IFNAME} -p udp -d 198.18.0.0/16 $MATCH_SET -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
else
$IPT -I PREROUTING 1 ${IFNAME:+-i $IFNAME} -p tcp $EXT_ARGS -m comment --comment "$TAG" -j SS_SPEC_WAN_AC
$IPT -I PREROUTING 1 ${IFNAME:+-i $IFNAME} -p tcp -d 198.18.0.0/16 -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
$IPT -I PREROUTING 2 ${IFNAME:+-i $IFNAME} -p udp -d 198.18.0.0/16 -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
fi
fi
done
fi
fi
case "$OUTPUT" in
1)
$IPT -I OUTPUT 1 -p tcp $EXT_ARGS -m comment --comment "$TAG" -j SS_SPEC_WAN_AC
# fake-ip first
if [ "$ENABLE_FAKE_IP" = "1" ]; then
$IPT -I OUTPUT 1 -m mark --mark 255 -j RETURN 2>/dev/null
$IPT -I OUTPUT 2 -p tcp -d 198.18.0.0/16 -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
$IPT -I OUTPUT 3 -p udp -d 198.18.0.0/16 -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
$IPT -I OUTPUT 4 -p tcp $EXT_ARGS -m comment --comment "$TAG" -j SS_SPEC_WAN_AC
else
$IPT -I OUTPUT 1 -m mark --mark 255 -j RETURN 2>/dev/null
$IPT -I OUTPUT 2 -p tcp $EXT_ARGS -m comment --comment "$TAG" -j SS_SPEC_WAN_AC
fi
;;
2)
ipset -! -R <<-EOF || return 1
@@ -816,10 +953,19 @@ ac_rule_iptables() {
EOF
$IPT -N SS_SPEC_ROUTER 2>/dev/null
$IPT -F SS_SPEC_ROUTER 2>/dev/null
$IPT -A SS_SPEC_ROUTER -m mark --mark 255 -j RETURN && \
$IPT -A SS_SPEC_ROUTER -m set --match-set ssr_gen_router dst -j RETURN && \
# $IPT -A SS_SPEC_ROUTER -m mark --mark 255 -j RETURN
$IPT -A SS_SPEC_ROUTER -m set --match-set ssr_gen_router dst -j RETURN
$IPT -A SS_SPEC_ROUTER -j SS_SPEC_WAN_FW
$IPT -I OUTPUT 1 -p tcp -m comment --comment "$TAG" -j SS_SPEC_ROUTER
# fake-ip first
if [ "$ENABLE_FAKE_IP" = "1" ]; then
$IPT -I OUTPUT 1 -m mark --mark 255 -j RETURN 2>/dev/null
$IPT -I OUTPUT 2 -p tcp -d 198.18.0.0/16 -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
$IPT -I OUTPUT 3 -p udp -d 198.18.0.0/16 -j REDIRECT --to-ports "$local_port" -m comment --comment "$TAG"
$IPT -I OUTPUT 4 -p tcp -m comment --comment "$TAG" -j SS_SPEC_ROUTER
else
$IPT -I OUTPUT 1 -m mark --mark 255 -j RETURN 2>/dev/null
$IPT -I OUTPUT 2 -p tcp -m comment --comment "$TAG" -j SS_SPEC_ROUTER
fi
;;
esac
return $?
@@ -855,29 +1001,29 @@ tp_rule_nft() {
fi
local MATCH_SET=""
local EXT_ARGS=""
if [ -n "$PROXY_PORTS" ]; then
PORTS_ARGS=$(echo "$PROXY_PORTS" | sed 's/-m multiport --dports //')
if [ -n "$PORTS_ARGS" ]; then
EXT_ARGS="udp dport { $PORTS_ARGS }"
else
EXT_ARGS=""
fi
local PORTS_ARGS=$(echo "$PROXY_PORTS" | sed 's/-m multiport --dports //')
[ -n "$PORTS_ARGS" ] && EXT_ARGS="udp dport { $PORTS_ARGS }"
fi
if [ -n "$LAN_AC_IP" ]; then
# Create LAN access control set if needed
local mode="${LAN_AC_IP:0:1}"
local ips="${LAN_AC_IP:1}"
if ! $NFT list set ip ss_spec_mangle ss_spec_lan_ac >/dev/null 2>&1; then
$NFT add set ip ss_spec_mangle ss_spec_lan_ac '{ type ipv4_addr; flags interval; auto-merge; }' 2>/dev/null
else
$NFT flush set ip ss_spec_mangle ss_spec_lan_ac 2>/dev/null
fi
for ip in ${LAN_AC_IP#?}; do
for ip in $ips; do
[ -n "$ip" ] && $NFT add element ip ss_spec_mangle ss_spec_lan_ac "{ $ip }" 2>/dev/null
done
case "${LAN_AC_IP%${LAN_AC_IP#?}}" in
case "$mode" in
w | W)
MATCH_SET="ip saddr @ss_spec_lan_ac"
;;
@@ -906,8 +1052,10 @@ tp_rule_nft() {
fi
# Bulk import xhttp ip list into nft whitelist (server + shunt)
if [ -f "${xhttp_ip:=/etc/ssrplus/xhttp_address.txt}" ]; then
$NFT add element ip ss_spec_mangle whitelist "{ $(tr '\n' ',' < "${xhttp_ip}" | sed 's/,$//') }" 2>/dev/null
local xhttp_file="${xhttp_ip:=/etc/ssrplus/xhttp_address.txt}"
if [ -s "$xhttp_file" ]; then
local xhttp_content=$(tr '\n' ',' < "$xhttp_file" | sed 's/,$//')
[ -n "$xhttp_content" ] && $NFT add element ip ss_spec_mangle whitelist "{ $xhttp_content }" 2>/dev/null
fi
# use priority mangle for compatibility with other rules
@@ -917,6 +1065,12 @@ tp_rule_nft() {
$NFT flush chain ip ss_spec_mangle ss_spec_tproxy 2>/dev/null
fi
# 优先对 Fake-IP 进行 TPROXY 重定向 (无需受限于 PROXY_PORTS 限制)
if [ "$ENABLE_FAKE_IP" = "1" ]; then
$NFT add rule ip ss_spec_mangle ss_spec_tproxy ip daddr 198.18.0.0/16 counter tproxy ip to :"$LOCAL_PORT" meta mark set ${FWMARK} 2>/dev/null
fi
# Exclude special local addresses
if $NFT list chain ip ss_spec_mangle ss_spec_tproxy >/dev/null 2>&1; then
for net in 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4; do
$NFT add rule ip ss_spec_mangle ss_spec_tproxy ip daddr $net return 2>/dev/null
@@ -934,9 +1088,9 @@ tp_rule_nft() {
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp dport 53 return 2>/dev/null
# avoid redirecting to udp server address
if [ -n "$server" ]; then
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp dport != 53 ip daddr "$server" return 2>/dev/null
fi
# if [ -n "$server" ]; then
# $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp dport != 53 ip daddr "$server" return 2>/dev/null
# fi
# if server != SERVER add SERVER to whitelist set (so tproxy won't touch it)
if [ -n "$server" ]; then
@@ -946,6 +1100,9 @@ tp_rule_nft() {
$NFT add element ip ss_spec_mangle whitelist "{ $SERVER }" 2>/dev/null
fi
# 显式豁免白名单集合中的 IP
$NFT add rule ip ss_spec_mangle ss_spec_tproxy ip daddr @whitelist return 2>/dev/null
# access control and tproxy rules
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip saddr @bplan return 2>/dev/null
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip saddr @fplan counter tproxy ip to :"$LOCAL_PORT" meta mark set ${FWMARK} 2>/dev/null
@@ -965,7 +1122,9 @@ tp_rule_nft() {
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip daddr @ss_spec_wan_ac return 2>/dev/null
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip daddr @china return 2>/dev/null
# 80 端口规则:非国内 IP 的 80 端口,走 TPROXY
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp udp dport 80 counter drop comment "\"$TAG\"" 2>/dev/null
# 443 端口规则:非国内 IP 的 443 端口,走 TPROXY
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp udp dport 443 counter drop comment "\"$TAG\"" 2>/dev/null
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip saddr @gmlan ip daddr != @china counter tproxy ip to :"$LOCAL_PORT" meta mark set ${FWMARK} 2>/dev/null
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip daddr != @ss_spec_wan_ac counter tproxy ip to :"$LOCAL_PORT" meta mark set ${FWMARK} 2>/dev/null
@@ -975,7 +1134,9 @@ tp_rule_nft() {
$NFT add set ip ss_spec_mangle gfwlist '{ type ipv4_addr; flags interval; auto-merge; }' 2>/dev/null
fi
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip daddr @china return 2>/dev/null
# 80 端口规则:非国内 IP 的 80 端口,走 TPROXY
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp udp dport 80 counter drop comment "\"$TAG\"" 2>/dev/null
# 443 端口规则:非国内 IP 的 443 端口,走 TPROXY
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp udp dport 443 counter drop comment "\"$TAG\"" 2>/dev/null
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip daddr @gfwlist counter tproxy ip to :"$LOCAL_PORT" meta mark set ${FWMARK} 2>/dev/null
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip saddr @gmlan ip daddr != @china counter tproxy ip to :"$LOCAL_PORT" meta mark set ${FWMARK} 2>/dev/null
@@ -988,6 +1149,8 @@ tp_rule_nft() {
# finally, ensure prerouting hook entry to jump to tproxy chain
if ! $NFT list chain ip ss_spec_mangle prerouting >/dev/null 2>&1; then
$NFT add chain ip ss_spec_mangle prerouting '{ type filter hook prerouting priority mangle; policy accept; }'
else
$NFT flush chain ip ss_spec_mangle prerouting 2>/dev/null
fi
# add prerouting jump (idempotent)
@@ -1056,8 +1219,14 @@ tp_rule_iptables() {
do
$ipt -A SS_SPEC_TPROXY -p udp -d "$net" -j RETURN
done
# fake-ip 规则
if [ "$ENABLE_FAKE_IP" = "1" ]; then
$ipt -A SS_SPEC_TPROXY -p udp $MATCH_SET -d 198.18.0.0/16 -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
fi
[ -n "$SERVER" ] && $ipt -A SS_SPEC_TPROXY -p udp ! --dport 53 -d "$SERVER" -j RETURN
[ -n "$SERVER" ] && [ "$server" != "$SERVER" ] && ipset -! add whitelist "$SERVER"
$ipt -A SS_SPEC_TPROXY -p udp -m set --match-set whitelist dst -j RETURN
if [ -f "${xhttp_ip:=/etc/ssrplus/xhttp_address.txt}" ]; then
while IFS= read -r ip; do
[ -n "$ip" ] && ipset add whitelist "$ip" -exist
@@ -1065,6 +1234,7 @@ tp_rule_iptables() {
fi
$ipt -A SS_SPEC_TPROXY -p udp -m set --match-set bplan src -j RETURN
$ipt -A SS_SPEC_TPROXY -p udp $PROXY_PORTS -m set --match-set fplan src -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
$ipt -A SS_SPEC_TPROXY -p udp -m set --match-set blacklist dst -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
case "$RUNMODE" in
router)
ipset -! -R <<-EOF || return 1
@@ -1073,15 +1243,27 @@ tp_rule_iptables() {
EOF
$ipt -A SS_SPEC_TPROXY -p udp -m set --match-set ss_spec_wan_ac dst -j RETURN
$ipt -A SS_SPEC_TPROXY -p udp -m set --match-set china dst -j RETURN
# 80 端口规则:非国内 IP 的 80 端口,走 TPROXY
$ipt -A SS_SPEC_TPROXY -p udp --dport 80 -j DROP
# 443 端口规则:非国内 IP 的 443 端口,走 TPROXY
$ipt -A SS_SPEC_TPROXY -p udp --dport 443 -j DROP
# 保留海外 QUIC 代理
# $ipt -A SS_SPEC_TPROXY -p udp --dport 80 -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
# $ipt -A SS_SPEC_TPROXY -p udp --dport 443 -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
# $ipt -A SS_SPEC_TPROXY -p udp -m set ! --match-set china dst --dport 80 -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
# $ipt -A SS_SPEC_TPROXY -p udp -m set ! --match-set china dst --dport 443 -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
$ipt -A SS_SPEC_TPROXY -p udp -m set --match-set gmlan src -m set ! --match-set china dst -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
$ipt -A SS_SPEC_TPROXY -p udp $PROXY_PORTS -m set ! --match-set ss_spec_wan_ac dst -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
;;
gfw)
$ipt -A SS_SPEC_TPROXY -p udp -m set --match-set china dst -j RETURN
# 80 端口规则:非国内 IP 的 80 端口,走 TPROXY
$ipt -A SS_SPEC_TPROXY -p udp --dport 80 -j DROP
# 443 端口规则:非国内 IP 的 443 端口,走 TPROXY
$ipt -A SS_SPEC_TPROXY -p udp --dport 443 -j DROP
# 保留海外 QUIC 代理
# $ipt -A SS_SPEC_TPROXY -p udp --dport 80 -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
# $ipt -A SS_SPEC_TPROXY -p udp --dport 443 -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
$ipt -A SS_SPEC_TPROXY -p udp $PROXY_PORTS -m set --match-set gfwlist dst -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
$ipt -A SS_SPEC_TPROXY -p udp -m set --match-set gmlan src -m set ! --match-set china dst -j TPROXY --on-port "$LOCAL_PORT" --tproxy-mark ${FWMARK}
;;
@@ -1463,7 +1645,7 @@ restore_from_persistence() {
fi
}
while getopts ":m:s:l:S:L:i:e:a:B:b:w:p:G:D:F:N:M:I:oOuUyfgrzAKPCRXh" arg; do
while getopts ":m:s:l:S:L:i:e:E:a:B:b:w:p:G:D:F:N:M:I:oOuUyfgrzAKPCRXh" arg; do
case "$arg" in
m)
Interface=$OPTARG
@@ -1486,6 +1668,9 @@ while getopts ":m:s:l:S:L:i:e:a:B:b:w:p:G:D:F:N:M:I:oOuUyfgrzAKPCRXh" arg; do
e)
EXT_ARGS=$OPTARG
;;
E)
ENABLE_FAKE_IP=$OPTARG
;;
a)
LAN_AC_IP=$OPTARG
;;
@@ -1689,6 +1874,14 @@ if [ -n "$local_port" ]; then
if [ "$USE_NFT" = "1" ]; then
# NFTables
# Detect Fake_IP Mode state change (execute first)
ENABLE_FAKE_IP_STATE_FILE="/tmp/.last_enable_fake_ip"
if [ -f "$ENABLE_FAKE_IP_STATE_FILE" ]; then
LAST_ENABLE_FAKE_IP=$(cat "$ENABLE_FAKE_IP_STATE_FILE")
else
LAST_ENABLE_FAKE_IP=""
fi
# Save previous TPROXY state file
TPROXY_STATE_FILE="/tmp/.last_tproxy"
if [ -f "$TPROXY_STATE_FILE" ]; then
@@ -1778,6 +1971,13 @@ if [ -n "$local_port" ]; then
LAST_XHTTP_FILE_HASH=""
fi
# Check if Fake_IP Mode state has changed
ENABLE_FAKE_IP_CHANGED=0
if [ "$LAST_ENABLE_FAKE_IP" != "$ENABLE_FAKE_IP" ]; then
loger 6 "Fake_IP Mode state changed: $LAST_ENABLE_FAKE_IP -> $ENABLE_FAKE_IP"
ENABLE_FAKE_IP_CHANGED=1
fi
# STEP 1: Check if TPROXY has value (1 or 2)
if [ "$TPROXY" = "1" ] || [ "$TPROXY" = "2" ]; then
TPROXY_HAS_VALUE=1
@@ -1873,7 +2073,9 @@ if [ -n "$local_port" ]; then
# 1. TPROXY changes from empty ↔ has value
# 2. PROXY_PORTS changes from empty ↔ has value
# 3. Any IP list has changed
if [ "$TPROXY_HAS_VALUE" != "$LAST_HAS_VALUE" ] || \
# 4. ENABLE_FAKE_IP state changed
if [ "$ENABLE_FAKE_IP_CHANGED" = "1" ] || \
[ "$TPROXY_HAS_VALUE" != "$LAST_HAS_VALUE" ] || \
[ "$PROXY_HAS_VALUE" != "$LAST_PROXY_HAS_VALUE" ] || \
[ "$ANY_IP_LIST_CHANGED" = "1" ]; then
FORCE_RECREATE=1
@@ -1891,6 +2093,7 @@ if [ -n "$local_port" ]; then
fi
# STEP 4: Save current state
echo "$ENABLE_FAKE_IP" > "$ENABLE_FAKE_IP_STATE_FILE"
echo "$TPROXY" > "$TPROXY_STATE_FILE"
echo "$PROXY_PORTS" > "$PROXY_PORTS_STATE_FILE"
echo "$(normalize_ip_list "$WAN_BP_IP")" > "$WAN_BP_IP_STATE_FILE"
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@
require "luci.sys"
local ucursor = require "luci.model.uci".cursor()
local json = require "luci.jsonc"
local datatypes = require "luci.cbi.datatypes"
-- An omitted value reaches us either as a missing argument or as an empty
-- string, depending on whether the caller quoted the expansion. Empty strings
@@ -181,6 +182,62 @@ local function format_host_port(host, port)
return host .. ":" .. tostring(port)
end
local function format_dns_server(proxy)
if not proxy or proxy == "" then
return proxy
end
local scheme, rest = proxy:match("^([a-zA-Z0-9%+%-%.]+)://(.+)$")
if scheme then
scheme = scheme:lower()
if scheme == "tls" or scheme == "dot" then
if not rest:match(":%d+$") then
rest = rest .. ":853"
end
return "tls://" .. rest
elseif scheme == "https" or scheme == "doh" or scheme == "dohl" or scheme == "doq" or scheme == "doql" then
local host, path = rest:match("^([^/]+)(.*)$")
host = host or rest
path = path or ""
if path == "" or path == "/" then
path = "/dns-query"
end
return "https://" .. host .. path
elseif scheme == "tcp" or scheme == "udp" then
if not rest:match(":%d+$") then
rest = rest .. ":53"
end
return scheme .. "://" .. rest
else
return proxy
end
end
local host, port = proxy:match("^([^:]+):(%d+)$")
if host and port then
if port == "853" then
return "tls://" .. host .. ":853"
elseif port == "443" then
return "https://" .. host .. "/dns-query"
elseif port == "53" then
return host -- 53 端口输出纯 IP / Host (如 8.8.4.4)
else
return "udp://" .. host .. ":" .. port
end
end
-- 确保包含字母,防止把 IP 当成域名
if proxy:match("[a-zA-Z]") and not proxy:match("[^%w%.%-]") then
local path = ""
if not proxy:match("/") then
path = "/dns-query"
end
return "https://" .. proxy .. path
end
return proxy
end
-- 确保正确判断程序是否存在
local function is_finded(e)
return luci.sys.exec(string.format('type -t -p "%s" -p "/usr/libexec/%s" 2>/dev/null', e, e)) ~= ""
@@ -391,31 +448,141 @@ local Xray = {
}
if server.type == "v2ray" and dns_mode == "7" and os.getenv("SSR_SWITCH_PROBE") ~= "1" then
local dns_host = builtin_dns_server:match("^([^:]+)") or "8.8.4.4"
local dns_port = tonumber(builtin_dns_server:match(":(%d+)$")) or 53
local raw_servers_str = builtin_dns_server or "8.8.4.4:53"
local dns_servers = {}
local host_list = {}
local port_list = {}
local first_host = nil
local first_port = nil
-- 按逗号或空格分割多个 DNS 地址
for raw_server in string.gmatch(raw_servers_str, "[^,%s\r\n]+") do
if raw_server and raw_server ~= "" then
-- 1. 先进行格式化处理
local formatted = format_dns_server(raw_server)
local host = nil
local port = nil
-- 2. 解析 host 和对应的实际端口
if formatted:match("^https://") then
local target = formatted:match("^https://([^/]+)")
if target:match(":%d+$") then
host, port = target:match("^([^:]+):(%d+)$")
port = tonumber(port)
else
host = target
port = 443
end
elseif formatted:match("^tls://") then
local target = formatted:match("^tls://([^/]+)")
if target:match(":%d+$") then
host, port = target:match("^([^:]+):(%d+)$")
port = tonumber(port)
else
host = target
port = 853
end
elseif formatted:match("^tcp://") or formatted:match("^udp://") then
local target = formatted:match("^[a-zA-Z0-9%+%-%.]+://([^/]+)")
if target:match(":%d+$") then
host, port = target:match("^([^:]+):(%d+)$")
port = tonumber(port)
else
host = target
port = 53
end
else
if formatted:match(":%d+$") then
host, port = formatted:match("^([^:]+):(%d+)$")
port = tonumber(port)
else
host = formatted
port = 53
end
end
-- 保存格式化后的 Xray.dns.servers 条目
table.insert(dns_servers, formatted)
-- 分别收集 Host 与 Port
if host and port then
table.insert(host_list, host)
table.insert(port_list, port)
-- 保存第一个 DNS 的 host 和 port
-- if not first_host then --dokodemo-door 入站的 address 单个地址使用。
-- first_host = host --dokodemo-door 入站的 address 单个地址使用。
-- first_port = port --dokodemo-door 入站的 address 单个地址使用。
-- end
end
end
end
-- 如果没有有效的 DNS 服务器,使用默认值
if #dns_servers == 0 then
table.insert(dns_servers, "8.8.4.4")
table.insert(host_list, "8.8.4.4")
table.insert(port_list, 53)
-- first_host = "8.8.4.4" --dokodemo-door 入站的 address 单个地址使用。
-- first_port = 53 --dokodemo-door 入站的 address 单个地址使用。
end
-- 构建 dns 出站地址(支持逗号分隔的多个地址)
-- 检查所有 DNS 端口是否一致
--local outbound_address = "" --dokodemo-door 入站的 address 单个地址使用。
local all_same_port = true
local common_port = port_list[1]
for i = 2, #port_list do
if port_list[i] ~= common_port then
all_same_port = false
break
end
end
-- 根据端口一致性计算 address 与 target_port
local address_str = ""
local target_port = 0
if all_same_port then
-- outbound_address = table.concat(host_list, ",") --dokodemo-door 入站的 address 单个地址时使用。
address_str = table.concat(host_list, ",")
target_port = common_port
else
local combined = {}
for i = 1, #host_list do
table.insert(combined, string.format("%s:%d", host_list[i], port_list[i]))
end
-- outbound_address = table.concat(combined, ",") --dokodemo-door 入站的 address 单个地址时使用。
address_str = table.concat(combined, ",")
target_port = 0
end
-- 构建 Xray.dns 配置
Xray.dns = {
queryStrategy = (dns_ipv4_only == "1") and "UseIPv4" or "UseIP",
servers = {
string.format("tcp://%s:%d", dns_host, dns_port)
}
servers = dns_servers
}
-- 构建 dokodemo-door 入站
table.insert(Xray.inbounds, {
listen = "127.0.0.1",
port = 5335,
protocol = "dokodemo-door",
settings = {
address = dns_host,
port = dns_port,
address = address_str,
-- address = first_host, -- 单个地址
port = target_port,
-- port = first_port, -- 对应端口
network = "tcp,udp"
},
tag = "builtin-dns-in"
})
xray_builtin_dns = {
address = dns_host,
port = dns_port
address = address_str,
-- address = outbound_address, --dokodemo-door 入站的 address 单个地址时使用。
port = target_port
}
end
-- 传入连接
@@ -27,6 +27,9 @@ config server_subscribe
option auto_update_week_time '*'
option auto_update_day_time '2'
option auto_update_min_time '0'
option enable_mihomo '1'
option enable_fake_ip '1'
option sub_convert '1'
option url_test_url 'https://www.google.com/generate_204'
option user_agent 'v2rayN/9.99'
option filter_words '过期/重置/套餐/剩余/网址/QQ群/官网/防失联/回国'
+5 -5
View File
@@ -17,12 +17,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-wwand
PKG_VERSION:=1.6.6_p3
PKG_RELEASE:=18
PKG_VERSION:=1.6.6_p7
PKG_RELEASE:=19
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git
PKG_SOURCE_VERSION:=4676454a1a8356de565fc02e44f66a6813c66b09
PKG_SOURCE_VERSION:=ef4b59a891229d189ad5167def96eab22a5ceb03
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only
@@ -80,8 +80,8 @@ endef
# luci-app-statistics. A new resource has to be added here, and that is the
# point of listing them.
LUCI_WWAND_VIEW:=modems.js settings.js status.js
LUCI_WWAND_RES:=bands.js esim.js format.js graph.js mccmnc.js modemopts.js \
modemsid.js netsel.js rpc.js signal.svg simlist.js
LUCI_WWAND_RES:=bands.js esim.js format.js graph.js logbox.js mccmnc.js \
modemopts.js modemsid.js netsel.js rpc.js signal.svg simlist.js
define Package/luci-app-wwand/install
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/wwand
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-xray-status
PKG_VERSION:=3.7.2
PKG_RELEASE:=2
PKG_RELEASE:=3
PKG_LICENSE:=MPLv2
PKG_LICENSE_FILES:=LICENSE
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-xray
PKG_VERSION:=3.7.2
PKG_RELEASE:=7
PKG_RELEASE:=8
PKG_LICENSE:=MPLv2
PKG_LICENSE_FILES:=LICENSE
@@ -205,12 +205,14 @@ function rules(proxy, bridge, manual_tproxy, extra_inbound, fakedns) {
domain: blocked_domain_rules(proxy),
});
}
if (length(fast_domain_rules(proxy)) > 0) {
splice(result, 0, 0, {
type: "field",
inboundTag: [...tproxy_tcp_inbound_v4_tags, ...tproxy_udp_inbound_v4_tags, ...tproxy_tcp_inbound_v6_tags, ...tproxy_udp_inbound_v6_tags, ...extra_inbound_global_tcp_tags, ...extra_inbound_global_udp_tags],
outboundTag: "direct",
domain: fast_domain_rules(proxy)
});
}
if (proxy["direct_bittorrent"] == "1") {
splice(result, 0, 0, {
type: "field",
+1 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=69
PKG_RELEASE:=71
endif
LUCI_TITLE:=Footstrap Theme
@@ -879,22 +879,28 @@ function anchorEnabled() {
* reports zero and this does nothing. Same guards as the main correction not while the reader
* scrolls, not across a navigation, never more than a viewport. */
let _lateFrame = 0;
/* WHY THE LAST LATE CORRECTION DID OR DID NOT WRITE an unmarked export for the sweep, the same
* shape and the same reason as `restAt()` and `engineTrusted()` beside it. `lateDrift()` has eight
* ways to return without writing, and from outside they are one symptom: `writes: []`. Three CI
* runs were spent guessing between them whether the theme tried and missed, had no reference to
* try from, or read the engine as having already done the job and each guess cost a push. One
* short string, set at every exit, ends that: the finding names the line instead of the silence. */
let _lateWhy = null;
function why(w) { _lateWhy = w; }
function lateDrift(ref, grow, floorShrink) {
/* the reference from BEFORE this tick, captured by the caller: one taken after the mutation
* describes the page as the mutation left it, so its drift is zero by construction */
if (_lateFrame || !ref) return;
if (_lateFrame) return why('busy');
if (!ref) return why('no-reference');
why('armed');
_lateFrame = requestAnimationFrame(() => {
const seen = scrollTop();
/* STILL FOR SCROLL_IDLE, the interval this file already calls a page nobody is scrolling.
* A frame is not long enough to tell a flick from a still page: a flick moves the offset in
* steps of tens of milliseconds and two rAFs (~16 ms) fall inside one step, so the offset
* reads the same twice while the page is plainly moving. 120 ms was still short enough to
* let one 160px correction through on a loaded runner. */
_lateFrame = window.setTimeout(() => {
const settle = () => {
_lateFrame = 0;
if (!anchorEnabled() || Date.now() < _userUntil) return;
if (_restPage !== pageStamp()) return;
if (!anchorEnabled()) return why('anchoring-off');
if (Date.now() < _userUntil) return why('reader-intent');
if (_restPage !== pageStamp()) return why('page-changed');
/* THE OFFSET, NOT THE EVENT STREAM. `scrolling()` cannot answer this one: the engine's
* own compensation moves the offset and starts the motion sampler, so gating on it
* skips every tick this exists for and in WebKit a programmatic scroll's event
@@ -904,6 +910,11 @@ function lateDrift(ref, grow, floorShrink) {
* moved since, and whatever this would put back they have already scrolled past. A
* correction landing inside a flick is itself a jump (161px, webkit/Overview).
*
* The wait-length fork at the bottom of this function does consult `scrolling()`, and
* it is not this rule loosened: it decides HOW LONG to wait, never WHETHER to write. A
* tick the engine did move the offset for takes the long road and still arrives here,
* where this same check is what answers.
*
* `ref.at` and not `_restAt`: run() re-remembers between the mutation and this frame,
* and where the sampler has not started yet WebKit again that re-take records the
* offset the reader has already flicked to, so comparing against it compares a value
@@ -913,12 +924,12 @@ function lateDrift(ref, grow, floorShrink) {
* against 600px of growth so an offset that merely differs is the engine working, and
* refusing on that leaves the engine's own residual (58px) uncorrected. What must not be
* touched is a page still in motion, which is asked directly instead. */
if (scrollTop() !== seen) return;
if (scrollTop() !== seen) return why('offset-moved');
/* the tick usually replaces the element this was taken on, so without the section
* fallback the correction does nothing on the tick it exists for */
let el = ref.el, was = ref.top;
if (!el || !el.isConnected) {
if (!ref.sec || !ref.sec.isConnected || ref.secTop == null) return;
if (!ref.sec || !ref.sec.isConnected || ref.secTop == null) return why('reference-gone');
el = ref.sec; was = ref.secTop;
}
let drift = el.getBoundingClientRect().top - was;
@@ -984,7 +995,7 @@ function lateDrift(ref, grow, floorShrink) {
* REPEAT's own mark never moved (misses [true,true,false]) — e0b6db4's fault on the
* other side of the same comparison. */
rememberRest(true);
return;
return why('engine-partly-' + Math.round(compensated) + '-of-' + Math.round(grow));
}
/* else: within table-row rounding drift is still whatever it was (< 1 per the guard
* above), so the plain `drift < 1` return two lines down is what fires, unwritten and
@@ -1007,12 +1018,13 @@ function lateDrift(ref, grow, floorShrink) {
* second of three back-to-back refills, unchanged chromium/firefox/webkit alike,
* `_engineTrusted` true throughout. */
if (grow > 1 || floorShrink > 1) rememberRest(true);
return; /* the engine put it back */
return why('no-drift-grow-' + Math.round(grow)); /* the engine put it back */
}
if (Math.abs(drift) > (window.innerHeight || 800)) return;
if (Math.abs(drift) > (window.innerHeight || 800)) return why('drift-too-big');
const sc = scroller();
const at = sc ? sc.scrollTop : window.scrollY;
writeOffset(sc, at + drift);
why('wrote-' + Math.round(drift));
/* A FRESH, FORCED rememberRest(), not just `_restAt = scrollTop()` task refill2. The
* write moves the page by exactly the drift measured, so `_rest.top` still holds FOR THIS
* TICK's own `el`, but `_rest` itself was taken by run() at the top of THIS callback,
@@ -1030,7 +1042,7 @@ function lateDrift(ref, grow, floorShrink) {
* `force`, `moved 0, 0, 0` with it, both `trusted true` throughout). */
rememberRest(true);
/* A DROP IN THIS SAME BOX'S OWN FLOOR IS NOT EVIDENCE ABOUT THE ENGINE task refill2. Every
* `run()` clears and rewrites `r.target`'s `min-height`, and a WRITE to that property is a
* `run()` clears and rewrites the floored box's `min-height`, and a WRITE to that property is a
* scroll-anchor invalidation in its own right (`holdFloor()`'s own citation), independent of
* how reliably this engine otherwise keeps a reference: a floored box whose content really
* did shrink pays this cost on every engine, every time, structurally counting it toward
@@ -1056,7 +1068,50 @@ function lateDrift(ref, grow, floorShrink) {
/* This engine did not keep the reference across a container refill, once more on this
* page see LATE_MISS_LIMIT above for what happens once that has been measured twice. */
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
}, SCROLL_IDLE);
};
/* HOW LONG TO WAIT IS A QUESTION ABOUT THE READER, NOT A CONSTANT task late419.
*
* STILL FOR SCROLL_IDLE is the answer where the page is, or might be, in motion: a frame is
* not long enough to tell a flick from a still page by the offset alone, because a flick
* moves it in steps of tens of milliseconds and two rAFs (~16ms) fall inside one step, so
* the offset reads the same twice while the page is plainly moving. 120ms was still short
* enough to let one 160px correction through on a loaded runner. That is unchanged, and it
* is what every branch below still runs on.
*
* But a page the theme ALREADY KNOWS is still does not need to be asked again. `scrolling()`
* is this file's own answer to "has anything moved the offset in the last SCROLL_IDLE",
* sampled from the POSITION every frame rather than from the event stream, so it sees
* momentum and rubber-banding that dispatch nothing (its own comment) and `_userUntil` is
* the reader's hand on the page: a `touchstart`, `wheel`, `mousedown` or `keydown` arrives
* BEFORE the offset it is about to move, so a flick about to begin has already said so.
* Where both answer "nobody is driving and nothing has moved", the only thing that can have
* touched the offset since the reference was taken is the engine, whose own correction
* window is measured at 7-36ms not 400 so the wait is the next frame and the same
* `scrollTop() !== seen` check decides, exactly as it does on the long path. Two rAFs in
* total, one apart: `settleDeferredFloor()`'s own shape, and for its stated reason the
* offset read twice a frame apart is the check, and the wait is only there to space the
* two reads. A third frame was measured and is not bought: it costs 11-16ms of the
* headroom under the gate's own `LATE_MS` and changes no reading webkit 67/71/72ms
* against 51/54/61ms on the same three stands, everything else identical.
*
* NEITHER GUARD ALONE WOULD DO, and that is the whole reason this is a pair. `_userUntil` is
* a 400ms timer off the last EVENT, and iOS momentum carries the page long after the finger
* has gone the same fault `scrolling()` exists because of. And `scrolling()` alone would
* write into the moment a finger is down on a page that has not moved yet. A synthetic flick
* makes the first half concrete: `tools/scroll-anchor.mjs`'s QUIET drives its 24 steps by
* assigning `scrollTop`, so it carries no intent event at all, and an intent-only gate would
* take the short path straight through the middle of it.
*
* Measured (`tools/scroll-anchor.mjs`, the `engine DECLINES` cell this task added the
* engine ablated off while the theme still trusts it, which is the state CI caught 2 runs in
* 3 and no local run ever): 404-422ms before, 8-61ms after, on nine cells three engines
* against three stands, `@1440 side compact overview`, the cell CI reported. The other two
* cells of the same axis are untouched, which is the point: the engine-anchoring-on cell
* corrects at 4-19ms (the engine's own work, which never reaches this path) and the
* engine-OFF cell at 6-44ms (`applyAnchor()`, a different function). `mid-flick surprises`
* reads 0 on all 27. */
if (!scrolling() && Date.now() >= _userUntil) _lateFrame = requestAnimationFrame(settle);
else _lateFrame = window.setTimeout(settle, SCROLL_IDLE);
});
}
@@ -1270,12 +1325,35 @@ function observeContent() {
* nothing on the distrusted path too, in the recovery check right below, so the read can no
* longer be skipped there. One extra `records.find` and an `offsetHeight` per distrusted
* tick the one path that used to pay nothing here at all. */
const r = records.find((m) => m.type === 'childList' && m.target.hasAttribute('data-fs-floor'));
/* THE FLOORED BOX THE RECORD SITS IN, not only a record whose target IS one task blindgrow.
* `dom.content()` empties and refills the node it is handed, and that node is often a level
* or two INSIDE the box `holdFloor()` pinned: measured live on Overview (webkit/owrtsnapb,
* `../tmp/floorprobe.mjs`), of twelve nodes a poll refills on that page one sits inside a
* floored box without wearing the mark itself. For those the old `m.target.hasAttribute`
* matched nothing, `grew` read 0, and a `lateDrift()` whose element-based `drift` was ALSO
* blind `anchorRef()` having hit-tested something the growth never reached concluded
* there was nothing to correct and wrote nothing at all. That is the `writes: []`,
* `corrected never` shape CI reported on `webkit owrtsnap @1440 side compact overview` in
* three runs of three while every local run of the same cell passed: locally the fold
* happened to land BELOW the growing block, so `drift` carried the correction on its own and
* the missing `grew` never showed.
*
* `closest()` and not a parent walk: it stops at the first floored ancestor, which is the box
* whose `min-height` holds the pre-tick height this measures against, and it costs one call
* on the handful of records a tick delivers. Strictly wider than what it replaces a target
* that already wore the mark is its own `closest()` so no tick that used to find a witness
* can stop finding one. */
let r = null, box = null;
for (const m of records) {
if (m.type !== 'childList' || !m.target.closest) continue;
const b = m.target.closest(FLOORED);
if (b) { r = m; box = b; break; }
}
/* a box freshly wearing its FIRST floor has no "before" to measure against parseFloat
* of an unset `min-height` is NaN, `|| 0` reads as "no growth" rather than false growth
* the size of the whole box */
const before = r && (parseFloat(r.target.style.minHeight) || 0);
const grew = before ? r.target.offsetHeight - before : 0;
const before = box && (parseFloat(box.style.minHeight) || 0);
const grew = before ? box.offsetHeight - before : 0;
/* RECOVERY EVIDENCE, task trust read here and nowhere else; see TRUST_RECOVERY_LIMIT's own
* comment for why `applyAnchor()` cannot see it. THE SAME REFERENCE `lateDrift()` trusts on
* the other path (`_rest.el`'s own rect against the top it was remembered at), read before
@@ -1299,7 +1377,7 @@ function observeContent() {
_lateMisses = _lateHits = 0;
}
/* THE SAME BOX'S FLOOR, BEFORE AND AFTER THIS run() task refill2. `holdFloor()` inside
* run() clears and rewrites `r.target`'s own `min-height` every tick, which is a scroll-
* run() clears and rewrites `box`'s own `min-height` every tick, which is a scroll-
* anchor-invalidating style write on its own account (css-scroll-anchoring-1 §2.2.2,
* `holdFloor()`'s own comment) — so a floored box whose CONTENT genuinely shrinks (a poll's
* data losing rows, or in this callback the mutation record ITSELF being a removal) drops the
@@ -1320,10 +1398,10 @@ function observeContent() {
* own `run()`, or another mutation's) leaves nothing stale behind cleared below the moment
* any tick's `holdFloor()` actually runs, for the identical reason. */
const wasScrolling = scrolling();
if (r && before && wasScrolling) _deferredFloor = r.target;
if (r && before && wasScrolling) _deferredFloor = box;
run(records);
if (!wasScrolling) _deferredFloor = null;
const floorShrink = (r && before) ? Math.max(0, before - (parseFloat(r.target.style.minHeight) || 0)) : 0;
const floorShrink = (r && before) ? Math.max(0, before - (parseFloat(box.style.minHeight) || 0)) : 0;
/* `#view` ITSELF EMPTIED AND REFILLED IS A PAGE SWAP, NOT A REFILL task latecommit,
* docs/anchoring.md "The commit is not a refill". The router commits a client navigation
* with `dom.content()` on the live `#view` (commitStage(), fs-router.js) and the browser
@@ -1340,14 +1418,30 @@ function observeContent() {
* normal with the engine ablated off. One pass and no `type` test, unlike the
* `records.find()` above: this observer registers `childList` only, and a record of any
* other type carries two empty node lists anyway. */
let gone = 0, came = 0;
for (const m of records)
let gone = 0, came = 0, took = 0, gave = 0;
for (const m of records) {
if (m.target === viewHost) { gone += m.removedNodes.length; came += m.addedNodes.length; }
if (m.type === 'childList') { took += m.removedNodes.length; gave += m.addedNodes.length; }
}
if (gone && came) {
forgetRest();
return;
}
if (trustEngine) lateDrift(settled, grew, floorShrink);
/* NOT ON A BATCH THAT ONLY TOOK NODES AWAY task twohalves. `dom.content()` is empty-then-fill,
* and where the two halves reach this observer as two batches, arming on the first aims the
* correction at a page that is about to stop existing: CI measured `wrote--834` for a refill
* that grew the page by 120, the reader thrown by the transient and nothing left for the real
* half. Superseding the armed call with the later batch was measured instead and is WORSE
* a real tick delivers around twenty records, each one cancelling the last, and the sweep went
* from clean to 12 findings of ordinary drift (docs/anchoring.md, "The later batch must NOT
* win"). This is the discriminator that survives both: a batch that removed nodes, added none
* and left the floored box no taller is a removal, and a removal is not a page to correct
* against. A synchronous `dom.content()` every real poll tick delivers its removals and
* its additions in ONE batch and is untouched. */
if (trustEngine) {
if (took && !gave && grew <= 0) why('emptying');
else lateDrift(settled, grew, floorShrink);
}
else scheduleAnchor(ref);
});
for (const host of hosts) {
@@ -1448,6 +1542,8 @@ return baseclass.extend({
/* "is the reader scrolling" and "I could not measure, wake me when they stop": a pass that reads
* layout asks the first and calls the second, one that only writes does neither */
scrolling,
/* unmarked, for tools/scroll-anchor.mjs — see `_lateWhy` */
lateWhy: () => _lateWhy,
deferMeasurement,
/* -> the offset this file last took a reference at, or null before it has taken one.
+12
View File
@@ -0,0 +1,12 @@
# Optional — run `pnpm setup:router` once and it writes/updates every value below.
# OpenWrt router address — bare IP/hostname; the web proxy target and the
# .ut-sync ssh target both derive from it (ssh key selection etc. belongs
# in ~/.ssh/config)
# VITE_OPENWRT_HOST=192.168.1.1
# Dev server bind / port
VITE_DEV_PORT=5173
# 0.0.0.0 = LAN; 127.0.0.1 = local only
VITE_DEV_HOST=0.0.0.0
+4
View File
@@ -0,0 +1,4 @@
# Fail `pnpm install` early with a clear message on unsupported Node versions
# (the range in package.json `engines` mirrors Vite 8's requirement) instead
# of letting the install die somewhere deep inside a dependency.
engine-strict=true
+46
View File
@@ -0,0 +1,46 @@
# Build & dev server
**Dual-layer build**: source in `.dev/` → OpenWrt-compatible output committed to `htdocs/luci-static/`.
| source | output |
| ----------------------------------- | -------------------------------------------------------------------------------------- |
| `.dev/src/media/main.css` | `htdocs/luci-static/shadcn/main.css` |
| `.dev/src/media/login.css` | `htdocs/luci-static/shadcn/login.css` |
| `.dev/src/resource/*.js` | `htdocs/luci-static/resources/*.js` (`menu-shadcn`, `sidebar-shadcn`, `router-shadcn`) |
| `.dev/src/media/patches/*` | `htdocs/luci-static/shadcn/patches/*` (see [patches.md](patches.md)) |
| `.dev/public/shadcn/` | `htdocs/luci-static/shadcn/` — icons/images copied as-is |
| `ucode/template/themes/shadcn/*.ut` | not processed by Vite; pushed to a device via the SSH dev plugin |
`htdocs/` is generated output checked into git. Rebuild it with `pnpm build`, or trigger the manual `frontend-assets-build.yml` workflow, which builds and commits `htdocs/**`.
## Terser options that are load-bearing
`.dev/src/resource/*.js` goes through terser (compress + local-scope mangle, no bundling); each file stays a standalone LuCI `L.require()`-able module. Two options must not be dropped:
- `compress.directives: false` — keeps the leading `'require <dep>'` strings that LuCI's dependency scanner reads;
- `mangle.toplevel: false` — keeps top-level names (plus `parse.bare_returns` for the top-level `return`).
## Dev-server environment
All env vars are optional. `VITE_OPENWRT_HOST` is the bare router address (default `192.168.1.1`); the web proxy target and the `.ut`-sync SSH target (`root@<hostname>`) both derive from it — key selection etc. belongs in `~/.ssh/config`.
`ucode/template/themes/shadcn/*.ut` is pushed whole to `/usr/share/ucode/luci/template/themes/shadcn/` on dev-server startup and on every save (tar over ssh stdin), and `/cgi-bin` page loads wait for in-flight pushes.
`pnpm setup:router` writes all of that to `.env` and installs the SSH key on the device in one shot (`pnpm setup:router <ip>` to run non-interactively; safe to re-run after a reflash).
## `vite.config.ts` plugins
- `local-serve-plugin` — serves `main.css`/`login.css`/sidebar, menu & router JS at their `/luci-static/...` paths during `pnpm dev` and forces a full reload on change
- `ut-sync-plugin` — pushes the `.ut` template dir to the router over SSH (full push on startup + debounced push on save; `/cgi-bin` requests wait for pending pushes)
- `redirect-plugin` — redirects `/` to `/cgi-bin/luci` in dev
- `luci-js-compress` — runs `.dev/src/resource/*.js` through terser into `resources/`
- `mock-pages-plugin` — serves saved page snapshots at `/mocks/` against the live theme, and injects the mock bar into both those and proxied device pages (see [mock-pages.md](mock-pages.md))
## Tests & formatting
No linter CLI; the only tests are `.dev/tests/*.test.js` (plain `node:test`, no deps), run with `pnpm test`. Prettier (with `prettier-plugin-tailwindcss`) runs on format-on-save and sorts `@apply`/class lists — don't hand-reorder them.
## Releases
- `Makefile` (`PKG_VERSION` / `PKG_RELEASE`) is the OpenWrt package manifest, built via `feeds/luci/luci.mk`.
- `.github/workflows/build-theme.yml` builds `.ipk`/`.apk` via `eamonxg/build-luci-package` on version tags, pushes to `main`/`feat/**`, or when the commit message contains `[build]`.
+24
View File
@@ -0,0 +1,24 @@
# CSS conventions
Style with TailwindCSS v4 `@apply`, using CSS Nesting (`&:hover`, `&[disabled]`, `.parent &`, etc.) for scoped selectors — this is the dominant pattern across every component file. Fall back to raw CSS declarations only when `@apply` can't express the rule: custom properties, `@keyframes`/`animation`/`filter`, `clip-path`, `backdrop-filter`, and inline SVG data-URI backgrounds. The single deliberate exception is `patches/*.css`, plain CSS by design — see [patches.md](patches.md).
## Import order
`main.css` import order is meaningful (later imports win the cascade): `_tokens.css``_base.css``_layout.css``components/_*.css``_utilities.css``_shared.css`. New component styles get their own `components/_name.css`, imported before `_utilities.css`. Third-party app patches are **not** bundled into `main.css` — they load on demand per page.
Built CSS keeps Tailwind's native `@layer` structure. Theme partials (`_base.css`, `components/*`, `_utilities.css`, …) are plain unlayered CSS — organization comes from the file split, never wrap rules in `@layer`. Unlayered partials outrank Tailwind's layered base/utilities regardless of specificity; the OKLCH tokens already gate browsers to ones with `@layer` support.
## Tokens
- **Token source**: the engine and resolver (operators, `createResolver`, CSS emission helpers) come from `@eamonxg/luci-theme-tokens` (`/engine`, `/emit`); this repo only keeps `tokens/spec.js` (derivations, baked-alpha variants) + `tokens/defaults.js` (input colors). Edit those, then run `pnpm gen:tokens`. Do not edit generated `src/media/_tokens.css` directly.
- **`_tokens.css`**: generated flat OKLCH custom properties for light and dark modes plus the shared `@theme inline` mapping. It is imported by both `main.css` and `login.css`; runtime token-based `color-mix()` and relative `oklch(from …)` are prohibited.
- **`login.css`**: separate Vite build entry for the login page; it does **not** import `main.css`, but re-imports the generated `_tokens.css`.
## Dark mode
`@custom-variant dark` keyed on `[data-darkmode=true]`, set by an inline script in `header.ut` before paint (reads `localStorage['shadcn.theme']`) to avoid a flash of the wrong theme.
## Icons, two sources
- `.dev/src/assets/icons/` (Lucide SVGs) are referenced from CSS via the `@assets` alias as `mask-image`/`mask`, so they inherit `currentColor`.
- `.dev/public/shadcn/icons/` are SVGs referenced directly via `<img>`/JS (sidebar, menu, login, theme toggle) and copied verbatim to `htdocs/luci-static/shadcn/icons/`.
+33
View File
@@ -0,0 +1,33 @@
# Mock pages
Style a third-party app's page — write or adjust its `patches/*.css`, or check a `main.css`/component change against it — **without having the app (or a device) installed**. Snapshots live in `.dev/mocks/*.html` (git-ignored: large, device-specific, and they go stale). Served by `mock-pages-plugin` in `.dev/vite.config.ts`.
## The mock bar
Every HTML page the dev server hands out — proxied device pages and served snapshots alike — gets `scripts/mock-bar.client.js` (served at `/mocks/__bar.js`), a floating bottom-left bar in a Shadow DOM, so theme and patch CSS can neither restyle it nor be polluted by it.
On a device page it lists what `.dev/mocks/` holds, so the workflow is reachable without typing the `/mocks/` URL: `◆` appears when this page's `data-page` matches a snapshot and opens it in one click, `⊕` captures the open page, and an empty `.dev/mocks/` shrinks the bar to a lone `⊕`. Inside a snapshot it names the open one, steps through the rest, and `↩` goes back to the same page on the device. `✕` collapses it to a dot, remembered in `localStorage['shadcn.mockbar.collapsed']`.
The snapshot list is injected inline next to the script; both tags carry `data-shadcn-mock`, which is how a capture strips them back out — a snapshot must never bake in a list that is re-injected, current, on every serve.
## Capture
Open the page through the dev proxy and hit `⊕` on the mock bar — or press <kbd>Alt/Option+Shift+S</kbd>, or call `__shadcnMockCapture()`. It POSTs the live DOM to `/mocks/__save`, which writes `.dev/mocks/<data-page>.html` — doctype included, dev-only script tags stripped, custom-header gated so a foreign origin can't drive it. Hand-saved HTML works too; the filename is free, since a snapshot's identity is its `<body data-page>`.
**Capture from a device running _this_ theme — snapshots are not portable between themes.** A snapshot copies a rendered page verbatim, so it hard-codes the rendering theme in its stylesheet links (`/luci-static/shadcn/main.css` plus that page's patch), in the device's UCI token overrides inlined as `<style>`, and in the theme's own shell markup. A snapshot taken under `luci-theme-aurora` therefore renders **completely unstyled** here, since a dev server only serves its own `/luci-static/<theme>/` prefix; the tell is a terminal line naming the other theme's stylesheet (`[Mocks] miss /luci-static/aurora/main.css → 404 …`). Mirroring it under `mocks/static/`, which that generic hint suggests, is the wrong fix — re-capture on a shadcn device. Reusing a foreign snapshot regardless only makes sense for the app's own content region: repoint its stylesheet links at `shadcn`, and drop the inline `<style>`, whose captured tokens otherwise override this theme's.
## View
`pnpm dev`, then <http://localhost:5173/mocks/> — an index of every snapshot with its `data-page` and age. Each is served with the Vite HMR client injected, so editing `main.css`, a component, a `patches/*.css` or served JS full-reloads the open mock. Absolute `/luci-static/…` links resolve against this checkout and compile on the fly.
## Navigate
Inside a mock the bar also takes over clicks on the snapshot's own `/cgi-bin/luci/…` links, matching them to snapshots by `data-page` and jumping in place — an app's own tab bar works as it does on the device. Uncaptured targets are blocked with a hint naming the missing snapshot. The bar lists all snapshots and cycles with <kbd>[</kbd>/<kbd>]</kbd>; `↩` leaves for the real page, targeting the `requestpath` from LuCI's own inline bootstrap when that agrees with the snapshot's `data-page`, else falling back to splitting `data-page` (lossy when a segment contains a dash, `admin-status-disks-info`), then to the last device page visited in this tab.
## Third-party assets
Mirror an app's own css/js under `.dev/mocks/static/` following its URL (e.g. `.dev/mocks/static/luci-static/resources/foo/foo.css`); served as-is, no HMR. Misses requested by a mock page 404 instantly with a one-time terminal hint naming the mirror path, so mocks never hang on an unreachable router; anything else on `/luci-static` that falls through to the proxy is bounded to 5s → 504.
## No auth, no runtime
LuCI's runtime scripts (`luci.js`/`cbi.js`/`xhr.js`, `/cgi-bin/` endpoints) are stripped and `L`/`LuCI`/`XHR` stubbed, or LuCI would boot, poll, 403 and pop "Session expired". Framework-dependent theme JS (`menu-shadcn`, `sidebar-shadcn`) therefore no-ops — the captured DOM is already rendered, so the page still looks right; the theme's own inline scripts (dark mode, sidebar cache replay) still run.
+33
View File
@@ -0,0 +1,33 @@
# On-demand page patches
Third-party LuCI app/page compatibility fixes live one file per page in `src/media/patches/<page>.css`, where `<page>` is the `[data-page="..."]` value (request path segments joined by `-`). They are **not** bundled into `main.css`; `header.ut` links only the ones that match the page being rendered.
## Plain CSS, by design
**Patches are the one place that writes plain CSS instead of `@apply`.** Each is its own Rollup entry, so `@reference "../main.css";` + `@apply` made every file carry its own `@property` boilerplate (7 patches: 6,491 B → 1,083 B once rewritten).
Write narrow `[data-page]`/class-scoped overrides with native declarations, still reaching for theme values through the `:root` custom properties (`var(--panel-bg)`, `var(--foreground-a30)`, `var(--shadow-sm)`, …) rather than hardcoded colours or magic numbers. A value a patch needs but `:root` doesn't expose gets added to `STRUCTURE` in `scripts/gen-tokens.js` (that is why `--shadow-sm` exists), not inlined. CSS Nesting still works, since it needs no Tailwind processing.
The `@reference` + `@apply` route still _compiles_ for theme-repo patches and has real upsides — build-time validation (a typo'd utility fails the build; a typo'd `var()` fails silently at runtime) and the shared `dark:`/`md:`/`hover:` vocabulary — native is the default for the size numbers above, not a hard gate. App-shipped patches bypass the build entirely and have always been plain-CSS-only.
Globally-applicable chrome tweaks (e.g. icon opacity) belong in `_shared.css`, not here.
## Discovery and matching
`vite.config.ts` builds each patch as its own Rollup entry → `htdocs/luci-static/shadcn/patches/<page>.css`.
`header.ut` discovers installed patches at render time via `fs.lsdir()` (no build-time allow-list) and matches them against the cumulative path-segment prefixes of the current page: a patch applies to its page and all subpages, matching only on real segment boundaries so a prefix never leaks onto a lookalike sibling app. All matching patches load (sorted, so a shorter/general name precedes a longer/specific one, which then cascades on top) — this also lets dynamically generated pages (e.g. one page per contact/device) be covered by a patch named after their fixed prefix.
Because discovery is at render time, **any package — not just the theme — may drop a `<page-prefix>.css` into `luci-static/shadcn/patches/`** and it takes effect immediately, no theme rebuild required.
`PATCH_ALIASES` in `vite.config.ts` can duplicate one built payload (CSS and JS) under several page names when unrelated pages share it. It is currently empty — the log viewer ships only `patches/admin-status-logs.js`, whose prefix covers both log tabs on every supported release; its CSS is core-page styling and lives in `components/_syslog.css` inside `main.css`, where the `.syslog-view` markup contract is deliberately global so other packages can reuse the viewer. `_`-prefixed files in `patches/` are `@import`-only fragments, never entries.
## JS payloads
The same `lsdir()` sweep loads `patches/<page>.js` as `<script defer>` after the patch stylesheets (theme-owned sources in `src/resource/patches/`, Terser-compressed to `shadcn/patches/`; third-party packages may drop plain scripts the same way).
A JS patch must register `window.luciPatches[<stem>] = { mount, unmount }` and mount itself once at eval: the [client-side router](router.md) drives it across same-document navigations and a plain script would otherwise keep running on pages that are gone. `header.ut` also emits every installed patch file as `body[data-patches]` and marks its own `<link>`/`<script>` tags `data-luci-patch` so the router can enable/disable them per page.
## Adding / removing a theme patch
Create the file, run `pnpm build`, verify the built file is small. Removal is symmetric — delete the file, rebuild.
+647
View File
@@ -0,0 +1,647 @@
# The client-side router
> The router now ships from `@eamonxg/luci-theme-devkit` (`runtime/router.js`,
> page-scoped patches in `runtime/patches.js`), built into
> `resources/router-shadcn.js` by its Vite plugin; this theme keeps only the
> markers, the `luci-navigate` listener in `menu-shadcn.js` and the CSS. The
> design below is unchanged; the devkit copy is the maintained one.
How the theme turns a menu click into an in-document view swap instead of a
full page load, where it deliberately does not, and the invariants a router
inside LuCI has to keep. Source: `.dev/src/resource/router-shadcn.js`,
loaded from `footer.ut` after `menu-shadcn.js`. **No changes to luci-base
or to any view** — the router is additive theme JS plus small template hooks
(a patch manifest, `data-shadcn-*` markers on the stylesheets header.ut
itself renders, the asset version, a focusable `#maincontent`).
Ported from `luci-theme-aurora`'s `router-aurora.js` and kept in step with it
(currently its `58d6498`, 2026-08-17 — same-URL reloads handed to the server,
the Turbo-shaped progress bar, no focus ring on the landmark); the kernel,
resolver, teardown and gates are the same code, and the
theme-specific parts — hostname/title format, the sidebar sync in
`menu-shadcn.js`, `#maincontent` being the scroller — are the only
divergences, each called out below.
## At a glance
![One LuCI navigation: what the device does, what the browser does, and which of those steps the same-document router deletes](https://raw.githubusercontent.com/eamonxg/assets/master/shared/architecture/same-document-router-architecture.svg)
Two swimlanes over a shared millisecond axis — what the OpenWrt device does,
what the browser does — then a ledger of which steps the router deletes,
which stay on the device, and which the browser takes over, including the
teardown it now owes because the document no longer dies.
**The figures in it were measured with `luci-theme-aurora` deployed**, on the
shared code path (see "Why it pays" below). Two of them are aurora's alone
and do not transfer: the `header.ut` cost (1 ubus · 2 uci · 1 lsdir · 2
readfile — shadcn's header.ut makes 1 ubus and 1 `lsdir`) and the static
`main.css` row (191,899 B — shadcn's is 146,992 B). Everything else is
dispatcher and luci-base work, identical under either theme.
## Prior art
[luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap)
(credited in the README) solves the same problem, and reading it informed
two pieces here: pausing `L.Poll` on a hidden tab, and folding a view's
read-only state along its dispatch path (both below). The rest is
independent, and one choice diverges deliberately. footstrap drives
navigation through the **History API** (`pushState`/`popstate`) with its own
scroll bookkeeping and a `prototype.render` guard to repair stale renders;
this router is built on the **Navigation API** instead (see "Kernel"), which
hands scroll, history and supersession to the browser and needs none of
that — at the cost of running only on newer browsers, where the theme falls
back to the plain MPA it already is. On top of that shared base this router
also adds a session-expiry gate, reproduces `template` pages from the
server's own shell rather than hand-porting them, and cross-fades the swap
with a view transition — each its own section below.
## Why it pays, measured
Numbers below were taken with the aurora theme's router on the same code
path (`bench-fullload.mjs` / `bench-dispatch.sh`, 2026-08-18); the shadcn
port shares the kernel, the resolver and the view render, so the shape holds
and the ratio is the point. Device: **Cudy TR3000** (mediatek/filogic,
ARMv8), OpenWrt SNAPSHOT r0-20d94d5, plain HTTP, warm cache, RUNS=10,
medians over the 8 pages below. Run-to-run spread is ±40 ms on a full load.
Where a full load's time goes:
| stage | ms | what it is |
| ----------------------------------------- | ------: | ------------------------------------------------------ |
| dispatch #1 — the page HTML | 0→123 | TTFB 118: menu tree, ACL fold, `view.ut``header.ut` |
| dispatch #2`admin/translations/<lang>` | 124→209 | a _second_ CGI process, parser-blocking, uncacheable |
| DOMContentLoaded | 215 | the shell is back, byte-identical to the one discarded |
| view module + ubus data + render | 215→321 | static assets are already cache hits |
**209 of the 321 ms passes before anything page-specific has happened.** Both
dispatches re-derive a shell the browser already had on screen; the view's
own ubus calls do not start until 227 ms. A same-document swap deletes both
dispatches and keeps the last row — the same 8 pages land at a median of
**91 ms** warm, with the data calls starting at 2 ms instead of 227 ms.
The dispatch cost is the dispatcher's, not the theme's: measured on the
device over loopback, a `view` node's HTML is 75.4 ms, `admin/translations/en`
is 62.7 ms for a **13-byte** body, and a 191,899-byte static file is served
in 0.8 ms. The cost is the dispatch, not the payload — and a full page load
pays it twice.
End to end, click → view painted:
| page | full load | router (warm) | faster |
| ----------------------- | --------: | ------------: | -----: |
| status/routesj | 326 | 92 | 72 % |
| status/nftables | 316 | 90 | 72 % |
| status/logs | 281 | 100 | 64 % |
| status/processes | 457 | 228 | 50 % |
| status/channel_analysis | 401 | 54 | 87 % |
| status/realtime | 211 | 37 | 82 % |
| system/system | 496 | 132 | 73 % |
| system/admin | 231 | 40 | 83 % |
Median **73 % faster**, range 5087 %. `bench-router.mjs timing`, an
independent harness, was run twice the same day and landed at 72 % and
74.5 % — all of that is inside the device's own spread, so treat the range,
not the digit, as the result. Document prefetch cannot reach it: it hides
the first dispatch at best, and the catalog is a subresource fetched after
the document arrives.
## Why it is possible
For a `view` node the dispatcher renders `view.ut`: the theme header, then
`<div id="view">` with an inline `L.require('ui').then(ui =>
ui.instantiateView('<path>'))`, then the theme footer. The server decides
_which_ view; the client renders it. The router repeats what `view.ut` does
without the reload: resolve the path against the menu tree the client
already holds (`ui.menu.load()` serves it from `sessionStorage`), swap the
content region, re-instantiate the view, and let the browser own the URL.
## Kernel: the Navigation API, and only that
`navigation.addEventListener('navigate', …)` + `event.intercept()`. One
event covers link clicks, `location.assign`, back/forward traversals to
same-document entries, and our own `navigation.navigate()`; the browser
writes the URL and history entry, exposes `event.signal` for supersession,
and (with `scroll: 'after-transition'`) restores window scroll on
traversal / scrolls to top on push, so the router carries no
`pushState`/`popstate` code and no fragment-vs-navigation heuristics. The
one piece of scroll bookkeeping it does keep is for `#maincontent`, the
theme's own scroller (see "The navigation procedure", step 7).
Why this API rather than the History API `luci-theme-footstrap` uses:
- **The browser owns URL, history and scroll.** `pushState` puts the
router in charge of all three and of keeping them consistent with what
it rendered; here it only ever renders.
- **Supersession is built in.** A newer navigation aborts the older one's
`event.signal`; the generation gate below is a check, not a state
machine, and no `render` guard is needed to repair a stale paint.
- **Every navigation kind arrives at one listener** — link click,
`location.assign`, back/forward to a same-document entry, our own
`navigation.navigate()` — so there is exactly one path to keep correct.
- **The fallback is free.** Where the API is missing, the theme is the
MPA it already was; nothing has to be polyfilled or feature-forked.
**Browsers without the API stay MPA.** `footer.ut` only requires the module
when `window.navigation` exists, and `__init__` re-checks the surface it
actually uses: `navigation.addEventListener`, `NavigateEvent`, and
`intercept` on its prototype. Chrome/Edge **105+**, Safari 26.2+, Firefox
147+ get the router — 105, not the 102 that first shipped the Navigation
API, because the method was called `transitionWhile()` until Chrome 108 and
`canIntercept` was `canTransition`; gating on `intercept` is what makes the
floor 105. The theme's declared floor (Chrome 111 / Safari 16.4 /
Firefox 128) keeps working as it does today. This is a deliberate trade:
one code path, correct by construction, over a second history-API path that
would double the surface of everything below.
## Compatibility
### Browsers — per platform feature
| Feature | Used for | Required? | Chrome / Edge | Safari | Firefox | Without it |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -------------- | ------------- | --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Navigation API (`navigation.addEventListener('navigate')`, `NavigateEvent.intercept()`, `event.destination/signal`, `navigation.navigate()/back()`) | the whole router | **yes — gate** | 105+ (2022) | 26.2+ (2026-01) | 147+ (2026-01) | `router-shadcn.js` is not even loaded (`footer.ut` checks `window.navigation`); the theme is the plain MPA it was before |
| `document.startViewTransition()` (same-document) | crossfade at the swap | no | 111+ | 18+ | 144+ | swap without animation; also off under `prefers-reduced-motion` |
| `fetch(url, { priority: 'low' })` | hover module prewarm | no | 101+ | 17.2+ | 132+ | the option is ignored, the fetch still runs at default priority |
| `MutationObserver`, `DOMParser`, `WeakSet`, `URL`, `Element.replaceWith`, `:scope`, `matchMedia`, optional chaining / `??=` | render completion, template shells, poison gate, staging | yes | ≥ 85 | ≥ 14 | ≥ 79 | all inside the theme's declared floor (Chrome 111 / Safari 16.4 / Firefox 128) |
So the router's effective floor is Chrome/Edge 105, Safari 26.2, Firefox 147;
everything older keeps the theme's existing floor and behaviour. The
aurora port was verified live in Chrome 151 (headless CDP); Safari/Firefox
by feature detection only — the gate is the same API surface, not a UA sniff.
### OpenWrt / LuCI
The theme already requires OpenWrt 23.05+ (ucode templates). Except for the
two version-scoped items called out below, the router touches only luci-base
surfaces that are identical in the `openwrt-23.05`, `openwrt-24.10`,
`openwrt-25.12` and `master` branches of `openwrt/luci` (checked against the
branch sources, 2026-08): `L.require` with instance caching and
`prototype.constructor`, `L.view`, `L.dom.content` and the `data-idref`
registry, `L.env.{scriptname, base_url, resource_version, media,
requestpath, dispatchpath, pathinfo, nodespec}`, `L.hasSystemFeature`,
`L.Poll.{queue, start, stop, active, timer}` (and `start()`'s reset of
`tick`, which is what re-arms an incoming view's first poll), the
`poll-status` indicator id `setupDOM`'s `poll-start` handler registers,
`ui.menu.load()`'s session-cached tree with `satisfied` /
`firstchild_ineligible` / `wildcard` / `action.type` (`view`, `alias`,
`firstchild`, `template`), `ui.instantiateView`, `ui.hideIndicator`,
`ui.hideModal`, `uci.state.values` / `uci.unload()` / `uci.load()`,
`network.js`'s uci-backed state, `Request.addInterceptor` /
`rpc.addInterceptor` and the `-32002``session.access` probe in
`setupDOM`, `dispatcher.uc`'s `ctx_append` acl folding, `view.ut`'s `#view` +
inline `instantiateView` shell, and `dispatcher.uc`'s `resolve_firstchild` /
`node_weight` / alias re-dispatch semantics (ported line for line).
**Two surfaces are not the same across those branches**, and the resolver is
written against the newer one:
- **`node.css`** entered `build_pagetree`'s schema in master only
(7c6d8ff, 2026-08). 23.05, 24.10 and 25.12 carry no `css` on any node, so
`nodeCss()` returns `null` and the feature is simply inert there.
- **Wildcard descent.** `wildcardaction` exists in 25.12 and master, not in
23.05 or 24.10 — an absent key just falls back to `node.action`, which is
what those releases do anyway, so that part is safe. The _resolution rule_
around it is not: 25.12 and master descend into a matching `satisfied`
child before treating trailing segments as args, while 23.05 and 24.10
capture every remaining segment the moment a `wildcard` node is reached.
The router ports the 25.12/master rule. On 23.05 or 24.10 a tree that has
both `foo/*` and a real `foo/bar` child would therefore resolve differently
in the router than in the dispatcher — the exact "click opens one page, F5
opens another" failure this resolver exists to avoid. Both the rule and
`wildcardaction` came in as one commit (df90c60a7, 2026-01-17) whose stated
purpose is to let `path/*` carry an action distinct from the bare path, so
the shape had no defined behaviour before it and a tree written for
23.05/24.10 is unlikely to use it — but that is an argument, not a survey
of every installed `menu.d`, and the router has not been run on either
release. Treat 23.05/24.10 as inspected, not verified.
Live verification so far: the aurora build of this router on OpenWrt
SNAPSHOT r0-20d94d5 (2026-08, mediatek/filogic) and an earlier SNAPSHOT on
ipq60xx. 23.05 / 24.10 / 25.12 by branch source only, not on device.
That list is also executable: `contract()` in `router-shadcn.js` looks every
one of those surfaces up at boot (`L.view`, `L.require`, `L.dom.content`,
`L.env.{base_url,resource,media}`, `L.Request.addInterceptor`,
`L.uci.{load,unload,state}`, `rpc.addInterceptor`,
`poll.{queue,start,stop,active}`, `ui.menu.load`, `ui.hideModal`,
`ui.hideIndicator`, `E`) and, if any is missing, logs which and does not
activate — the theme is the MPA it was, not a broken router, on a luci-base
that moved.
## What is intercepted
A `navigate` event is intercepted only when **all** hold:
- `event.canIntercept` (same-origin, not cross-document-only), not
`hashChange`, no `downloadRequest`, no `formData`, `navigationType !== 'reload'`;
- the destination is **not the document's own URL** (fragment aside). A
same-URL navigation arrives as `navigationType: 'replace'`, not
`'reload'`, yet it is a reload by another name: luci-base's
`ui.changes.apply/revert` end in `window.location =
window.location.href.split('#')[0]` (and the expiry modal's button in the
same) precisely so the server re-renders the shell — a theme switched
under System → Language and Style, a new language, a new hostname, a
changed menu tree — and intercepting it left the swap showing the old
shell until F5. A click on the current page's own link is the same reload
it is in the MPA;
- the destination path (minus `L.env.scriptname`) resolves in the menu tree
to a **serviceable node** (below);
- the document is not **poisoned** (below) and its session is not known to
be **expired** (below);
- the router **activated** in this document: it does so only when the page
it booted on is itself serviceable. A `call`/`cbi`/`function` page carries
scripts (legacy `XHR.poll`, inline timers) that only a document death
retires; the first click away from one is always a full load.
Anything else falls through untouched: the browser performs the ordinary
full navigation, i.e. exactly what the theme did before. Modifier-clicks and
`target=_blank` never reach the event.
### Serviceable nodes
Resolved with a port of the dispatcher's own rules, not a paraphrase:
- `alias` → jump to `action.path` from the root and continue;
- `firstchild` → the same `resolve_firstchild()` / `node_weight()` the
dispatcher runs: candidates are `satisfied` children with a `title` and an
object `action`; weight `min(order ?? 9999, 9999)` +10000 for
`auth.login`; a `firstchild` candidate counts only if it resolves further;
`firstchild_ineligible` excluded; ties keep key order. The ACL check is
skipped because `/admin/menu` is already filtered for the session;
- `wildcard` nodes are descended into first — a segment that matches a
`satisfied` child wins over arg capture — and only the remainder becomes
request args; with args present the node's `wildcardaction` (the `path/*`
entry's own action) runs, and `action` for the bare path. This is the
25.12/master rule; 23.05 and 24.10 capture at the first `wildcard` node
instead — see "OpenWrt / LuCI" above;
- a hop counter (32) breaks cycles in a foreign `menu.d`;
- **any segment that does not match a `satisfied` child ends the attempt.**
The dispatcher would fall back to the deepest satisfied ancestor and
re-resolve from there; the router returns `null` and hands the navigation
to the server. Deliberate: the fallback costs one full load, guessing the
ancestor wrong costs the wrong page.
Two tracks are kept, as a full load keeps them: **requested** segments →
`L.env.requestpath`, `L.env.pathinfo`, `body[data-page]`; **resolved**
segments → `L.env.dispatchpath`, `L.env.nodespec`, the menu highlight, the
title. Pick a different child than the dispatcher would and a click opens
one page while F5 opens another — that is why the resolver is a port.
| node | served |
| --------------------------------------------------------- | ------------------------------------- |
| `view` | yes — `view.<path>` |
| `alias`, `firstchild` | yes — resolved to a leaf, recursively |
| `template` whose page is a view shell (Status → Overview) | yes — shell fetched once, see below |
| Lua `template`, `call`, `function`, `cbi`, `rewrite` | no → full load |
`rewrite` is deliberately not resolved: it is not in the tree and a splice
mistake opens the wrong page, which is worse than the reload it falls back to.
### Template nodes: the server's own shell, never a hand port
`admin/status/overview` is a `template` whose server side defines page
globals (`progressbar`, `renderBox`, `renderBadge`), emits an `<h2>` and a
`div.includes` (server-rendered Lua includes), and then instantiates
`view.status.index`. A first version re-implemented those helpers in the
router and drifted on the first real page (the network badges lost their
labels: upstream's `renderBadge` takes extra `L.itemlist` arguments the port
did not know about). So the router does not port anything: when a link to a
template node is hovered or focused, its page is **fetched once per
document** (the in-flight request is shared by every intent event that
arrives before it resolves), parsed with `DOMParser`, and the content region after `#tabmenu` (to the
end of `#maincontent` — this theme renders no `<footer>` inside it, and the
router stops at one if a template ever emits it) is kept as the page's
_shell_ — every node cloned, `#view`
replaced by an empty div, the inline `instantiateView('…')` script read for
the class name, the remaining inline scripts (the helpers) replayed into
global scope on staging. luci-base's own bootstrap (`luci.js` and
`L = new LuCI(env)`) also lives in that region and is filtered out. If the
document _is_ that template (the session started on Overview), the shell is
taken from the live region and no fetch happens. A template node is only
intercepted once its shell is known — so a Lua template page (no
`instantiateView` call) is remembered as unservable after one hover fetch
and never enters the router's error path, and a template clicked without a
prior hover is a plain full load that seeds the shell for the rest of the
document. Its status include modules are singletons carrying
`oneshot`/`hide` state that a full load would reset — verified against a
real full load, not against expectation.
## The navigation procedure
`intercept({ handler, focusReset: 'manual', scroll: 'after-transition' })`,
handler in order:
1. **Generation.** `const gen = ++this.gen`; every later DOM write is gated
on it. `event.signal` aborts our own awaits, but it cannot cancel a LuCI
XHR (`L.Request` hands back a bare promise; the `XMLHttpRequest` only
surfaces on the _resolved_ `Response`, too late to abort) or a
`View.__init__` chain already running, so the generation is the
correctness mechanism and the signal is hygiene.
2. **Teardown of the departing document state**, i.e. what a document
death would have done for free:
- `Poll`: `queue.length = 0; stop(); start()` — three steps. The flush
drops the old view's pollers; `stop()` drops the tick; `start()` on an
empty queue re-arms `tick = 0` so the incoming view's `poll.add()`
auto-starts and fires immediately instead of waiting up to `interval`
seconds for the surviving tick to align. Upstream's `initDOM()` does
the same `Poll.start()` on an empty queue before the first view.
- `uci`: `unload()` every package present in `state.values` **or**
`uci.loaded` (documents start with an empty cache; four shipped apps
read `load()`'s return as an existence check and draw an error over
the page when the cache answers `[]`; and `uci.loaded` keeps a
package's request promise — a rejected one included — until
`unload()`, so a failed load left there would be handed to every later
view). Then, if `L.network` has been loaded,
`load(['network','luci'])` — plus `'wireless'` when
`L.hasSystemFeature('wifi')` — is re-issued and **awaited**
and a rejection propagates to the hard-load fallback rather than
leaving `network.js` on an empty config: `network.js` fills its `_state` once and
from then on answers out of the uci cache (`getWifiDevices()` _is_
`uci.sections('wireless','wifi-device')`), so dropping those without
refilling hands every consumer an empty config for the rest of the
document. Unsaved local edits die with the page as they would on a
full load; saved changes live on the server and the Unsaved-changes
indicator is unaffected.
- bare `setInterval`s registered since the router booted are cleared
(`setInterval`/`clearInterval` are hooked in `__init__`, i.e. when
`L.require('router-shadcn')` instantiates the class; `poll.timer`, the
one interval `L.Poll` owns, is skipped). `setTimeout` and rAF are
**not** touched: the
core keeps tooltips, notification timeouts and a request timeout on
`setTimeout`, and there is no self-rescheduling timeout in any
shipped view.
- `window`/`document` listeners a view registered **while it rendered**
are removed. Several shipped views add them per render (statistics
graphs: an anonymous `resize` that later throws against detached DOM;
nlbwmon: `tooltip-open`/`touchstart`; the core's own dropdown widgets:
one `window` click/touchstart per instance), so they accumulate and
act on pages that are gone. The hook records registrations inside the
render window only. A **warm** render evaluates no module, so its
registrations are per-render by construction and go on the next
teardown. A **cold** render also runs the module's top level, whose
registrations must survive (removing them is one-way — an editor's
module-eval listeners never come back), so cold registrations are only
credited to the class and released when a later warm render of the
same class registers the same target/type, which proves them
per-render.
- `ui.hideIndicator('poll-status')` — luci-base leaves a
_Refreshing_ / _Paused_ indicator behind that a document death would
have taken with it.
- `ui.hideModal()`, the theme's own surfaces (mega
menu, mobile drawer, palette) close.
- page-scoped patch CSS is disabled and its JS patch unmounted (below).
The uci flush is the one part of this that is awaited rather than
fire-and-forget, so it is a separate step after `teardown()` returns.
3. **Environment.** `L.env.requestpath/dispatchpath/pathinfo/nodespec`,
`body[data-page]`, `document.title`. An alias is re-dispatched
server-side, so `requestpath` and `data-page` carry the alias target while
`pathinfo` keeps the URL as requested; a `firstchild` keeps the requested
path in both. header.ut titles pages `<hostname> | <title>`; the head
before the separator is read off the initial document, so it matches
whatever the template emitted. `nodespec` drives
`L.hasViewPermission()` and therefore the Save/Apply footer's readonly
state — and its `readonly` is **folded down the dispatch path** the way
`dispatcher.uc` does it: `ctx_append` collects every node's
`depends.acl` and one `check_acl_depends()` over the union is writable as
soon as _any_ group is writable, so a page is readonly only when every
acl-bearing node on its path is. The tree's per-node flag
(`apply_tree_acls`) covers that node's own acl alone; handing the leaf
node over as-is gave a read-only user a live Save & Apply on every page
under a read-only group. The tree object is not mutated (`nodespec` is a
copy). `data-page` keys `ui.tabs` session state and the theme's
page-scoped CSS.
4. **Chrome.** `menu-shadcn.js` exposes `syncRoute()`: it re-marks the
sidebar's `.active` item/sub-item/logout from `L.env.dispatchpath` with
the same longest-link-prefix rule header.ut's pre-paint replay uses on
the cached markup, opens the active accordion group and closes the rest
(unless the sidebar is collapsed to its icon rail), rebuilds the topbar
breadcrumb and re-renders `#tabmenu` for the new section. The sidebar is
**not** rebuilt — its DOM, delegated listeners and the sessionStorage
cache taken on `pagehide` all keep working — only its state changes.
`closeSurfaces()` (teardown) closes the palette, the mobile drawer and
the collapsed-rail popover.
5. **Staging.** A fresh `<div id="view" class="view-staging">` is inserted
right after `#tabmenu`, i.e. **first in tree order**
`getElementById('view')` returns the first match, so everything LuCI's
view chain writes goes into the staged element while the outgoing page
stays on screen (dimmed, `.view-leaving`). The stage is invisible but
**laid out** (`visibility:hidden; height:0; overflow:hidden`, never
`display:none`): the realtime graphs size themselves from
`#view.offsetWidth` inside `render()`, and a `display:none` stage handed
them a 0-wide canvas. Nothing is removed yet.
6. **Patches.** `header.ut` emits the installed on-demand patch stems as
`body[data-patches]`; the router applies the same segment-prefix rule the
template applies at render time: matching `patches/<stem>.css` links are
ensured (`<link data-luci-patch>`, enabled for the page on screen,
`disabled` — not removed — for the rest, so a return costs nothing);
matching `patches/<stem>.js` files are loaded once and their
`window.luciPatches[stem]` `{ mount, unmount }` pair is driven per
visit (the list of stems to mount belongs to the navigation that computed
it, so a superseded one mounts nothing later); URLs the router adds carry
the same `?v=PKG_VERSION` luci.mk stamps on the template's own links,
read from `body[data-asset-version]`, so they hit the same cache entry — a JS patch that registers nothing is simply executed once,
MPA-style. A patch script mounts itself when it evaluates; if the user
has navigated on before it arrives, its `load` handler checks whether
the current page still wants that stem and unmounts it otherwise (a
same-stem page reached meanwhile keeps it mounted).
A menu.d node's own `css` (`header.ut` links `<resource>/<node.css>` for
the dispatched node, marked `data-luci-node-css`) is kept the same
way: one `<link>` per stylesheet, enabled for the page whose resolved leaf
declares it, `disabled` for every other page, never removed. Both
attributes are exempt from the poison gate.
7. **View.**
- **cold** (`view.<path>` never required in this document):
`window.L.require(className)` — the require _is_ the render (LuCI
instantiates on first require) and it must go through `window.L`, the
runtime instance, never the prototypal `L` a module factory receives
(`ui` hangs `itemlist`/`showModal` on `window.L`; a view required
through the wrong `L` dies three modules down on `L.itemlist is not a
function`, and because `require()` caches by name the binding is fixed
by the _first_ requirer);
- **warm**: `require()` hands back the cached instance whose `__init__`
already ran; LuCI's class system sets `prototype.constructor`, so
`new instance.constructor()` runs a fresh `__init__``load()`
`render()``dom.content('#view')`, exactly what a full load starts
from.
- **completion** is observed, not assumed: a `MutationObserver` on the
staged element resolves when a non-spinner child lands (or the spinner
is removed for an empty render). Not completing within 15 s is a
**failure**, not a completion: committing the spinner and releasing the
serialization would let the still-running chain paint into a later
navigation's `#view`, so the timeout rejects and the catch path
hard-loads the destination. On completion —
and only if this navigation is still the latest — the outgoing region
(everything after `#tabmenu` except the staged element) is removed
and the staged view is unhidden inside `document.startViewTransition()`
when available and reduced motion is off; the navigation's `finished`
promise resolves after that swap. In this theme `#maincontent`, not the
window, is the scroller, so the browser's `scroll: 'after-transition'`
never sees the offset that matters: the departing entry's
`#maincontent.scrollTop` is remembered by `navigation.currentEntry.key`
when the navigate event is intercepted (it still names the departing
entry during dispatch) and put back on a `traverse` (by
`event.destination.key`), while a push lands at the top — as a full
load would.
- **Renders are serialized.** Neither an in-flight LuCI XHR nor a running
`View.__init__` chain can be cancelled (`L.Request` never exposes its
handle), and every chain paints into _whichever_ `#view` is first at
paint time. So a navigation first awaits the previous one's completion
(bounded by the same timeout) before it tears anything down or stages
anything — the previous chain finishes into its own staged element,
which is then discarded. Rapid A→B→C therefore never interleaves:
B is skipped when C arrives before B ran (`event.signal` /
generation), and C waits for whichever render is actually in flight.
The document's initial LuCI-rendered view is tracked the same way, so a
click during the first load cannot be painted over by it — and a first
render that never completes rejects that wait, so the first navigation
takes the hard-load fallback instead of staging next to a chain that
may still paint. That first render also runs inside a render window
(opened after the router's own listeners are registered), so the
listeners it adds are credited to its class like a cold render's; the
ones it registered before the router loaded are out of reach. The cost is
that a click during a slow load waits for that load; the alternative —
wrapping `prototype.render` per class and repairing stale cold renders
by re-navigating — leaves a real window open and needs three mechanisms
where one suffices.
8. **Focus and announcement.** `#maincontent` (`tabindex=-1`) with
`preventScroll`; the new `document.title` is written into
`#luci-nav-status` (`role=status`, `aria-live=polite`), since a
same-document swap fires no load a screen reader would announce.
9. **Progress.** A navigation that outlives 150 ms gets `#luci-nav-progress`
inserted — Turbo Drive's bar, in shape: a hairline at the top whose
`width` is driven inline and **trickles** in ever-smaller steps
(`+ (100 - w) / 30` every 300 ms) until commit, so a slow render keeps
visibly moving instead of looking stuck; on commit it fills to 100 %,
fades (`data-state="done"`) and is **removed from the DOM**. Shorter
navigations stay silent, overlapping ones share the bar. The browser's
own progress bar only shows for document loads, which is exactly what a
same-document swap is not — hence GitHub, YouTube, Turbo/HEY and every
nprogress user draw their own. Reduced motion drops the transitions, not
the bar.
10. Any exception → `console.error` (a silent fallback makes every router
regression look like "the page is just slow") → `location.href =
destination` — a hard full load, never a stuck page.
## The expiry gate
luci-base answers a dead session with `notifySessionExpiry()`: `Poll.stop()`
plus a modal whose only button is a hard reload. A same-document swap would
`hideModal()` and `Poll.start()` right through it and browse on, every page
erroring in turn (measured on the aurora port's expiry bench against a
router without the gate — `expiredFullLoad: false`). So the router listens
for the same two
signals luci-base acts on — a `403` with `X-LuCI-Login-Required: yes` on any
`L.Request`, and the `session.access` probe luci-base fires after a
`-32002` coming back denied or errored — and from then on intercepts nothing:
the next click is a full load, which the dispatcher turns into the login
page. A denied call on any other object is an ACL matter and is ignored.
Nothing is reset: the flag dies with the document, as the session did. The
same flag keeps the visibility gate (below) from restarting a poll the
expiry stopped.
## Hidden tabs
luci-base keeps polling in a background tab. The router stops `Poll` on
`visibilitychange` → hidden when it was active and starts it again on
return — unless the user had paused it, or the session died meanwhile. On
a weak router that is RPC work nobody is looking at.
## The poison gate
A `<style>`/`<link rel=stylesheet>` a view writes into `<head>` dies with
the document on a full load and **survives** a same-document swap, painting
every page after it (a shipped file manager hides Save/Reset on every config
page with one unlayered `!important` rule). Removing it is not an option: a
library that imports CSS at module eval never runs again, so deletion is
one-way (an editor page came back as a black rectangle two million pixels
tall). Hence a gate, not a sweep: before intercepting, any sheet outside
`#view` that is not one of the theme's own — header.ut marks everything it
renders (`data-luci-shell` on `main.css`, the font, custom and token
`<style>`s; `data-luci-patch` on patches; `data-luci-node-css` on the
menu.d node css) — marks the document **poisoned** and the navigation is a
full load — the fresh document carries no view CSS, so the router resumes
"Own" means _marked_, and the boot snapshot the gate
compares against is filtered by those markers, so a sheet the boot page's own
modules inserted before the router loaded still counts as foreign instead of
being grandfathered in for the rest of the document. Correctness over speed,
never the other way.
An owner-based refinement (stamp each sheet with the inserting module off
the call stack, enable it for pages whose dependency closure holds that
module, `disabled` for the rest) was built, verified on the device and
**removed again**: on this device one view page inserts its own CSS, the
saving is one reload when leaving it, and the price was three monkeypatches
plus an inline template script whose failure mode — a page silently missing
a shared library's CSS — is worse than the reload it avoids. Revisit only
with a real corpus of self-styling view pages.
## Module prewarm on hover
Entering (`pointerover`/`focusin`/`pointerdown`) a link to a serviceable
node `fetch()`es its view module with `priority: 'low'` — not `require()`,
which would render it. The URL is built byte-for-byte as `LuCI.require()`
builds it (`<base_url>/<name with . → />.js?v=<resource_version>`) or it
misses the HTTP cache. The walk is transitive: the fetched body is scanned
for its leading `'require x'` string literals with a regex that is **not**
line-anchored (shipped files are minified onto one line), and dotted names
are warmed the same way; dotless names are either luci-base's file-less
built-ins (`view`, `baseclass`, `dom`, `poll`, `request`, `session`) or flat
libraries the chrome has already loaded, so they are declined outright.
Deduplicated per class name; stops once a navigation to that link has
committed. Cold navigations are the only place this shows; warm ones are
already 0-byte cache hits.
## What is deliberately not done
- **No history-API path.** See "Kernel".
- **No document prefetch.** A hover prefetch of a document the router will
never load is pure router CPU; the theme ships no speculation rules.
- **No `unload`/`beforeunload`**, ever (bfcache).
- **No cancellation of in-flight XHR** — there is no handle to cancel with
(see step 1); the
generation gate makes it a waste, not a bug. Upstream-only.
- **No sweeping of a view's global listeners or timeouts** — one-way
deletions of module-eval registrations. If a per-render offender ever
appears, the answer is a targeted teardown, not a global hook.
- **`ui.changes.confirm/revert` and `awaitReconnect`** keep their hard
`window.location` writes — a rollback/reboot boundary _should_ be a fresh
document.
## Verification matrix
- Unit (`.dev/tests/router.test.js`): resolver against a fixture tree
(alias chain, nested firstchild, weights, ineligible, unsatisfied,
wildcard args, cycle); URL → segments; patch prefix matching; pragma scan
on a minified head; readonly folding; expiry signals; the same-URL reload
rule; node css of the resolved leaf; the contract check.
- Device (the aurora repo's `bench-router.mjs`, CDP — it drives the theme
through the browser and needs only its selectors adapted; not yet
re-run against this port):
1. full walk of every clickable node, each compared
against a real full load of the same URL — `data-page`,
`dispatchpath`, URL, title, tab count, footer presence, console clean;
2. click → view painted, median of N, router vs full load, warm and cold;
3. soak: 60 navigations over 12 pages, heap / DOM nodes / listeners /
poll queue length flat after the first pass;
4. back/forward chain through alias and firstchild URLs — no reload;
5. poison gate: a foreign `<style>` in `<head>` makes the next
navigation a full load, the one after is a same-document swap again;
5b. sheets: the same, on every walked view page that really inserts its
own sheets (found on the walk) instead of an injected one — reached
same-document and landed on directly (its modules insert before the
router boots), leaving is a full load either way;
5c. hygiene: no progress bar left in the DOM after a swap, live region
present and carrying the title, a hidden tab stops polling and a
visible one resumes it;
6. nodecss: a page whose menu.d node declares `css` — link enabled on
arrival, disabled after leaving, re-enabled without a duplicate on
return (skipped when no installed node declares one);
7. expiry (last, destroys the session): logout fetched from inside the
document, one failing RPC → luci-base's modal and `Poll.stop()`; the
next navigation is a full load landing on the login form.
The walk also compares `nodespec.readonly`, `L.hasViewPermission()`, the
set of enabled node-css links and the live-region text against the full
load, and reports which pages carry sheets that are not the theme's.
- Run the unit tests with `pnpm test` from `.dev/`.
+17
View File
@@ -0,0 +1,17 @@
# Sidebar & menu
- `header.ut`: near-minimal shell — empty `#sidebar` plus a parser-blocking inline script right after it that replays the sidebar cache (below) before first paint; sidebar chrome + nav are otherwise built client-side in `menu-shadcn.js`.
- `sidebar-shadcn.js`: state machine for theme (light/dark/device), sidebar collapse/expand, accordion, and mobile drawer — exposed as `window.ShadcnSidebar` after the `shadcn-sidebar-ready` event fires.
- `menu-shadcn.js`: resolves the `admin` branch of `ui.menu.load()`, then renders a two-level sidebar; `ICON_MAP` maps a LuCI menu node's `name` to `/shadcn/icons/*.svg`; deeper levels render as `#tabmenu`. `syncRoute()` re-marks the active sidebar item (same longest-link-prefix rule as the header.ut replay), breadcrumb and tabs from `L.env` after a [router](router.md) swap without rebuilding the sidebar; `closeSurfaces()` closes palette/drawer/popover.
## Sidebar cache (anti-flash)
`menu-shadcn.js` snapshots `#sidebar.innerHTML` + scroll position into `sessionStorage['shadcn.sidebar.cache']` (`{v, lang, html, scroll}`) after render and on `pagehide`. The `header.ut` inline script replays it pre-paint on the next navigation, recomputes the active highlight for the current URL (longest link-path prefix — keep in sync with menu-shadcn's dispatchpath matching), restores accordion/scroll state, and sets `data-shadcn-built` / `data-shadcn-restored` on `#sidebar`.
When restored, `renderSidebarChrome` only re-syncs the hostname, and `renderSidebarNav` preserves accordion/scroll across its authoritative rebuild.
Restored HTML loses inline JS handlers (`innerHTML` serialization), so anything that must work before the re-render needs a delegated listener — e.g. the logout click in `header.ut`, which clears the cache and sets `window.shadcnSuppressSidebarCache` so the `pagehide` re-cache stays suppressed.
**Bump `v` whenever the sidebar markup changes shape.**
Cross-document `@view-transition` rules live in `components/_view-transitions.css` (only `#sidebar` gets its own snapshot group — the topbar must not, or its top-layer snapshot escapes `.content-card`'s rounded-corner clipping during transitions); they assume the cache keeps the sidebar's first frame populated.
@@ -0,0 +1,10 @@
// Theme identity read by the Vite build (vite.config.ts) and the devkit dev
// layer / bins (@eamonxg/luci-theme-devkit). Everything theme-specific lives
// here; the machinery is generic.
export default {
name: "shadcn", // media dir (/luci-static/shadcn) + ucode theme dir
css: ["main", "login"], // src/media/<e>.css → /luci-static/shadcn/<e>.css
resources: ["menu-shadcn", "sidebar-shadcn"], // served at /luci-static/resources/<m>.js in dev
assets: { dir: "public/shadcn", only: /^icons\/[^/]+\.svg$/ }, // only icons served in dev
patchAliases: {},
};
+39
View File
@@ -0,0 +1,39 @@
{
"name": "luci-theme-shadcn",
"description": "Modern OpenWrt LuCI theme with sidebar layout built with Vite and TailwindCSS",
"type": "module",
"scripts": {
"dev": "vite",
"test": "node --test tests/*.test.js",
"setup:router": "luci-theme-setup",
"build": "pnpm clean && pnpm gen:tokens && pnpm check:contrast && vite build && pnpm check:colors",
"check:colors": "node scripts/check-built-css.js",
"check:contrast": "node scripts/check-token-contrast.js",
"gen:tokens": "node scripts/gen-tokens.js",
"clean": "luci-theme-clean",
"format": "prettier --config prettier.config.mjs --ignore-path ../.prettierignore --write .. --ignore-unknown",
"format:check": "prettier --config prettier.config.mjs --ignore-path ../.prettierignore --check .. --ignore-unknown",
"lint-staged": "lint-staged --cwd ..",
"prepare": "cd .. && husky"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"devDependencies": {
"@eamonxg/luci-theme-devkit": "^0.3.1",
"@eamonxg/luci-theme-tokens": "^2.0.0",
"@fontsource-variable/inter": "^5.2.8",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.9.1",
"colorjs.io": "^0.6.1",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^4.3.0",
"terser": "^5.47.1",
"tw-animate-css": "^1.4.0",
"vite": "^8.0.13"
},
"packageManager": "pnpm@10.33.2"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
import * as tailwindcss from "prettier-plugin-tailwindcss";
/** @type {import("prettier").Config} */
export default {
useTabs: false,
tabWidth: 2,
printWidth: 80,
singleQuote: false,
trailingComma: "all",
semi: true,
arrowParens: "always",
plugins: [tailwindcss],
};
@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-activity"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
</svg>

After

Width:  |  Height:  |  Size: 425 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-chart-bar"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="M7 16h8" />
<path d="M7 11h12" />
<path d="M7 6h3" />
</svg>

After

Width:  |  Height:  |  Size: 397 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-chevron-down"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-chevron-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-container"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z" />
<path d="M10 21.9V14L2.1 9.1" />
<path d="m10 14 11.9-6.9" />
<path d="M14 19.8v-8.1" />
<path d="M18 17.5V9.4" />
</svg>

After

Width:  |  Height:  |  Size: 586 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/></svg>

After

Width:  |  Height:  |  Size: 437 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 275 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-gauge"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12 14 4-4" />
<path d="M3.34 19a10 10 0 1 1 17.32 0" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-hard-drive"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 16h.01" />
<path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
<path d="M21.946 12.013H2.054" />
<path d="M6 16h.01" />
</svg>

After

Width:  |  Height:  |  Size: 540 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-layers"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 587 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-layout-dashboard"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="7" height="9" x="3" y="3" rx="1" />
<rect width="7" height="5" x="14" y="3" rx="1" />
<rect width="7" height="9" x="14" y="12" rx="1" />
<rect width="7" height="5" x="3" y="16" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 503 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-lock"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>

After

Width:  |  Height:  |  Size: 384 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-log-out"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
</svg>

After

Width:  |  Height:  |  Size: 395 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-menu"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 5h16" />
<path d="M4 12h16" />
<path d="M4 19h16" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-monitor"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>

After

Width:  |  Height:  |  Size: 424 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-moon"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" />
</svg>

After

Width:  |  Height:  |  Size: 409 B

@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-network"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="16" y="16" width="6" height="6" rx="1" />
<rect x="2" y="16" width="6" height="6" rx="1" />
<rect x="9" y="2" width="6" height="6" rx="1" />
<path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" />
<path d="M12 12V8" />
</svg>

After

Width:  |  Height:  |  Size: 523 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-panel-left-close"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
<path d="m16 15-3-3 3-3" />
</svg>

After

Width:  |  Height:  |  Size: 401 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-panel-left-open"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
<path d="m14 9 3 3-3 3" />
</svg>

After

Width:  |  Height:  |  Size: 399 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-phone"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384" />
</svg>

After

Width:  |  Height:  |  Size: 513 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-server"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 490 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-settings"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 665 B

@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-shield"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
</svg>

After

Width:  |  Height:  |  Size: 463 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-sliders-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 5H3" />
<path d="M12 19H3" />
<path d="M14 3v4" />
<path d="M16 17v4" />
<path d="M21 12h-9" />
<path d="M21 19h-5" />
<path d="M21 5h-7" />
<path d="M8 10v4" />
<path d="M8 12H3" />
</svg>

After

Width:  |  Height:  |  Size: 511 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-sun"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>

After

Width:  |  Height:  |  Size: 559 B

@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-user"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<rect width="1000" height="1000" rx="250" fill="#000"/>
<path d="M244 625 590 279M461 638 677 420M669 642l90-90" fill="none" stroke="#fff" stroke-width="40" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<rect width="1000" height="1000" rx="250" fill="#000"/>
<path d="M244 625 590 279M461 638 677 420M669 642l90-90" fill="none" stroke="#fff" stroke-width="40" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

@@ -0,0 +1,21 @@
{
"name": "Shadcn",
"short_name": "Shadcn",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,40 @@
/**
* Copyright (C) 2025 eamonxg <eamonxiong@gmail.com>
* Licensed under the Apache License, Version 2.0.
*/
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
const outputDir = resolve(
import.meta.dirname,
"../../htdocs/luci-static/shadcn",
);
const files = ["main.css", "login.css"];
const forbidden = [
{
label: "token-based color-mix()",
pattern: /color-mix\([^)]*var\(--/g,
},
{
label: "relative oklch()",
pattern: /oklch\(from/g,
},
];
const failures = [];
for (const file of files) {
const css = await readFile(resolve(outputDir, file), "utf8");
for (const { label, pattern } of forbidden) {
const count = css.match(pattern)?.length ?? 0;
if (count > 0) failures.push(`${file}: ${label} × ${count}`);
}
}
if (failures.length > 0) {
console.error(`Runtime token color functions found:\n${failures.join("\n")}`);
process.exitCode = 1;
} else {
console.log("Built CSS contains no runtime token color functions.");
}
@@ -0,0 +1,41 @@
/**
* Copyright (C) 2025 eamonxg <eamonxiong@gmail.com>
* Licensed under the Apache License, Version 2.0.
*/
import Color from "colorjs.io";
import { resolveMode } from "../tokens/resolve.js";
const MIN_TEXT_CONTRAST = 4.5;
const checks = [
["muted_foreground", "background"],
["sidebar_muted", "sidebar_bg"],
];
const failures = [];
for (const mode of ["light", "dark"]) {
const tokens = resolveMode(mode);
for (const [foreground, background] of checks) {
const ratio = Color.contrast(
new Color(tokens[background]),
new Color(tokens[foreground]),
"WCAG21",
);
if (ratio < MIN_TEXT_CONTRAST) {
failures.push(
`${mode}: ${foreground} on ${background} is ${ratio.toFixed(2)}:1`,
);
}
}
}
if (failures.length > 0) {
console.error(
`Text token contrast must be at least ${MIN_TEXT_CONTRAST}:1:\n${failures.join("\n")}`,
);
process.exitCode = 1;
} else {
console.log("Text token contrast meets WCAG AA.");
}
@@ -0,0 +1,98 @@
/**
* Copyright (C) 2025 eamonxg <eamonxiong@gmail.com>
* Licensed under the Apache License, Version 2.0.
*/
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { alpha, toOklch } from "@eamonxg/luci-theme-tokens/engine";
import { block, themeColors } from "@eamonxg/luci-theme-tokens/emit";
import { resolveMode } from "../tokens/resolve.js";
import { ALPHAS } from "../tokens/spec.js";
const snake = (s) => s.replace(/-/g, "_");
const withAlpha = (oklchStr, pct) => toOklch(alpha(oklchStr, pct / 100));
function alphaTokens(resolved) {
const out = {};
for (const [base, list] of Object.entries(ALPHAS)) {
const val = resolved[snake(base)];
if (val === undefined) throw new Error(`ALPHAS base not resolved: ${base}`);
for (const a of list) out[`${base}-a${a}`] = withAlpha(val, a);
}
return out;
}
const light = resolveMode("light");
const dark = resolveMode("dark");
const lightA = alphaTokens(light);
const darkA = alphaTokens(dark);
const STRUCTURE = `
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
--radius-base: 0.5rem;
/* Elevation. Emitted on :root (not just inlined into the shadow-* utilities)
so on-demand patches/*.css -- standalone entries that carry no Tailwind
helper output -- can reach it as var(--shadow-sm). */
--shadow-sm:
0 1px 3px 0 oklch(0% 0 0 / 0.1), 0 1px 2px -1px oklch(0% 0 0 / 0.1);
`;
const themeColorsCss = themeColors([
...Object.keys(light),
...Object.keys(lightA),
]);
// Add aliases for backward compatibility
const aliases = [
` --color-sidebar-fg: var(--sidebar-foreground);`,
` --color-terminal-fg: var(--terminal-foreground);`,
].join("\n");
const THEME = `@theme inline {
${themeColorsCss}
${aliases}
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--shadow-sm: var(--shadow-sm);
/* Radius ladder driven by a single knob; at 0.5rem it matches the
Tailwind default scale (rounded-lg = 0.5rem). Tune --radius-base
to scale all corners for custom-radius support. */
--radius-sm: calc(var(--radius-base) * 0.5);
--radius: calc(var(--radius-base) * 0.5);
--radius-md: calc(var(--radius-base) * 0.75);
--radius-lg: var(--radius-base);
--radius-xl: calc(var(--radius-base) * 1.5);
}
`;
const HEADER = `/**
* luci-theme-shadcn: design tokens -- GENERATED, DO NOT EDIT.
* Run \`pnpm gen:tokens\`. Source: tokens/defaults.js + tokens/spec.js
* All color values are flat oklch() literals; no dynamic color functions.
* Dark mode overrides must stay after light mode block.
*/
`;
const css =
HEADER +
"\n" +
block(":root", light, lightA) +
STRUCTURE +
"}\n\n" +
block('[data-darkmode="true"]', dark, darkA) +
"}\n\n" +
THEME;
await writeFile(
resolve(import.meta.dirname, "../src/media/_tokens.css"),
css,
"utf-8",
);
console.log("gen-tokens: wrote src/media/_tokens.css");
@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-activity"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
</svg>

After

Width:  |  Height:  |  Size: 425 B

Some files were not shown because too many files have changed in this diff Show More