Compare commits
7
Commits
main
...
Zzerg0Pack
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aa02b6ba4 | ||
|
|
9582105ab9 | ||
|
|
f39d84c4da | ||
|
|
a9b40ee9ce | ||
|
|
df6bdaa616 | ||
|
|
3ba36a7bd5 | ||
|
|
55498882f7 |
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user