Compare commits

...
7 Commits
Author SHA1 Message Date
vlad 4aa02b6ba4 fix(setup): scope strategy-group GC to wizard-created balancers only
The GC that removes orphaned <sub>-<strategy> balancers matched by name
pattern, so a hand-made group (e.g. a manually created 'qomar-failover' not
yet referenced by a rule) could be deleted on the next wizard commit. Mark
wizard-created strategy groups with an _auto=1 option and GC only those —
never a group the user built by hand. xrayctl ignores the unknown option.
2026-07-13 19:49:39 +03:00
vlad 9582105ab9 feat(luci): bilingual Help page + a plain-language monkey guide
- help.js: a RU/EN reference (toggle persisted in localStorage) explaining
  every concept — wizard, subscriptions, groups, per-device modes, the global
  IP list, Advanced, and how a client actually gets proxied (gateway).
- banana.js: 'Интернет для маленьких детей и успешных обезьян' — the same map
  told through bananas and monkeys, with the real term in brackets each time.
Both are static E()-only views (no rpc/uci, no XSS surface). Added as the last
two menu tabs (Help, then 🍌 to its right).
2026-07-13 16:30:20 +03:00
vlad f39d84c4da feat(luci): guided Setup wizard (Zzerg0Pack)
A first-run 'Setup' tab that collapses the real minimal path into three steps
on top of the existing /etc/config/shater model (schema unchanged):
  1. Servers  — add/enable subscriptions, test, one-click Create route(s).
  2. Devices  — per-client policy modal (all / only-lists / except / block),
     a LAN client picker, and a whole-file global IP list (Browse & upload).
  3. Turn on  — one switch: enable engine + LAN interception, apply, show exit IP.

Written with the review's frontend fixes baked in from the start:
  - discoverClients excludes the router's own IP and the upstream gateway
    (routing either through the VPN loops and kills connectivity);
  - per-client edit prefills the server from the group/node-bearing rule, not
    the trailing direct rule (only-mode no longer loses its pinned node);
  - global-list upload never reports success when no route exists yet, and its
    apply errors surface instead of being swallowed as an upload-cancel;
  - garbage-collect orphaned <sub>-<strategy> balancer groups on commit;
  - a reloading guard so the 10s poll never redraws mid uci.unload->load;
  - .catch on every commit(); exit IP kept in state so the poll can't wipe it;
  - dead code removed (toArray, rowsById).

Adds Setup as the first menu tab; grants luci-rpc (client list) + file
read/write on /etc/xray/lists/* (global-list upload) in acl.d.
2026-07-13 16:28:32 +03:00
vlad a9b40ee9ce fix(luci): graceful Test-all failure + geodata Update button
- nodes.js: "Test all nodes" probes every node in a single ubus call; on a
  large subscription that exceeds the rpcd call timeout and left the spinner
  hung on an unhandled rejection. Add a .catch that reports it and points to
  the per-node Test / background probe.
- lists.js: add an "Update" button for geodata. GeodataDownload is a no-op
  when already present, so refreshing required a manual Remove first; Update =
  remove + re-download, enabled only when installed.

Poll intervals (overview 5s / live 3s) left as-is: the new rpcd TTL cache
makes those polls cache hits, so they no longer spawn xrayctl per tick.
2026-07-13 16:10:11 +03:00
vlad df6bdaa616 fix(rpcd): POSIX-escape single quotes in shq() instead of stripping them
shq() stripped every ' before wrapping, so a value containing a single quote
(e.g. a node named "it's fast") was silently mangled and every subsequent
enable/disable/delete/test/group targeting that name failed. Escape ' as the
standard '\'' sequence so values survive intact. Injection was already closed
by the single-quote wrapping; this is purely a data-loss fix.
2026-07-13 16:03:16 +03:00
vlad 3ba36a7bd5 perf(rpcd): read-through TTL cache for status/nodes/stats/conns
shater.uc runs inside rpcd's single-threaded loop and each read popen()'d
xrayctl synchronously (2-4s), blocking the whole ubus bus on every LuCI
dashboard poll (Overview alone fires 3 calls/5s). Cache the heavy reads on
tmpfs as a small {_ts,d} wrapper with a short TTL; invalidate after any
state-changing action so the dashboard never shows stale post-apply data.

Measured on the 570-node testbed: nodes 3.31s (miss) -> 0.09s (hit); status
~1s -> 0.00s. The bus stays responsive between refreshes.
2026-07-13 16:00:50 +03:00
vlad 55498882f7 fix(xrayctl): write run.json/last-good 0600 (they hold node credentials)
run.json and last-good.json are the rendered xray config with every node
secret expanded (VLESS/VMess UUIDs, SS/Trojan passwords, WG keys). They were
created 0644 in /etc/xray, so any local user could read all proxy credentials.
Harden writeBytesAtomic and the rollback/last-good write paths to 0600; add a
regression test.

Note: sysctl scoping (route_localnet/accept_local on lo only) and the
crash-loop teardown (infinite respawn + cron watchdog) reviewed in this pass
were already correct on main; only the file mode remained.
2026-07-13 15:54:02 +03:00
10 changed files with 1320 additions and 12 deletions
@@ -0,0 +1,97 @@
'use strict';
'require view';
// "Internet for small kids and successful monkeys" — a jargon-free guide that
// explains every part of the service through bananas and monkeys. Read-only.
var CSS = '' +
'.mk-wrap{max-width:820px;line-height:1.55}' +
'.mk-lead{font-size:1.05rem;opacity:.85;margin:.2em 0 1.2em}' +
'.mk-card{border:1px solid rgba(214,178,20,.35);border-left:4px solid #e3b505;border-radius:12px;' +
'padding:.7em 1em;margin:.7em 0;background:rgba(227,181,5,.06)}' +
'.mk-card h3{margin:.1em 0 .35em;font-size:1.1rem}' +
'.mk-card p{margin:.35em 0}' +
'.mk-card ul{margin:.3em 0 .3em 1.1em;padding:0}' +
'.mk-card li{margin:.25em 0}' +
'.mk-real{opacity:.6;font-size:.85em}' +
'.mk-fin{font-size:1.15rem;font-weight:650;text-align:center;margin:1.2em 0;padding:.6em;' +
'border-radius:12px;background:rgba(227,181,5,.12)}';
// each chapter: emoji + title, then lines. "- " => bullet.
var CH = [
{ h: '🐒 Ты — обезьяна', p: [
"Ты обезьяна. Тебе нужны бананы 🍌 — это сайты, видосики, игры, музыка.",
"Обычно ты тянешь лапу и берёшь банан. Но часть бананов висит за забором 🔒 — злой сторож (провайдер и блокировки) не пускает.",
"Shater — это твой личный тайный подкоп под забор."
] },
{ h: '🌴 Волшебные лианы', real: 'по-умному: «серверы» / «ноды»', p: [
"Чтобы достать банан из-за забора, ты не лезешь через него в лоб (там сторож). Ты хватаешься за волшебную лиану 🌴 — и оп! — ты уже в других джунглях (другой стране), где этот банан висит свободно.",
"Лиан много и они в разных джунглях: 🇳🇱 🇩🇪 🇬🇧. Одна лиана = один сервер."
] },
{ h: '🗺️ Карта лиан', real: 'по-умному: «подписка»', p: [
"Сам ты лиану не сплетёшь. Её даёт банан-дилер: выдаёт карту 🗺️ со списком лиан — это и есть подписка.",
"Лианы иногда подгнивают и меняются, поэтому карта обновляется сама.",
"Где: вкладка Setup → «Get servers». Добавил карту → «Update now» → «Create route»."
] },
{ h: '🐒🐒 Стая берёт лучшую лиану', real: 'по-умному: «группа» / «балансировка»', p: [
"Хвататься за первую попавшуюся лиану — можно улететь в болото. Умные обезьяны сбиваются в стаю и всегда хватают самую быструю и живую лиану. Это группа-балансировщик.",
"Правило джунглей: одна стая = одна карта. Смешать лианы из двух разных карт в одну стаю нельзя."
] },
{ h: '🎯 Каждой обезьяне — свой приказ', real: 'по-умному: «политика на устройство»', p: [
"Ты вожак. У тебя подопечные обезьяны — телефон, комп, телек. Каждой даёшь свой приказ (Setup → «Add device»):",
"- 🍌 Всё через лиану — вся еда обезьяны идёт тайным ходом.",
"- 🎯 Только эти бананы — тайным ходом лезем лишь за избранными (по списку), остальное берём обычно.",
"- 🚫 Всё кроме этих — наоборот: по списку берём обычно, остальное тайным ходом.",
"- ⛔ Запретить — этой обезьяне такие бананы вообще не давать (блок)."
] },
{ h: '📋 Общий список запретных рощ', real: 'по-умному: «глобальный IP-лист»', p: [
"Есть рощи 🌴🌴🌴, которые ВСЕ обезьяны должны брать только через лиану. Чтобы не повторять каждой — вешаешь один общий список (карточка «Routing list» на Setup).",
"Кнопкой «Browse & upload» просто закидываешь файлик со списком рощ — и он действует сразу для всех. Обновился список — перезакинул файлик."
] },
{ h: '🚀 Врубить джунгли', real: 'по-умному: шаг «Turn on»', p: [
"Пока рубильник выключен — никаких подкопов, приказы висят без дела.",
"Шаг 3 «Turn on» — один тумблер: открывает тайный ход и показывает, из каких джунглей ты теперь таскаешь бананы (твой новый внешний IP)."
] },
{ h: '🌳 Как обезьяна вообще попадает к подкопу', real: 'по-умному: «шлюз» (gateway)', p: [
"САМОЕ важное, тут все спотыкаются! Тайный ход прорыт под ТВОИМ деревом (роутер Shater).",
"Обезьяна пойдёт через подкоп, только если она ходит через твоё дерево. Пропиши устройству «шлюз» = адрес твоего дерева-роутера. Иначе обезьяна гуляет мимо и подкопа даже не видит."
] },
{ h: '🍌 Проверка: правда ли своровал?', p: [
"С устройства скажи волшебное заклинание:",
"- curl http://api.ipify.org",
"Покажет, из каких ты джунглей: чужие 🌍 — подкоп работает; родные — идёшь поверху, мимо лианы."
] },
{ h: '🛡️ Правила безопасности бананов', real: 'по мелочи, но полезно', p: [
"- Kill-switch (в Settings): closed — лиана оборвалась → лучше посидеть голодным, чем спалиться и лезть поверху. open — оборвалась → лезь обычным путём.",
"- DNS — чтобы сторож не подсмотрел, за каким бананом ты тянешься (защита от утечек).",
"- GeoIP — толстая книга «какой банан в какой стране растёт», чтобы приказы вроде «всю рекламу в топку» и «российское — вот так» срабатывали по-умному."
] }
];
function chapter(c) {
var kids = [ E('h3', {}, [ c.h + (c.real ? ' ' : ''), c.real ? E('span', { 'class': 'mk-real' }, [ '(' + c.real + ')' ]) : '' ]) ];
var ul = null;
c.p.forEach(function(line) {
if (line.indexOf('- ') === 0) {
if (!ul) { ul = E('ul', {}); kids.push(ul); }
ul.appendChild(E('li', {}, [ line.slice(2) ]));
} else { ul = null; kids.push(E('p', {}, [ line ])); }
});
return E('div', { 'class': 'mk-card' }, kids);
}
return view.extend({
handleSaveApply: null, handleSave: null, handleReset: null,
render: function() {
if (!document.getElementById('mk-css'))
document.head.appendChild(E('style', { 'id': 'mk-css' }, CSS));
return E('div', { 'class': 'mk-wrap' }, [
E('h2', {}, [ '🍌 Интернет для маленьких детей и успешных обезьян' ]),
E('div', { 'class': 'mk-lead' }, [ 'Гайд без единого умного слова. Ну почти — умные слова спрятаны в скобках, вдруг захочешь повыпендриваться.' ])
].concat(CH.map(chapter)).concat([
E('div', { 'class': 'mk-fin' }, [ 'Всё! Теперь ты не просто обезьяна, а УСПЕШНАЯ обезьяна с тайным ходом к любым бананам. 🐒🍌🎉' ])
]));
}
});
@@ -0,0 +1,179 @@
'use strict';
'require view';
'require dom';
// Bilingual help/reference page. A RU/EN toggle switches the displayed language;
// the choice is remembered in localStorage. Read-only, no config access.
var LANG_KEY = 'shater-help-lang';
// Each section carries an en/ru title and an en/ru body (array of lines).
// A line beginning with "- " renders as a bullet.
var SECTIONS = [
{
title: { ru: "Что такое Shater", en: "What Shater is" },
body: {
ru: [
"Shater — прозрачный прокси на роутере: он перехватывает трафик клиентов и заворачивает нужное через VPN (движок xray).",
"Всё основное настраивается на вкладке Setup — там мастер из трёх шагов. Тонкая ручная настройка — на вкладке Advanced."
],
en: [
"Shater is a transparent proxy on your router: it intercepts client traffic and sends the parts you choose through a VPN (the xray engine).",
"Everyday configuration lives on the Setup tab, which is a 3-step wizard. Fine-grained manual settings live on the Advanced tab."
]
}
},
{
title: { ru: "Мастер настройки (Setup)", en: "The Setup wizard" },
body: {
ru: [
"- Шаг 1 «Серверы»: добавь подписку (можно несколько), обнови её и нажми «Создать маршрут». Каждая подписка становится своим пулом серверов с автобалансировкой.",
"- Шаг 2 «Устройства»: кнопкой «Add device» выбираешь машину по IP и задаёшь, как её заворачивать (режимы — ниже).",
"- Шаг 3 «Включить»: один тумблер поднимает движок и перехват, затем показывает внешний IP."
],
en: [
"- Step 1 (Servers): add a subscription (one or more), refresh it, and press Create route. Each subscription becomes its own auto-balanced pool of servers.",
"- Step 2 (Devices): use Add device to pick a machine by IP and choose how its traffic is routed (modes below).",
"- Step 3 (Turn on): a single switch starts the engine and interception, then shows your exit IP."
]
}
},
{
title: { ru: "Подписки и ноды", en: "Subscriptions and nodes" },
body: {
ru: [
"Подписка — ссылка провайдера со списком серверов (нод). Ноды подтягиваются автоматически по интервалу обновления.",
"Чекбокс слева от подписки включает/выключает её. «Проверить все» замеряет живость нод.",
"Нюанс: живость по TCP не всегда значит, что нода реально гонит данные — старые протоколы (ss, grpc) часто отвечают на коннект, но рвут туннель. Стабильнее обычно reality/vless."
],
en: [
"A subscription is a provider link with a list of servers (nodes). Nodes are fetched automatically at the update interval.",
"The checkbox next to a subscription enables/disables it. Test all servers measures node liveness.",
"Note: TCP liveness does not guarantee a node actually carries data — older protocols (ss, grpc) often accept the connection but drop the tunnel. reality/vless nodes are usually more reliable."
]
}
},
{
title: { ru: "Группы (балансировка)", en: "Groups (load balancing)" },
body: {
ru: [
"Группа — балансировщик поверх набора нод. «Создать маршрут» делает по одной группе на подписку (стратегия по умолчанию — самый быстрый по пингу).",
"Балансировать между нодами РАЗНЫХ подписок в одном пуле нельзя: группа привязана к одной подписке. Между подписками — только переключением цели, либо последовательно через цепочки (Advanced → Chains)."
],
en: [
"A group is a load balancer over a set of nodes. Create route makes one group per subscription (default strategy: fastest by ping).",
"You cannot balance across nodes of DIFFERENT subscriptions in one pool: a group is tied to a single subscription. To combine subscriptions, switch the target manually, or go through them sequentially with chains (Advanced → Chains)."
]
}
},
{
title: { ru: "Политика на устройство", en: "Per-device policy" },
body: {
ru: [
"Шаг 2, кнопка Add device. Выбираешь IP устройства и режим:",
"- Everything via VPN — весь трафик устройства идёт через VPN.",
"- Only these lists — через VPN только сайты/сети из выбранных списков, остальное напрямую.",
"- Everything EXCEPT lists — наоборот: списки напрямую, всё остальное через VPN.",
"- Block — блокировать выбранные сайты для этого устройства.",
"Server: Auto — самый быстрый сервер; Advanced — конкретная нода или другая стратегия."
],
en: [
"Step 2, Add device. Pick the device IP and a mode:",
"- Everything via VPN — all of the device traffic goes through the VPN.",
"- Only these lists — only the sites/networks in the chosen lists go through the VPN, the rest stays direct.",
"- Everything EXCEPT lists — the opposite: the lists go direct, everything else through the VPN.",
"- Block — block the chosen sites for this device.",
"Server: Auto — the fastest server; Advanced — a specific node or a different strategy."
]
}
},
{
title: { ru: "Списки и глобальный IP-лист", en: "Lists and the global IP list" },
body: {
ru: [
"Списки — наборы доменов или IP-сетей. В режимах «Only/Except» выбираются галочками: готовые пакеты, свой список (вставкой) или список по URL.",
"Глобальный IP-лист (карточка «Routing list» на Setup) — один файл, общий для ВСЕХ клиентов: трафик на эти IP идёт через VPN, всё остальное напрямую. Обновляется целиком кнопкой Browse & upload — просто перезалей .txt."
],
en: [
"Lists are sets of domains or IP networks. In the Only/Except modes you select them with checkboxes: ready-made packs, your own pasted list, or a list from a URL.",
"The global IP list (the Routing list card on Setup) is a single file shared by ALL clients: traffic to these IPs goes through the VPN, everything else stays direct. Update it wholesale with Browse & upload — just re-upload the .txt."
]
}
},
{
title: { ru: "Продвинутое (вкладка Advanced)", en: "The Advanced tab" },
body: {
ru: [
"- Chains — многохоп: трафик идёт последовательно через несколько групп/нод (A→B), а не балансируется между ними.",
"- Egress — точка выхода: интерфейс/туннель, прокси, direct или block.",
"- DNS — свои резолверы и правила, защита от DNS-утечек.",
"- Rules / Lists — полное ручное редактирование правил и списков, если возможностей мастера мало.",
"- Kill-switch (Settings): closed — если туннель недоступен, блокировать; open — пускать напрямую."
],
en: [
"- Chains — multi-hop: traffic goes sequentially through several groups/nodes (A→B) rather than being balanced across them.",
"- Egress — the exit point: an interface/tunnel, a proxy, direct, or block.",
"- DNS — custom resolvers and rules, plus DNS-leak protection.",
"- Rules / Lists — full manual editing of every rule and list when the wizard is not enough.",
"- Kill-switch (Settings): closed — block when no tunnel is available; open — fall through to direct."
]
}
},
{
title: { ru: "Как клиент реально попадает в прокси", en: "How a client actually gets proxied" },
body: {
ru: [
"Роутер перехватывает только тех клиентов, у кого шлюз (default gateway) указывает на этот роутер. Пропиши на устройстве шлюзом IP роутера с Shater — тогда его трафик пойдёт через правила Shater.",
"Проверка выхода: с клиента выполни curl http://api.ipify.org — покажет внешний IP (иностранный, если ушёл через VPN; свой обычный — если напрямую)."
],
en: [
"The router only intercepts clients whose default gateway points at this router. Set the device gateway to the IP of the Shater router, and its traffic will flow through the Shater rules.",
"Check the exit: from the client run curl http://api.ipify.org — it shows the exit IP (a foreign one if it went through the VPN; your normal one if direct)."
]
}
}
];
function renderSection(sec, lang) {
var kids = [ E('h3', { 'style': 'margin:1em 0 .2em;font-size:1.05rem' }, [ sec.title[lang] ]) ];
var bullets = null;
(sec.body[lang] || []).forEach(function(line) {
if (line.indexOf('- ') === 0) {
if (!bullets) { bullets = E('ul', { 'style': 'margin:.2em 0 .4em 1.2em' }); kids.push(bullets); }
bullets.appendChild(E('li', { 'style': 'margin:.2em 0;line-height:1.5' }, [ line.slice(2) ]));
} else {
bullets = null;
kids.push(E('p', { 'style': 'margin:.3em 0;line-height:1.55' }, [ line ]));
}
});
return E('div', {}, kids);
}
return view.extend({
handleSaveApply: null, handleSave: null, handleReset: null,
render: function() {
var lang = 'ru';
try { lang = localStorage.getItem(LANG_KEY) || 'ru'; } catch (e) {}
var box = E('div', {});
function draw() { dom.content(box, SECTIONS.map(function(s) { return renderSection(s, lang); })); }
var ruBtn, enBtn;
function sync() {
ruBtn.className = 'btn cbi-button cbi-button-' + (lang === 'ru' ? 'action important' : 'neutral');
enBtn.className = 'btn cbi-button cbi-button-' + (lang === 'en' ? 'action important' : 'neutral');
}
function pick(l) { lang = l; try { localStorage.setItem(LANG_KEY, l); } catch (e) {} draw(); sync(); }
ruBtn = E('button', { 'click': function() { pick('ru'); } }, [ 'Русский' ]);
enBtn = E('button', { 'style': 'margin-left:.3em', 'click': function() { pick('en'); } }, [ 'English' ]);
sync();
draw();
return E('div', { 'style': 'max-width:900px' }, [
E('h2', {}, [ 'Shater — Справка / Help' ]),
E('div', { 'style': 'margin:.3em 0 1em' }, [ ruBtn, enBtn ]),
box
]);
}
});
@@ -123,6 +123,28 @@ return view.extend({
})
}, _('Remove'));
// Update = remove + re-download (GeodataDownload is a no-op when already
// present). Enabled only when installed; geo matchers are briefly skipped
// while it refreshes.
var upBtn = E('button', {
'class': 'btn cbi-button cbi-button-action',
'disabled': present ? null : 'disabled',
'click': ui.createHandlerFn(this, function() {
busy(true, _('Updating geodata … briefly unavailable while it refreshes.'));
return callGeoRemove().then(function() { return callGeoDownload(); }).then(function(r) {
busy(false);
if (r && r.error)
ui.addNotification(null, E('p', {}, _('Geodata update failed:') + ' ' + r.error), 'danger');
else
ui.addNotification(null, E('p', {}, _('Geodata updated.')), 'info');
refresh(r || {});
}).catch(function(e) {
busy(false);
ui.addNotification(null, E('p', {}, _('Geodata update error:') + ' ' + e), 'danger');
});
})
}, _('Update'));
var hint = !present && !canDl
? E('div', { 'class': 'cbi-value-description', 'style': 'color:#c00' },
_('Not enough free space to download the data files. Free some flash first.'))
@@ -133,7 +155,7 @@ return view.extend({
return E('div', {}, [
E('div', { 'style': 'display:flex;align-items:center;gap:.75em;flex-wrap:wrap' }, [
badge, info, dlBtn, rmBtn, msg
badge, info, dlBtn, rmBtn, upBtn, msg
]),
hint
]);
@@ -334,6 +334,10 @@ return view.extend({
var n = mergeProbes(res);
var alive = ((res && res.probe) || []).filter(function(p) { return p.alive; }).length;
ui.addNotification(null, E('p', {}, _('Probed %d nodes — %d reachable').format(n, alive)), 'info');
}).catch(function() {
// Probing every node at once can exceed the rpcd call timeout on a
// large subscription — fail gracefully instead of hanging the spinner.
ui.addNotification(null, E('p', {}, _('Probe timed out — too many nodes to test at once. Use a per-node Test, or let the background probe update liveness.')), 'warning');
});
})
}, [ _('Test all nodes') ]);
@@ -0,0 +1,901 @@
'use strict';
'require view';
'require dom';
'require poll';
'require rpc';
'require ui';
'require uci';
'require fs';
'require network';
// Shater — Setup: a guided, jargon-free first-run wizard on top of the same
// /etc/config/shater model the Advanced pages expose. Three steps:
// 1. Get servers — update the subscription, test nodes, one-click bind a group.
// 2. Devices — a per-client policy modal (full / only-lists / except / block).
// 3. Turn it on — one switch: enable engine + inbound, apply, show exit IP.
// No egress/chain/observatory/fwmark vocabulary appears here on purpose.
var callStatus = rpc.declare({ object: 'xray', method: 'status', expect: { '': {} } });
var callNodes = rpc.declare({ object: 'xray', method: 'nodes', expect: { '': {} } });
var callSubUpdate = rpc.declare({ object: 'xray', method: 'sub_update', params: [ 'name' ], expect: { '': {} } });
var callNodeTest = rpc.declare({ object: 'xray', method: 'node_test', params: [ 'name', 'method', 'url', 'http_method' ], expect: { '': {} } });
var callApply = rpc.declare({ object: 'xray', method: 'apply', expect: { '': {} } });
var callHostHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', expect: { '': {} } });
var callLeases = rpc.declare({ object: 'luci-rpc', method: 'getDHCPLeases', expect: { '': {} } });
var PROBE_URL = 'http://www.gstatic.com/generate_204';
// Whole-file IP list applied to every client (one file, one rule): traffic to any
// address in the file goes through the VPN, everything else stays direct. The section
// names below are reserved and hidden from the per-device list picker.
var GLIST_PATH = '/etc/xray/lists/ipv4.txt';
var GLIST_RULESET = 'iplist';
var GLIST_RULE = 'iplist-vpn';
// Ready-made list packs. Each maps to concrete dst_* contributions; ruleset-backed
// packs are auto-created (uci.add) on first use, then referenced by name.
var PACKS = [
{ key: 'ru', label: _('RU-blocked sites'),
ruleset: { name: 'ru-blocked', type: 'domain', source: 'url',
url: 'https://raw.githubusercontent.com/1andrevich/Re-filter-lists/main/domains_all.lst',
format: 'plain', update_interval: '24h' } },
{ key: 'ads', label: _('Ads / trackers'), dst_domain: 'geosite:category-ads-all' },
{ key: 'private', label: _('Local networks (LAN)'), dst_ip: [ '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16' ] }
];
var STRATEGIES = [
[ 'leastping', _('Fastest (least ping)') ],
[ 'failover', _('Failover (in order)') ],
[ 'roundrobin', _('Round-robin') ],
[ 'random', _('Random') ],
[ 'single', _('Single (pin first alive)') ]
];
// ---- small utils -----------------------------------------------------------
function slug(ip) { return String(ip || '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, ''); }
function isRFC1918(ip) {
if (!/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return false;
if (ip.indexOf('10.') === 0 || ip.indexOf('192.168.') === 0) return true;
var m = ip.match(/^172\.(\d+)\./);
return m && +m[1] >= 16 && +m[1] <= 31;
}
function looksLikeIP(s) { return /^\d+\.\d+\.\d+\.\d+(\/\d+)?$/.test(s) || s.indexOf(':') >= 0; }
function asList(v) { return (v == null) ? [] : (Array.isArray(v) ? v : [ v ]); }
// Run fn() with a temporarily raised rpc timeout (seconds). LuCI reads
// L.env.rpctimeout synchronously when the request is dispatched, so setting it
// right before a slow call and restoring immediately after scopes it to that call.
// Used for probing many nodes at once, which can take far longer than the 20s default.
function withTimeout(secs, fn) {
var prev = L.env.rpctimeout;
L.env.rpctimeout = secs;
try { return fn(); } finally { L.env.rpctimeout = prev; }
}
// ---- CSS (theme-aware, currentColor/rgba) ----------------------------------
var CSS = '' +
'.su-wrap{max-width:920px}' +
'.su-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.6em;margin:.2em 0 1.2em}' +
'.su-card{border:1px solid rgba(128,128,128,.25);border-radius:10px;padding:.6em .8em;background:rgba(128,128,128,.05)}' +
'.su-card .lab{font-size:.68rem;letter-spacing:.06em;text-transform:uppercase;opacity:.6}' +
'.su-card .val{font-size:1.35rem;font-weight:650;margin-top:.1em;font-variant-numeric:tabular-nums}' +
'.su-step{border:1px solid rgba(128,128,128,.25);border-radius:12px;padding:1em 1.1em;margin:.8em 0;background:rgba(128,128,128,.03)}' +
'.su-step.off{opacity:.55}' +
'.su-step h3{margin:0 0 .2em;font-size:1.05rem;display:flex;align-items:center;gap:.5em}' +
'.su-step p.hint{margin:.1em 0 .8em;opacity:.7;font-size:.9rem}' +
'.su-badge{display:inline-flex;align-items:center;justify-content:center;width:1.5em;height:1.5em;border-radius:50%;font-size:.8rem;font-weight:700;color:#fff;flex:0 0 auto}' +
'.su-badge.done{background:#22a06b}.su-badge.todo{background:#9aa0a6}' +
'.su-ok{color:#22a06b}.su-bad{color:#d64545}' +
'.su-row{display:flex;gap:.4em;align-items:center;flex-wrap:wrap;margin:.35em 0}' +
'.su-tbl{width:100%;border-collapse:collapse;margin-top:.4em}' +
'.su-tbl th{text-align:left;font-size:.7rem;text-transform:uppercase;opacity:.55;padding:.3em .5em;border-bottom:1px solid rgba(128,128,128,.25)}' +
'.su-tbl td{padding:.35em .5em;border-bottom:1px solid rgba(128,128,128,.12);vertical-align:middle}' +
'.su-mode{opacity:.75;font-size:.9rem}' +
'.su-modal .cbi-value{display:flex;align-items:flex-start;gap:.6em;margin:.5em 0}' +
'.su-modal label.hd{width:9em;flex:0 0 auto;font-weight:600;padding-top:.2em}' +
'.su-modal .bd{flex:1 1 auto}' +
'.su-lists label{display:block;margin:.15em 0}' +
'.su-sw{font-size:1.4rem;font-weight:700}';
function injectCSS() {
if (document.getElementById('su-css')) return;
document.head.appendChild(E('style', { 'id': 'su-css' }, CSS));
}
function badge(done) { return E('span', { 'class': 'su-badge ' + (done ? 'done' : 'todo') }, done ? '✓' : '○'); }
return view.extend({
handleSaveApply: null, handleSave: null, handleReset: null,
load: function() {
return Promise.all([
uci.load('shater'),
L.resolveDefault(callStatus(), {}),
L.resolveDefault(callNodes(), {}),
L.resolveDefault(callHostHints(), {}),
L.resolveDefault(callLeases(), {}),
L.resolveDefault(network.getNetworks(), [])
]);
},
render: function(data) {
injectCSS();
var self = this;
var state = {
status: data[1] || {},
nodes: (data[2] && data[2].nodes) || [],
hints: data[3] || {},
leases: data[4] || {},
networks: data[5] || []
};
var reloading = false; // set while reloadState swaps uci; poll skips redraw
var root = E('div', { 'class': 'su-wrap' });
// ---- uci model helpers (read live session) -------------------------
function groups() { return uci.sections('shater', 'group'); }
function subs() { return uci.sections('shater', 'subscription'); }
function rulesets() { return uci.sections('shater', 'ruleset'); }
function rules() { return uci.sections('shater', 'rule'); }
function gname(g) { return g.name || g['.name']; }
// --- global whole-file IP list -> VPN for every client ---
function listRulesetSec() { return rulesets().filter(function(r) { return (r.name || r['.name']) === GLIST_RULESET; })[0]; }
function listRuleSec() { return rules().filter(function(r) { return (r.name || r['.name']) === GLIST_RULE; })[0]; }
function listActive() { var r = listRuleSec(); return !!(r && uci.get('shater', r['.name'], 'enabled') !== '0'); }
// ensure ruleset(file) + one global rule; rule needs a group. returns true if fully wired.
function ensureGlobalList(enabled) {
if (!listRulesetSec()) {
var rs = uci.add('shater', 'ruleset');
uci.set('shater', rs, 'name', GLIST_RULESET);
uci.set('shater', rs, 'type', 'ipcidr');
uci.set('shater', rs, 'source', 'file');
uci.set('shater', rs, 'path', GLIST_PATH);
uci.set('shater', rs, 'format', 'plain');
}
var pg = primaryGroup();
if (!pg) return false;
var rl = listRuleSec();
if (!rl) {
var rid = uci.add('shater', 'rule');
uci.set('shater', rid, 'name', GLIST_RULE);
uci.set('shater', rid, 'order', '50');
uci.set('shater', rid, 'dst_ruleset', [ GLIST_RULESET ]);
uci.set('shater', rid, 'target', 'group:' + gname(pg));
uci.set('shater', rid, 'enabled', enabled ? '1' : '0');
} else {
uci.set('shater', rl['.name'], 'enabled', enabled ? '1' : '0');
uci.set('shater', rl['.name'], 'target', 'group:' + gname(pg));
}
return true;
}
function nodesAliveInGroup(g) {
var src = g.source || 'subscription';
return state.nodes.filter(function(n) {
if (!n || !n.alive) return false;
if (src === 'manual') return asList(g.node).indexOf(n.name) >= 0;
return n.group === g.subscription;
}).length;
}
function primaryGroup() {
var gs = groups();
// prefer a plain subscription group (no strategy suffix) with alive nodes
var best = null;
gs.forEach(function(g) {
var a = nodesAliveInGroup(g);
if (!best || a > best.a) best = { g: g, a: a };
});
return best ? best.g : null;
}
function primarySubName() {
var g = primaryGroup();
if (g && g.subscription) return g.subscription;
var s = subs();
return s.length ? (s[0].name || s[0]['.name']) : null;
}
function step1Done() { return groups().some(function(g) { return nodesAliveInGroup(g) > 0; }); }
// ensure a group with the given strategy exists; return its name
function ensureStrategyGroup(strategy) {
var sub = primarySubName();
if (!sub) return null;
if (strategy === 'leastping') {
var pg = primaryGroup();
if (pg) return gname(pg);
}
var wantName = (strategy === 'leastping') ? sub : (sub + '-' + strategy);
var found = groups().filter(function(g) { return gname(g) === wantName; })[0];
if (found) return wantName;
var sid = uci.add('shater', 'group');
uci.set('shater', sid, 'name', wantName);
uci.set('shater', sid, 'source', 'subscription');
uci.set('shater', sid, 'subscription', sub);
uci.set('shater', sid, 'strategy', strategy); uci.set('shater', sid, '_auto', '1');
return wantName;
}
// ---- persistence ---------------------------------------------------
function gcStrategyGroups() {
var used = {};
rules().forEach(function(r) { var t = r.target || ''; if (t.indexOf('group:') === 0) used[t.slice(6)] = 1; });
// GC only wizard-created balancers (marked _auto), never hand-made groups.
groups().forEach(function(g) {
var nm = gname(g);
if (uci.get('shater', g['.name'], '_auto') === '1' && !used[gname(g)]) uci.remove('shater', g['.name']);
});
}
function commit(runEngine) {
gcStrategyGroups();
return uci.save()
.then(function() { return uci.apply(); })
.then(function() {
var enabled = String((state.status && state.status.enabled) || '') === 'true' ||
uci.get('shater', 'globals', 'enabled') === '1';
if (!(runEngine || enabled)) return true;
// Surface a failed xrayctl apply instead of swallowing it with
// resolveDefault — the caller's .catch reports it to the user.
return callApply().then(function(r) {
if (r && r.ok === false) throw new Error(_('apply failed (see logs)'));
return r;
});
})
.then(reloadState);
}
function reloadState() {
// Guard the poll: uci.unload -> load has an async gap during which a
// redraw would read empty config and flash the whole page blank.
reloading = true;
uci.unload('shater');
return Promise.all([
uci.load('shater'),
L.resolveDefault(callStatus(), {}),
L.resolveDefault(callNodes(), {})
]).then(function(r) {
state.status = r[1] || {};
state.nodes = (r[2] && r[2].nodes) || [];
reloading = false;
redraw();
}).catch(function(e) { reloading = false; throw e; });
}
// ---- per-client policy inspection ----------------------------------
function rulesFor(ip) {
var pre = 'pc-' + slug(ip);
return rules().filter(function(r) {
var n = r.name || '';
return n === pre || n.indexOf(pre + '-') === 0;
});
}
function modeOf(ip) {
var rs = rulesFor(ip);
if (!rs.length) return { mode: 'off', label: _('Not routed (direct)') };
var byName = {};
rs.forEach(function(r) { byName[r.name] = r; });
var pre = 'pc-' + slug(ip);
if (byName[pre + '-block']) return { mode: 'block', label: _('Blocking selected lists') };
var a = byName[pre + '-a'], z = byName[pre + '-z'];
if (a && z) {
if ((a.target || '') === 'direct') return { mode: 'except', label: _('All via VPN except lists') };
return { mode: 'only', label: _('Only selected lists via VPN') };
}
if (byName[pre]) return { mode: 'all', label: _('Everything via VPN') };
return { mode: 'custom', label: _('Custom') };
}
function removeRulesFor(ip) {
rulesFor(ip).forEach(function(r) { uci.remove('shater', r['.name']); });
}
// ensure a ruleset exists for a pack/paste/url spec; return its name
function ensureRuleset(spec) {
var existing = rulesets().filter(function(r) { return (r.name || r['.name']) === spec.name; })[0];
if (existing) return spec.name;
var sid = uci.add('shater', 'ruleset');
uci.set('shater', sid, 'name', spec.name);
uci.set('shater', sid, 'type', spec.type || 'domain');
uci.set('shater', sid, 'source', spec.source || 'inline');
if (spec.source === 'url') { uci.set('shater', sid, 'url', spec.url); uci.set('shater', sid, 'format', spec.format || 'plain'); uci.set('shater', sid, 'update_interval', spec.update_interval || '24h'); }
if (spec.source === 'file') { uci.set('shater', sid, 'path', spec.path); uci.set('shater', sid, 'format', spec.format || 'plain'); }
if (spec.source === 'inline' && spec.entry) uci.set('shater', sid, 'entry', spec.entry);
return spec.name;
}
// ================================================================
// MODAL — per-client policy wizard
// ================================================================
function openModal(ip) {
var pg = primaryGroup();
if (!pg) { ui.addNotification(null, E('p', {}, _('Finish step 1 first (create a route from your subscription).')), 'warning'); return; }
var editing = modeOf(ip);
var sel = {
ip: ip || '',
mode: editing.mode === 'off' || editing.mode === 'custom' ? 'all' : editing.mode,
packs: {}, // key -> bool
rulesets: {}, // ruleset name -> bool
pasteName: '', pasteText: '',
urlName: '', urlVal: '',
server: 'auto', strategy: 'leastping', node: ''
};
// prefill list selection when editing
if (ip) {
var rs = rulesFor(ip);
var listRule = rs.filter(function(r) { var n = r.name || ''; return /-(a|block)$/.test(n); })[0];
if (listRule) {
asList(listRule.dst_ruleset).forEach(function(n) { sel.rulesets[n] = true; });
asList(listRule.dst_domain).forEach(function(d) {
PACKS.forEach(function(p) { if (p.dst_domain === d) sel.packs[p.key] = true; });
});
if (asList(listRule.dst_ip).length) sel.packs['private'] = true;
PACKS.forEach(function(p) { if (p.ruleset && sel.rulesets[p.ruleset.name]) sel.packs[p.key] = true; });
}
// The VPN target lives on whichever rule points at a group/node (the
// plain 'all' rule, the 'only' -a rule, or the 'except' -z rule) — never
// on a direct/block rule. Scan for it instead of guessing by suffix, or
// an "only" policy pinned to a node/strategy loses that on re-open.
var vpnRule = rs.filter(function(r) {
var t = r.target || '';
return t.indexOf('group:') === 0 || t.indexOf('node:') === 0;
})[0];
var tgt = (vpnRule && vpnRule.target) || '';
if (tgt.indexOf('node:') === 0) { sel.server = 'adv'; sel.node = tgt.slice(5); }
else if (tgt.indexOf('group:') === 0) {
var gn = tgt.slice(6);
var gsec = groups().filter(function(g) { return gname(g) === gn; })[0];
if (gsec && gsec.strategy && gsec.strategy !== 'leastping') { sel.server = 'adv'; sel.strategy = gsec.strategy; }
}
}
// device row: select of discovered clients + free text
var ipInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': sel.ip,
'placeholder': '192.168.x.y / 10.x.x.x', 'style': 'min-width:14em',
'input': function(ev) { sel.ip = ev.target.value.trim(); } });
var discovered = discoverClients();
var devSel = E('select', { 'class': 'cbi-input-select', 'change': function(ev) {
if (ev.target.value) { sel.ip = ev.target.value; ipInput.value = ev.target.value; }
} }, [ E('option', { 'value': '' }, _('— pick a device —')) ].concat(discovered.map(function(c) {
return E('option', { 'value': c.ip }, c.name ? (c.name + ' — ' + c.ip) : c.ip);
})));
// lists box (rebuilt when packs/rulesets change is not needed; static checkboxes)
var listsBox = E('div', { 'class': 'su-lists' });
function rebuildLists() {
var kids = [];
PACKS.forEach(function(p) {
kids.push(E('label', {}, [
E('input', { 'type': 'checkbox', 'checked': sel.packs[p.key] ? 'checked' : null,
'change': function(ev) { sel.packs[p.key] = ev.target.checked; } }), ' ', p.label ]));
});
rulesets().forEach(function(r) {
var nm = r.name || r['.name'];
if (nm === GLIST_RULESET) return; // global whole-file list, managed on its own card
if (PACKS.some(function(p) { return p.ruleset && p.ruleset.name === nm; })) return; // shown as pack
kids.push(E('label', {}, [
E('input', { 'type': 'checkbox', 'checked': sel.rulesets[nm] ? 'checked' : null,
'change': function(ev) { sel.rulesets[nm] = ev.target.checked; } }), ' ',
nm + ' ', E('span', { 'style': 'opacity:.5' }, '(' + (r.type || 'domain') + ')') ]));
});
// paste-your-own
var pasteTa = E('textarea', { 'class': 'cbi-input-textarea', 'rows': 3, 'style': 'width:100%;font-family:monospace;display:none',
'placeholder': 'example.com\nads.example.net\n1.2.3.0/24',
'input': function(ev) { sel.pasteText = ev.target.value; } });
var pasteNm = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'style': 'display:none;margin-top:.3em',
'placeholder': _('list name (optional)'), 'input': function(ev) { sel.pasteName = ev.target.value.trim(); } });
kids.push(E('div', { 'style': 'margin-top:.4em' }, [
E('button', { 'class': 'btn cbi-button', 'click': function(ev) {
ev.preventDefault();
var vis = pasteTa.style.display === 'none';
pasteTa.style.display = pasteNm.style.display = vis ? '' : 'none';
} }, [ _('+ paste domains / IPs') ]), pasteTa, pasteNm ]));
// url/file list
var urlNm = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'style': 'display:none;margin-top:.3em',
'placeholder': _('list name'), 'input': function(ev) { sel.urlName = ev.target.value.trim(); } });
var urlIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'style': 'display:none;margin-top:.3em;width:100%',
'placeholder': 'https://example.com/list.txt', 'input': function(ev) { sel.urlVal = ev.target.value.trim(); } });
kids.push(E('div', { 'style': 'margin-top:.3em' }, [
E('button', { 'class': 'btn cbi-button', 'click': function(ev) {
ev.preventDefault();
var vis = urlIn.style.display === 'none';
urlIn.style.display = urlNm.style.display = vis ? '' : 'none';
} }, [ _('+ add URL / file list') ]), urlNm, urlIn ]));
dom.content(listsBox, kids);
}
rebuildLists();
var listsWrap = E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'hd' }, _('Lists')),
E('div', { 'class': 'bd' }, [ listsBox ]) ]);
function syncListsVisibility() { listsWrap.style.display = (sel.mode === 'all') ? 'none' : ''; }
// mode radios
var MODES = [
[ 'all', _('Everything via VPN') ],
[ 'only', _('Only these sites / lists via VPN') ],
[ 'except', _('Everything EXCEPT these lists') ],
[ 'block', _('Block these sites for this device') ]
];
var modeBox = E('div', { 'class': 'bd' }, MODES.map(function(m) {
return E('label', { 'style': 'display:block;margin:.15em 0' }, [
E('input', { 'type': 'radio', 'name': 'su-mode', 'value': m[0], 'checked': sel.mode === m[0] ? 'checked' : null,
'change': function(ev) { if (ev.target.checked) { sel.mode = m[0]; syncListsVisibility(); } } }), ' ', m[1] ]);
}));
// server box
var stratSel = E('select', { 'class': 'cbi-input-select', 'change': function(ev) { sel.strategy = ev.target.value; } },
STRATEGIES.map(function(s) { return E('option', { 'value': s[0], 'selected': sel.strategy === s[0] ? 'selected' : null }, s[1]); }));
var aliveNodes = state.nodes.filter(function(n) { return n && n.alive; });
var nodeSel = E('select', { 'class': 'cbi-input-select', 'change': function(ev) { sel.node = ev.target.value; } },
[ E('option', { 'value': '' }, _('— any (use strategy) —')) ].concat(aliveNodes.map(function(n) {
return E('option', { 'value': n.name, 'selected': sel.node === n.name ? 'selected' : null }, n.name + (n.latency_ms ? (' · ' + n.latency_ms + 'ms') : '')); })));
var advWrap = E('div', { 'style': 'margin-top:.4em;padding-left:1.2em' + (sel.server === 'adv' ? '' : ';display:none') }, [
E('div', { 'class': 'su-row' }, [ E('span', { 'style': 'width:6em' }, _('Balancing')), stratSel ]),
E('div', { 'class': 'su-row' }, [ E('span', { 'style': 'width:6em' }, _('Or one node')), nodeSel ])
]);
var serverBox = E('div', { 'class': 'bd' }, [
E('label', { 'style': 'display:block' }, [
E('input', { 'type': 'radio', 'name': 'su-srv', 'checked': sel.server === 'auto' ? 'checked' : null,
'change': function(ev) { if (ev.target.checked) { sel.server = 'auto'; advWrap.style.display = 'none'; } } }),
' ', _('Auto — fastest server') ]),
E('label', { 'style': 'display:block' }, [
E('input', { 'type': 'radio', 'name': 'su-srv', 'checked': sel.server === 'adv' ? 'checked' : null,
'change': function(ev) { if (ev.target.checked) { sel.server = 'adv'; advWrap.style.display = ''; } } }),
' ', _('Advanced') ]),
advWrap
]);
syncListsVisibility();
// build target string from server selection (may create a group)
function computeTarget() {
if (sel.server === 'adv' && sel.node) return 'node:' + sel.node;
if (sel.server === 'adv') { var gn = ensureStrategyGroup(sel.strategy); return gn ? ('group:' + gn) : null; }
var pgName = gname(primaryGroup());
return pgName ? ('group:' + pgName) : null;
}
// gather dst_* contributions from selected lists (creates rulesets as needed)
function computeDst() {
var dst = { dst_ruleset: [], dst_domain: [], dst_ip: [] };
PACKS.forEach(function(p) {
if (!sel.packs[p.key]) return;
if (p.ruleset) dst.dst_ruleset.push(ensureRuleset(p.ruleset));
if (p.dst_domain) dst.dst_domain.push(p.dst_domain);
if (p.dst_ip) dst.dst_ip = dst.dst_ip.concat(p.dst_ip);
});
Object.keys(sel.rulesets).forEach(function(nm) { if (sel.rulesets[nm]) dst.dst_ruleset.push(nm); });
if (sel.pasteText && sel.pasteText.replace(/\s+/g, '')) {
var entries = sel.pasteText.split(/[\s,]+/).filter(Boolean);
var ipish = entries.filter(looksLikeIP).length;
var name = sel.pasteName || ('list-' + slug(sel.ip));
ensureRuleset({ name: name, type: (ipish > entries.length / 2) ? 'ipcidr' : 'domain', source: 'inline', entry: entries });
dst.dst_ruleset.push(name);
}
if (sel.urlVal) {
var uname = sel.urlName || ('url-' + slug(sel.ip));
ensureRuleset({ name: uname, type: 'domain', source: 'url', url: sel.urlVal, format: 'plain', update_interval: '24h' });
dst.dst_ruleset.push(uname);
}
return dst;
}
function doSave() {
if (!sel.ip) { ui.addNotification(null, E('p', {}, _('Pick or type a device IP first.')), 'warning'); return; }
var target = computeTarget();
if (!target) { ui.addNotification(null, E('p', {}, _('No server route available — finish step 1.')), 'warning'); return; }
var needLists = (sel.mode !== 'all');
var dst = needLists ? computeDst() : null;
if (needLists && !dst.dst_ruleset.length && !dst.dst_domain.length && !dst.dst_ip.length) {
ui.addNotification(null, E('p', {}, _('Pick at least one list for this mode.')), 'warning'); return;
}
var ip = sel.ip, pre = 'pc-' + slug(ip);
removeRulesFor(ip);
function newRule(name, order, opts) {
var sid = uci.add('shater', 'rule');
uci.set('shater', sid, 'name', name);
uci.set('shater', sid, 'enabled', '1');
uci.set('shater', sid, 'order', String(order));
uci.set('shater', sid, 'src', [ ip ]);
if (opts.dst) {
if (opts.dst.dst_ruleset.length) uci.set('shater', sid, 'dst_ruleset', opts.dst.dst_ruleset);
if (opts.dst.dst_domain.length) uci.set('shater', sid, 'dst_domain', opts.dst.dst_domain);
if (opts.dst.dst_ip.length) uci.set('shater', sid, 'dst_ip', opts.dst.dst_ip);
}
uci.set('shater', sid, 'target', opts.target);
}
if (sel.mode === 'all') {
newRule(pre, 10, { target: target });
} else if (sel.mode === 'only') {
newRule(pre + '-a', 10, { dst: dst, target: target });
newRule(pre + '-z', 90, { target: 'direct' });
} else if (sel.mode === 'except') {
newRule(pre + '-a', 10, { dst: dst, target: 'direct' });
newRule(pre + '-z', 90, { target: target });
} else if (sel.mode === 'block') {
newRule(pre + '-block', 5, { dst: dst, target: 'block' });
}
ui.hideModal();
ui.showModal(_('Saving…'), [ E('p', { 'class': 'spinning' }, _('Applying policy for %s').format(ip)) ]);
commit(true).then(function() {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Policy saved for %s').format(ip)), 'info');
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Save failed: ') + e), 'danger');
});
}
ui.showModal(ip ? (_('Configure device') + ' — ' + ip) : _('Add device policy'), [
E('div', { 'class': 'su-modal' }, [
E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'hd' }, _('Device')),
E('div', { 'class': 'bd' }, [ E('div', { 'class': 'su-row' }, [ ipInput, devSel ]) ]) ]),
E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'hd' }, _('Mode')), modeBox ]),
listsWrap,
E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'hd' }, _('Server')), serverBox ])
]),
E('div', { 'class': 'right' }, [
E('button', { 'class': 'btn cbi-button-neutral', 'click': ui.hideModal }, _('Cancel')), ' ',
E('button', { 'class': 'btn cbi-button-action important', 'click': ui.createHandlerFn(self, doSave) }, _('Save policy'))
])
], 'su-modal-dlg');
}
// discover LAN clients from host hints + leases + existing policies
function discoverClients() {
var map = {};
// Never offer the router's own LAN address or the upstream gateway as a
// client: routing either through the VPN loops and kills connectivity.
var skip = {};
asList(state.networks).forEach(function(n) {
try {
(n.getIPAddrs ? n.getIPAddrs() : []).forEach(function(c) { skip[String(c).split('/')[0]] = 1; });
var gw = n.getGatewayAddr ? n.getGatewayAddr() : null;
if (gw) skip[gw] = 1;
} catch (e) {}
});
// host hints: MAC -> {ipaddrs, ip6addrs, name?}
Object.keys(state.hints || {}).forEach(function(mac) {
var h = state.hints[mac] || {};
asList(h.ipaddrs).forEach(function(ip) {
if (isRFC1918(ip)) map[ip] = { ip: ip, mac: mac, name: h.name || '' };
});
});
asList(state.leases && state.leases.dhcp_leases).forEach(function(l) {
if (l.ipaddr && isRFC1918(l.ipaddr))
map[l.ipaddr] = { ip: l.ipaddr, mac: l.macaddr || '', name: l.hostname || (map[l.ipaddr] && map[l.ipaddr].name) || '' };
});
// existing policy IPs (may be offline)
rules().forEach(function(r) {
if ((r.name || '').indexOf('pc-') !== 0) return;
asList(r.src).forEach(function(s) { if (isRFC1918(s) && !map[s]) map[s] = { ip: s, mac: '', name: '' }; });
});
return Object.keys(map).filter(function(ip) { return !skip[ip]; }).sort(function(a, b) {
return a.localeCompare(b, undefined, { numeric: true });
}).map(function(ip) { return map[ip]; });
}
// ---- "route all traffic" quick toggle ------------------------------
function allRule() { return rules().filter(function(r) { return (r.name || r['.name']) === 'all-via-vpn'; })[0]; }
function toggleAll(on) {
var pg = primaryGroup();
if (on) {
if (!pg) { ui.addNotification(null, E('p', {}, _('Finish step 1 first.')), 'warning'); return; }
var sid = uci.add('shater', 'rule');
uci.set('shater', sid, 'name', 'all-via-vpn');
uci.set('shater', sid, 'enabled', '1');
uci.set('shater', sid, 'order', '900');
uci.set('shater', sid, 'target', 'group:' + gname(pg));
} else {
var r = allRule(); if (r) uci.remove('shater', r['.name']);
}
commit(true).catch(function(e) { ui.addNotification(null, E('p', {}, _('Apply failed: ') + e), 'danger'); });
}
// ================================================================
// RENDER
// ================================================================
function actBtn(label, cls, fn) {
return E('button', { 'class': 'btn cbi-button cbi-button-' + cls, 'style': 'margin-right:.4em',
'click': ui.createHandlerFn(self, fn) }, label);
}
function renderKPIs() {
var st = state.status || {};
var up = !!(st.xray_up || st.running);
var nlist = state.nodes || [];
var alive = nlist.filter(function(n) { return n && n.alive; }).length;
function card(lab, val, cls) {
return E('div', { 'class': 'su-card' }, [ E('div', { 'class': 'lab' }, lab),
E('div', { 'class': 'val' + (cls ? ' ' + cls : '') }, val) ]);
}
var routed = allRule() ? _('all') : String(countRoutedDevices());
return E('div', { 'class': 'su-kpis' }, [
card(_('Engine'), up ? _('running') : _('stopped'), up ? 'su-ok' : 'su-bad'),
card(_('Servers alive'), alive + ' / ' + nlist.length, alive ? 'su-ok' : ''),
card(_('Devices routed'), routed),
card(_('DNS'), (st.dns_mode || '-'))
]);
}
function countRoutedDevices() {
var ips = {};
rules().forEach(function(r) {
if ((r.name || '').indexOf('pc-') !== 0) return;
asList(r.src).forEach(function(s) { ips[s] = 1; });
});
return Object.keys(ips).length;
}
// STEP 1
function renderStep1() {
var done = step1Done();
var s = subs();
// modal to add a new subscription (name + URL + interval)
function addSubModal() {
var nameIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': _('name (e.g. myprovider)') });
var urlIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%', 'placeholder': 'https://…/sub/…' });
var ivIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'style': 'width:6em', 'value': '6h', 'placeholder': '6h' });
function row(lbl, node) { return E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'hd' }, lbl), E('div', { 'class': 'bd' }, [ node ]) ]); }
ui.showModal(_('Add subscription'), [
E('div', { 'class': 'su-modal' }, [ row(_('Name'), nameIn), row(_('URL'), urlIn), row(_('Update every'), ivIn) ]),
E('div', { 'class': 'right' }, [
E('button', { 'class': 'btn cbi-button-neutral', 'click': ui.hideModal }, _('Cancel')), ' ',
E('button', { 'class': 'btn cbi-button-action important', 'click': ui.createHandlerFn(self, function() {
var nm = (nameIn.value || '').trim(), u = (urlIn.value || '').trim();
if (!nm || !u) { ui.addNotification(null, E('p', {}, _('Enter a name and a URL.')), 'warning'); return; }
var sid = uci.add('shater', 'subscription');
uci.set('shater', sid, 'name', nm);
uci.set('shater', sid, 'url', u);
uci.set('shater', sid, 'enabled', '1');
uci.set('shater', sid, 'update_interval', (ivIn.value || '6h').trim());
ui.showModal(_('Adding…'), [ E('p', { 'class': 'spinning' }, _('Saving and fetching subscription')) ]);
return commit(false)
.then(function() { return L.resolveDefault(callSubUpdate(nm), {}); })
.then(function() { ui.hideModal(); return reloadState(); })
.then(function() { ui.addNotification(null, E('p', {}, _('Subscription added: ') + nm), 'info'); })
.catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', {}, '' + e), 'danger'); });
}) }, _('Add & fetch'))
])
], 'su-modal-dlg');
}
var addBtn = E('button', { 'class': 'btn cbi-button cbi-button-add',
'click': ui.createHandlerFn(self, function() { addSubModal(); }) }, [ '+ ' + _('Add subscription') ]);
var body = [];
if (!s.length) {
body.push(E('p', { 'style': 'opacity:.7' }, [ _('No subscription yet — add one to pull in servers.') ]));
body.push(E('div', { 'class': 'su-row' }, [ addBtn ]));
} else {
var rows = s.map(function(sub) {
var nm = sub.name || sub['.name'];
var mine = state.nodes.filter(function(n) { return n.group === nm; });
var alive = mine.filter(function(n) { return n.alive; }).length;
var en = uci.get('shater', sub['.name'], 'enabled') !== '0';
var cb = E('input', { 'type': 'checkbox', 'checked': en ? 'checked' : null,
'title': _('enable / disable this subscription'),
'change': function(ev) {
uci.set('shater', sub['.name'], 'enabled', ev.target.checked ? '1' : '0');
commit(false).catch(function(e) { ui.addNotification(null, E('p', {}, _('Save failed: ') + e), 'danger'); });
} });
return E('tr', {}, [
E('td', {}, [ cb ]),
E('td', {}, nm),
E('td', {}, [ E('span', { 'class': alive ? 'su-ok' : 'su-bad' }, alive + ' / ' + mine.length + ' ' + _('alive')) ])
]);
});
body.push(E('table', { 'class': 'su-tbl' }, [ E('tr', {}, [
E('th', { 'style': 'width:1.5em' }, _('On')), E('th', {}, _('Subscription')), E('th', {}, _('Servers')) ]) ].concat(rows)));
// enabled subscriptions that still have no route (group) of their own
var needRoute = s.filter(function(sub) {
var nm = sub.name || sub['.name'];
if (uci.get('shater', sub['.name'], 'enabled') === '0') return false;
return !groups().some(function(g) { return g.subscription === nm; });
});
body.push(E('div', { 'class': 'su-row', 'style': 'margin-top:.6em' }, [
addBtn,
actBtn(_('Update now'), 'action', function() {
ui.showModal(_('Updating…'), [ E('p', { 'class': 'spinning' }, _('Fetching subscriptions')) ]);
return callSubUpdate('').then(function() { ui.hideModal(); return reloadState(); }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', {}, '' + e), 'danger'); });
}),
actBtn(_('Test all servers'), 'neutral', function() {
ui.showModal(_('Testing…'), [ E('p', { 'class': 'spinning' }, _('Probing all servers — this can take up to a minute.')) ]);
return withTimeout(90, function() { return callNodeTest('', 'tcp', '', ''); })
.then(function() { ui.hideModal(); return reloadState(); })
.catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', {}, '' + e), 'danger'); });
}),
(needRoute.length ? actBtn(_('Create route(s)'), 'positive', function() {
needRoute.forEach(function(sub) {
var nm = sub.name || sub['.name'];
var sid = uci.add('shater', 'group');
uci.set('shater', sid, 'name', nm);
uci.set('shater', sid, 'source', 'subscription');
uci.set('shater', sid, 'subscription', nm);
uci.set('shater', sid, 'strategy', 'leastping');
});
return commit(false).then(function() { ui.addNotification(null, E('p', {}, _('Route(s) created for %d subscription(s).').format(needRoute.length)), 'info'); });
}) : E('span', { 'class': 'su-ok', 'style': 'align-self:center' }, [ '✓ ' + _('Routes ready') ]))
]));
}
return E('div', { 'class': 'su-step' }, [
E('h3', {}, [ badge(done), _('1. Get servers') ]),
E('p', { 'class': 'hint' }, _('Add or refresh a subscription; Shater then picks the fastest server for you. Each subscription becomes its own balanced pool.')),
E('div', {}, body)
]);
}
// STEP 2
function renderStep2() {
var ready = step1Done();
var clients = discoverClients();
var rows = clients.map(function(c) {
var m = modeOf(c.ip);
return E('tr', {}, [
E('td', {}, [ c.name ? E('span', {}, [ c.name, E('span', { 'style': 'opacity:.5' }, ' · ' + c.ip) ]) : c.ip ]),
E('td', { 'class': 'su-mode' }, m.label),
E('td', { 'style': 'text-align:right;white-space:nowrap' }, [
E('button', { 'class': 'btn cbi-button cbi-button-action', 'click': ui.createHandlerFn(self, function() { openModal(c.ip); }) }, _('Configure')),
' ',
(m.mode !== 'off' ? E('button', { 'class': 'btn cbi-button cbi-button-remove', 'title': _('Remove policy'),
'click': ui.createHandlerFn(self, function() {
removeRulesFor(c.ip);
return commit(true).then(function() { ui.addNotification(null, E('p', {}, _('Policy removed for %s').format(c.ip)), 'info'); });
}) }, '✕') : '')
])
]);
});
if (!rows.length) rows = [ E('tr', {}, [ E('td', { 'colspan': '3', 'style': 'opacity:.6' }, _('No devices seen yet — use “Add device” and type an IP.')) ]) ];
var allOn = !!allRule();
return E('div', { 'class': 'su-step' + (ready ? '' : ' off') }, [
E('h3', {}, [ badge(countRoutedDevices() > 0 || allOn), _('2. Choose devices') ]),
E('p', { 'class': 'hint' }, ready ? _('Pick which machines go through the VPN — fully, only for certain sites, or all-except a list.')
: _('Finish step 1 first.')),
E('div', { 'class': 'su-row' }, [
E('button', { 'class': 'btn cbi-button cbi-button-add', 'disabled': ready ? null : 'disabled',
'click': ui.createHandlerFn(self, function() { openModal(''); }) }, [ '+ ' + _('Add device') ]),
E('label', { 'style': 'margin-left:1em' }, [
E('input', { 'type': 'checkbox', 'checked': allOn ? 'checked' : null, 'disabled': ready ? null : 'disabled',
'change': function(ev) { toggleAll(ev.target.checked); } }), ' ', _('Route ALL traffic through VPN') ])
]),
E('table', { 'class': 'su-tbl' }, [ E('tr', {}, [ E('th', {}, _('Device')), E('th', {}, _('Policy')), E('th', {}) ]) ].concat(rows))
]);
}
// GLOBAL ROUTING LIST (one whole-file IP list for all clients) — its own card, no per-device checkboxes
function renderRoutingList() {
var rs = listRulesetSec(), active = listActive();
var fileInfo = E('span', { 'style': 'opacity:.75' }, [ _('checking file…') ]);
L.resolveDefault(fs.stat(GLIST_PATH), null).then(function(st) {
if (st && st.size) dom.content(fileInfo, [ _('file loaded: ') + Math.round(st.size / 1024) + ' KB' ]);
else dom.content(fileInfo, [ E('span', { 'class': 'su-bad' }, _('no file uploaded yet')) ]);
});
function applyErr(e) { ui.addNotification(null, E('p', {}, _('Apply failed: ') + e), 'danger'); }
var uploadBtn = E('button', { 'class': 'btn cbi-button cbi-button-action important',
'click': ui.createHandlerFn(self, function() {
return ui.uploadFile(GLIST_PATH).then(function() {
// file is on the router now; wiring the rule needs a group (step 1)
var wired = ensureGlobalList(true);
return commit(wired).then(function() {
ui.addNotification(null, E('p', {},
wired ? _('Routing list uploaded and applied.')
: _('File uploaded — finish step 1, then press Enable to route it.')),
wired ? 'info' : 'warning');
}).catch(applyErr);
}).catch(function() { /* uploadFile rejects on user cancel — ignore */ });
}) }, [ '📁 ' + _('Browse & upload (replace)') ]);
var toggle = E('button', { 'class': 'btn cbi-button cbi-button-' + (active ? 'reset' : 'positive'),
'disabled': step1Done() ? null : 'disabled',
'click': ui.createHandlerFn(self, function() {
ensureGlobalList(!active);
return commit(true).catch(applyErr);
}) },
active ? _('Disable') : _('Enable'));
var removeBtn = (rs || listRuleSec()) ? E('button', { 'class': 'btn cbi-button cbi-button-remove',
'click': ui.createHandlerFn(self, function() {
if (!confirm(_('Remove the global routing-list rule? (the uploaded file is kept)'))) return;
var rl = listRuleSec(); if (rl) uci.remove('shater', rl['.name']);
var r2 = listRulesetSec(); if (r2) uci.remove('shater', r2['.name']);
return commit(true).catch(applyErr);
}) }, _('Remove')) : '';
return E('div', { 'class': 'su-step' }, [
E('h3', {}, [ badge(active), _('Routing list (IP → VPN, all clients)') ]),
E('p', { 'class': 'hint' }, _('A single IP list that applies to every client: traffic to any address in the list goes through the VPN, everything else stays direct. To update the list, just upload the .txt again — it replaces the whole file, no per-device setup.')),
E('div', { 'class': 'su-row' }, [ fileInfo, E('span', { 'style': 'opacity:.4' }, [ ' · ' ]),
E('span', { 'class': active ? 'su-ok' : '' }, [ active ? _('active') : _('inactive') ]) ]),
E('div', { 'class': 'su-row' }, [ uploadBtn, toggle, removeBtn ])
]);
}
// STEP 3
function renderStep3() {
var st = state.status || {};
var on = !!(st.xray_up || st.running);
var enabled = uci.get('shater', 'globals', 'enabled') === '1';
var exitBox = E('span', { 'style': 'margin-left:.6em;opacity:.8' }, [ state.exitIp || '' ]);
var sw = E('button', { 'class': 'btn cbi-button cbi-button-' + (on ? 'reset' : 'positive'), 'style': 'font-size:1.05rem;padding:.4em 1.2em',
'click': ui.createHandlerFn(self, function() {
var turnOn = !on;
state.exitIp = ''; // cleared on any toggle; repopulated on turn-on probe
// ensure a tproxy inbound bound to lan
var inbs = uci.sections('shater', 'inbound');
if (turnOn) {
if (!inbs.length) {
var sid = uci.add('shater', 'inbound');
uci.set('shater', sid, 'name', 'lan');
uci.set('shater', sid, 'type', 'tproxy');
uci.set('shater', sid, 'network', 'lan');
uci.set('shater', sid, 'enabled', '1');
} else {
inbs.forEach(function(i) { uci.set('shater', i['.name'], 'enabled', '1'); });
}
uci.set('shater', 'globals', 'enabled', '1');
} else {
inbs.forEach(function(i) { uci.set('shater', i['.name'], 'enabled', '0'); });
uci.set('shater', 'globals', 'enabled', '0');
}
ui.showModal(_('Please wait…'), [ E('p', { 'class': 'spinning' }, turnOn ? _('Turning VPN on') : _('Turning VPN off')) ]);
return commit(true).then(function() {
ui.hideModal();
if (turnOn) {
// probe ONE fast node for a sample exit IP — probing all nodes
// would far exceed the rpc timeout with a large subscription.
var probe = state.nodes.filter(function(n) { return n && n.alive; })
.sort(function(a, b) { return (a.latency_ms || 1e9) - (b.latency_ms || 1e9); })[0];
if (!probe) { state.exitIp = _('on'); dom.content(exitBox, [ state.exitIp ]); return; }
dom.content(exitBox, [ _('checking exit IP…') ]);
callNodeTest(probe.name, 'http', PROBE_URL, 'GET').then(function(r) {
var arr = (r && r.probe) || [];
var hit = arr.filter(function(p) { return p && p.alive && p.exit_ip; })[0];
state.exitIp = hit ? (_('Exit IP: ') + hit.exit_ip + ' (' + probe.name + ')') : _('on');
dom.content(exitBox, [ state.exitIp ]);
}).catch(function() { state.exitIp = _('on'); dom.content(exitBox, [ state.exitIp ]); });
}
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', {}, '' + e), 'danger'); });
}) }, on ? _('Turn OFF') : _('Turn ON'));
var status = [];
if (on && st.drift) status.push(E('div', { 'class': 'su-bad' }, _('Config drift — press the switch to re-apply.')));
if (enabled && !on) status.push(E('div', { 'class': 'su-bad' }, _('Enabled but engine down — check Nodes/logs.')));
return E('div', { 'class': 'su-step' }, [
E('h3', {}, [ badge(on), _('3. Turn it on') ]),
E('p', { 'class': 'hint' }, _('One switch: enables the engine and LAN interception, then applies everything.')),
E('div', { 'class': 'su-row' }, [ E('span', { 'class': 'su-sw' }, on ? _('VPN is ON') : _('VPN is OFF')), sw, exitBox ]),
E('div', {}, status)
]);
}
function redraw() {
dom.content(root, [
E('h2', {}, _('Shater — Setup')),
renderKPIs(),
renderStep1(),
renderStep2(),
renderRoutingList(),
renderStep3()
]);
}
redraw();
poll.add(function() {
if (reloading) return; // skip the uci.unload->load window (see reloadState)
return Promise.all([
L.resolveDefault(callStatus(), {}),
L.resolveDefault(callNodes(), {})
]).then(function(r) { state.status = r[0] || {}; state.nodes = (r[1] && r[1].nodes) || []; redraw(); });
}, 10);
return root;
}
});
@@ -6,6 +6,12 @@
"depends": { "acl": [ "luci-app-shater" ] }
},
"admin/services/shater/setup": {
"title": "Setup",
"order": 0,
"action": { "type": "view", "path": "shater/setup" }
},
"admin/services/shater/overview": {
"title": "Overview",
"order": 1,
@@ -64,5 +70,17 @@
"title": "Settings",
"order": 10,
"action": { "type": "view", "path": "shater/settings" }
},
"admin/services/shater/help": {
"title": "Help / Справка",
"order": 11,
"action": { "type": "view", "path": "shater/help" }
},
"admin/services/shater/banana": {
"title": "🍌 Для обезьян",
"order": 12,
"action": { "type": "view", "path": "shater/banana" }
}
}
@@ -4,13 +4,20 @@
"read": {
"uci": [ "shater" ],
"ubus": {
"xray": [ "status", "nodes", "stats", "explain", "geodata_status", "compat", "profile_list", "backup", "sub_info", "conns", "node_qr", "wanmode" ]
"xray": [ "status", "nodes", "stats", "explain", "geodata_status", "compat", "profile_list", "backup", "sub_info", "conns", "node_qr", "wanmode" ],
"luci-rpc": [ "getHostHints", "getDHCPLeases", "getNetworkDevices" ]
},
"file": {
"/etc/xray/lists/*": [ "read" ]
}
},
"write": {
"uci": [ "shater" ],
"ubus": {
"xray": [ "sub_update", "apply", "confirm", "reload", "node_test", "node_import", "chain_test", "geodata_download", "geodata_remove", "restore", "profile_save", "profile_switch", "profile_delete", "node_enable", "node_delete", "node_assign_group" ]
},
"file": {
"/etc/xray/lists/*": [ "write" ]
}
}
}
@@ -6,9 +6,10 @@
// an { ok, code } result (action methods). Methods must be granted in acl.d.
'use strict';
import { popen } from 'fs';
import { popen, readfile, writefile, mkdir, unlink } from 'fs';
const XRAYCTL = '/usr/bin/xrayctl';
const CACHE_DIR = '/var/run/xray'; // tmpfs — read-through TTL cache for heavy reads
// Run a command, return its stdout as a string (empty on failure).
function run(cmd) {
@@ -38,9 +39,52 @@ function run_json(cmd) {
return data;
}
// Minimal shell-argument quoting: strip single quotes, wrap in single quotes.
// --- read-through TTL cache -------------------------------------------------
// rpcd is single-threaded: every xrayctl spawn (2-4s) blocks the whole ubus bus.
// Cache expensive reads on tmpfs as a small { _ts, d } wrapper so frequent LuCI
// polls return instantly instead of each spawning a process. Invalidated after
// any state-changing action so the dashboard never shows stale post-apply data.
function cache_path(key) {
return CACHE_DIR + '/rpc-' + key + '.json';
}
function run_json_cached(key, cmd, ttl) {
let now = time();
let raw = readfile(cache_path(key));
if (raw != null) {
try {
let c = json(raw);
if (c != null && c._ts != null && (now - c._ts) < ttl)
return c.d;
} catch (e) {}
}
let out = run(cmd);
let data;
try {
data = json(out);
} catch (e) {
return { error: 'invalid JSON from xrayctl', raw: trim(out) };
}
if (data == null)
data = {};
mkdir(CACHE_DIR);
try { writefile(cache_path(key), sprintf('%J', { _ts: now, d: data })); } catch (e) {}
return data;
}
function invalidate_cache() {
let keys = [ 'status', 'nodes', 'stats', 'conns' ];
for (let i = 0; i < length(keys); i++)
unlink(cache_path(keys[i]));
}
// POSIX single-quote escaping: close the quote, emit an escaped quote, reopen.
// This keeps values containing a single quote intact (e.g. a node named
// "it's fast" whose enable/delete/test would otherwise target a mangled name).
// Injection was already closed by wrapping in single quotes; this fixes the
// silent data loss of the previous strip-the-quote approach.
function shq(v) {
let s = replace('' + v, "'", '');
let s = replace('' + v, "'", "'\\''");
return "'" + s + "'";
}
@@ -48,7 +92,15 @@ function shq(v) {
// probe=true forces a fresh liveness sweep; the default serves xrayctl's cached
// sweep, which keeps the dashboard's frequent polling cheap.
function nodes_result(probe) {
let d = run_json(XRAYCTL + ' nodes' + (probe ? ' probe' : ''));
// A forced probe changes liveness, so drop stale caches and read fresh;
// the default read is served from the TTL cache (frequent dashboard polling).
let d;
if (probe) {
invalidate_cache();
d = run_json(XRAYCTL + ' nodes probe');
} else {
d = run_json_cached('nodes', XRAYCTL + ' nodes', 6);
}
if (type(d) == 'array')
return { nodes: d };
return d;
@@ -57,6 +109,7 @@ function nodes_result(probe) {
// Run an action command and report its exit code.
function action(cmd) {
let rc = system(cmd + ' >/dev/null 2>&1');
invalidate_cache(); // a mutating action makes cached status/nodes/stats stale
return { ok: rc == 0, code: rc };
}
@@ -65,7 +118,7 @@ return {
// --- read methods (acl read.ubus) ---
status: {
call: function() {
return run_json(XRAYCTL + ' status');
return run_json_cached('status', XRAYCTL + ' status', 5);
}
},
@@ -78,7 +131,7 @@ return {
stats: {
call: function() {
return run_json(XRAYCTL + ' stats');
return run_json_cached('stats', XRAYCTL + ' stats', 5);
}
},
@@ -176,7 +229,7 @@ return {
// Live connections from conntrack (best-effort outbound label).
conns: {
call: function() {
return run_json(XRAYCTL + ' conns');
return run_json_cached('conns', XRAYCTL + ' conns', 4);
}
},
+5 -3
View File
@@ -68,7 +68,9 @@ func writeBytesAtomic(path string, b []byte) error {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
// 0600: run.json embeds node credentials (UUIDs, passwords, WG keys) —
// must not be world-readable in /etc/xray.
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
@@ -160,7 +162,7 @@ func Apply(confirmTimeout int) error {
// Preserve current run.json as last-good only after a successful test.
if b, err := os.ReadFile(runJSON); err == nil {
_ = os.MkdirAll(filepath.Dir(lastGood), 0o755)
_ = os.WriteFile(lastGood, b, 0o644)
_ = os.WriteFile(lastGood, b, 0o600) // copy of run.json → credentials
}
if _, err := syncRunJSON(cfg, true); err != nil {
return err
@@ -247,7 +249,7 @@ func Rollback() error {
// 3) Restore xray run.json + reload (if we have a last-good).
_ = os.Remove(pendingFlag)
if b, err := os.ReadFile(lastGood); err == nil {
if err := os.WriteFile(runJSON, b, 0o644); err != nil {
if err := os.WriteFile(runJSON, b, 0o600); err != nil {
return err
}
return reloadXray()
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// run.json and last-good.json are the rendered xray config: they embed node
// credentials (VLESS/VMess UUIDs, Shadowsocks/Trojan passwords, WireGuard keys).
// They live in /etc/xray and must never be world-readable — regression guard for
// the 0644 -> 0600 hardening in writeBytesAtomic and the rollback/last-good paths.
func TestRunJSONWrittenOwnerOnly(t *testing.T) {
p := filepath.Join(t.TempDir(), "run.json")
if err := writeBytesAtomic(p, []byte(`{"ok":1}`)); err != nil {
t.Fatalf("writeBytesAtomic: %v", err)
}
fi, err := os.Stat(p)
if err != nil {
t.Fatalf("stat: %v", err)
}
if m := fi.Mode().Perm(); m != 0o600 {
t.Fatalf("run.json perms = %04o, want 0600 (file holds node credentials)", m)
}
}