Генераторы конфигов Xray и Mihomo для XKeen на GitHub Pages
- tools/xray: 04_outbounds.json и 05_routing.json — серверы, выбор сервисов по геобазам XKeen (ZKeen, V2Fly, Re:filter), реклама, QUIC, свои сайты, балансировщик для нескольких серверов - tools/mihomo: config.yaml с портами XKeen, подпиской, Hysteria2, автовыбором сервера и панелью zashboard - Обе страницы выдают команду, которая пишет файлы на роутер и перезапускает XKeen - Всё считается в браузере, ссылки не сохраняются и никуда не отправляются - Корень сайта перенаправляет на репозиторий Проверено: модульные тесты, xray -test и mihomo -t на всех сценариях, команды — в busybox ash, живое подключение через REALITY и Hysteria2.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru"><head><meta charset="utf-8"><title>Генераторы конфигов</title>
|
||||
<meta http-equiv="refresh" content="0; url=xray/"></head>
|
||||
<body><a href="xray/">Генератор Xray</a> · <a href="mihomo/">Генератор Mihomo</a></body></html>
|
||||
@@ -0,0 +1,239 @@
|
||||
// Разбор ссылок vless:// vmess:// trojan:// ss:// hy2:// в общий формат.
|
||||
// Работает в браузере (window.PM) и в Node (module.exports). Всё локально —
|
||||
// ссылки никуда не отправляются.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function b64decode(s) {
|
||||
s = s.trim().replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, '');
|
||||
while (s.length % 4) s += '=';
|
||||
const bin = typeof atob === 'function' ? atob(s) : Buffer.from(s, 'base64').toString('binary');
|
||||
// UTF-8 из бинарной строки
|
||||
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function looksBase64(s) {
|
||||
return /^[A-Za-z0-9+/=_\-\s]+$/.test(s) && s.replace(/\s/g, '').length >= 16;
|
||||
}
|
||||
|
||||
function dec(s) {
|
||||
try { return decodeURIComponent(s); } catch (e) { return s; }
|
||||
}
|
||||
|
||||
function int(v, what) {
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error('неверный ' + what + ': ' + v);
|
||||
return n;
|
||||
}
|
||||
|
||||
function splitList(v) {
|
||||
return v ? dec(v).split(',').map((x) => x.trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function truthy(v) {
|
||||
return v === '1' || v === 'true' || v === 'True';
|
||||
}
|
||||
|
||||
// Разбор адреса «хост:порт» из URL: поддерживает IPv6 в квадратных скобках.
|
||||
function hostPort(u) {
|
||||
const host = u.hostname.replace(/^\[|\]$/g, '');
|
||||
if (!host) throw new Error('нет адреса сервера');
|
||||
return { server: host, port: int(u.port || '443', 'порт') };
|
||||
}
|
||||
|
||||
// Общие параметры транспорта и шифрования из query (формат Xray share link).
|
||||
function transportFromQuery(q, fallbackHost) {
|
||||
let network = (q.get('type') || 'tcp').toLowerCase();
|
||||
if (network === 'raw') network = 'tcp';
|
||||
if (network === 'http' && q.get('headerType') !== 'http') network = 'xhttp';
|
||||
const t = { network };
|
||||
const path = q.get('path') ? dec(q.get('path')) : '';
|
||||
const host = q.get('host') ? dec(q.get('host')) : '';
|
||||
if (network === 'ws' || network === 'httpupgrade') {
|
||||
t.path = path || '/';
|
||||
if (host) t.host = host;
|
||||
} else if (network === 'grpc') {
|
||||
t.serviceName = dec(q.get('serviceName') || q.get('path') || '');
|
||||
t.multiMode = q.get('mode') === 'multi';
|
||||
} else if (network === 'xhttp' || network === 'splithttp') {
|
||||
t.network = 'xhttp';
|
||||
t.path = path || '/';
|
||||
if (host) t.host = host;
|
||||
t.mode = q.get('mode') || 'auto';
|
||||
} else if (network === 'tcp') {
|
||||
if (q.get('headerType') === 'http') {
|
||||
t.headerType = 'http';
|
||||
t.path = path || '/';
|
||||
if (host) t.host = host;
|
||||
}
|
||||
} else {
|
||||
throw new Error('транспорт «' + network + '» не поддерживается');
|
||||
}
|
||||
|
||||
const security = (q.get('security') || 'none').toLowerCase();
|
||||
const s = { security };
|
||||
if (security === 'tls' || security === 'reality') {
|
||||
s.sni = dec(q.get('sni') || q.get('peer') || '') || host || fallbackHost;
|
||||
s.fp = q.get('fp') || (security === 'reality' ? 'chrome' : '');
|
||||
s.alpn = splitList(q.get('alpn'));
|
||||
s.insecure = truthy(q.get('allowInsecure')) || truthy(q.get('insecure'));
|
||||
}
|
||||
if (security === 'reality') {
|
||||
s.pbk = q.get('pbk') || '';
|
||||
if (!s.pbk) throw new Error('в ссылке REALITY нет публичного ключа (pbk)');
|
||||
s.sid = q.get('sid') || '';
|
||||
s.spx = q.get('spx') ? dec(q.get('spx')) : '';
|
||||
} else if (security !== 'tls' && security !== 'none') {
|
||||
throw new Error('шифрование «' + security + '» не поддерживается');
|
||||
}
|
||||
return { transport: t, tls: s };
|
||||
}
|
||||
|
||||
function parseVless(link) {
|
||||
const u = new URL(link);
|
||||
const id = dec(u.username);
|
||||
if (!id) throw new Error('нет UUID');
|
||||
const hp = hostPort(u);
|
||||
const q = u.searchParams;
|
||||
const enc = q.get('encryption');
|
||||
if (enc && enc !== 'none') throw new Error('шифрование VLESS «' + enc + '» не поддерживается');
|
||||
return Object.assign({
|
||||
type: 'vless', name: dec(u.hash.slice(1)), uuid: id, flow: q.get('flow') || '',
|
||||
}, hp, transportFromQuery(q, hp.server));
|
||||
}
|
||||
|
||||
function parseTrojan(link) {
|
||||
const u = new URL(link);
|
||||
const pw = dec(u.username);
|
||||
if (!pw) throw new Error('нет пароля');
|
||||
const hp = hostPort(u);
|
||||
const q = u.searchParams;
|
||||
if (!q.get('security')) q.set('security', 'tls');
|
||||
return Object.assign({ type: 'trojan', name: dec(u.hash.slice(1)), password: pw }, hp, transportFromQuery(q, hp.server));
|
||||
}
|
||||
|
||||
function parseVmess(link) {
|
||||
const raw = b64decode(link.slice('vmess://'.length).split('#')[0]);
|
||||
let j;
|
||||
try { j = JSON.parse(raw); } catch (e) { throw new Error('ссылка vmess повреждена'); }
|
||||
if (!j.id || !j.add) throw new Error('в ссылке vmess нет адреса или UUID');
|
||||
const q = new URLSearchParams();
|
||||
q.set('type', j.net || 'tcp');
|
||||
if (j.type && j.type !== 'none') q.set(j.net === 'grpc' ? 'mode' : 'headerType', j.type);
|
||||
if (j.path) q.set(j.net === 'grpc' ? 'serviceName' : 'path', j.path);
|
||||
if (j.host) q.set('host', j.host);
|
||||
q.set('security', j.tls === 'tls' || j.tls === 'reality' ? j.tls : 'none');
|
||||
if (j.sni) q.set('sni', j.sni);
|
||||
if (j.alpn) q.set('alpn', j.alpn);
|
||||
if (j.fp) q.set('fp', j.fp);
|
||||
if (j.pbk) q.set('pbk', j.pbk);
|
||||
if (j.sid) q.set('sid', j.sid);
|
||||
return Object.assign({
|
||||
type: 'vmess', name: j.ps || '', uuid: j.id, alterId: Number(j.aid || 0),
|
||||
cipher: j.scy || 'auto', server: String(j.add), port: int(j.port, 'порт'),
|
||||
}, transportFromQuery(q, String(j.add)));
|
||||
}
|
||||
|
||||
const SS_METHODS = [
|
||||
'aes-128-gcm', 'aes-256-gcm', 'chacha20-poly1305', 'chacha20-ietf-poly1305',
|
||||
'xchacha20-poly1305', 'xchacha20-ietf-poly1305',
|
||||
'2022-blake3-aes-128-gcm', '2022-blake3-aes-256-gcm', '2022-blake3-chacha20-poly1305',
|
||||
];
|
||||
|
||||
function parseSs(link) {
|
||||
let body = link.slice('ss://'.length);
|
||||
let name = '';
|
||||
const h = body.indexOf('#');
|
||||
if (h >= 0) { name = dec(body.slice(h + 1)); body = body.slice(0, h); }
|
||||
const qi = body.indexOf('?');
|
||||
let query = '';
|
||||
if (qi >= 0) { query = body.slice(qi + 1); body = body.slice(0, qi); }
|
||||
body = body.replace(/\/$/, '');
|
||||
if (new URLSearchParams(query).get('plugin')) throw new Error('плагины Shadowsocks не поддерживаются');
|
||||
let userinfo, hostport;
|
||||
const at = body.lastIndexOf('@');
|
||||
if (at >= 0) {
|
||||
userinfo = body.slice(0, at);
|
||||
hostport = body.slice(at + 1);
|
||||
userinfo = userinfo.includes(':') ? dec(userinfo) : b64decode(dec(userinfo));
|
||||
} else {
|
||||
const d = b64decode(body);
|
||||
const a = d.lastIndexOf('@');
|
||||
if (a < 0) throw new Error('ссылка ss повреждена');
|
||||
userinfo = d.slice(0, a);
|
||||
hostport = d.slice(a + 1);
|
||||
}
|
||||
const c = userinfo.indexOf(':');
|
||||
if (c < 0) throw new Error('в ссылке ss нет метода или пароля');
|
||||
const method = userinfo.slice(0, c).toLowerCase();
|
||||
const password = userinfo.slice(c + 1);
|
||||
if (!SS_METHODS.includes(method)) throw new Error('метод Shadowsocks «' + method + '» не поддерживается');
|
||||
const u = new URL('ss://x@' + hostport);
|
||||
return Object.assign({ type: 'ss', name, method, password }, hostPort(u));
|
||||
}
|
||||
|
||||
function parseHy2(link) {
|
||||
const authority = link.replace(/^[a-z0-9]+:\/\//i, '').split(/[/?#]/)[0];
|
||||
if (/:\d+[-,]/.test(authority)) throw new Error('диапазоны портов Hysteria2 не поддерживаются');
|
||||
const u = new URL(link.replace(/^hysteria2:/, 'hy2:'));
|
||||
let auth = dec(u.username);
|
||||
if (u.password) auth += ':' + dec(u.password);
|
||||
if (!auth) throw new Error('нет пароля');
|
||||
const q = u.searchParams;
|
||||
const hp = hostPort(u);
|
||||
const obfs = q.get('obfs') || '';
|
||||
if (obfs && obfs !== 'salamander') throw new Error('обфускация «' + obfs + '» не поддерживается');
|
||||
return Object.assign({
|
||||
type: 'hysteria2', name: dec(u.hash.slice(1)), password: auth,
|
||||
sni: dec(q.get('sni') || '') || hp.server,
|
||||
insecure: truthy(q.get('insecure')),
|
||||
pinSHA256: (q.get('pinSHA256') || '').replace(/:/g, '').toLowerCase(),
|
||||
obfs, obfsPassword: dec(q.get('obfs-password') || ''),
|
||||
alpn: splitList(q.get('alpn')),
|
||||
}, hp);
|
||||
}
|
||||
|
||||
const PARSERS = { 'vless': parseVless, 'vmess': parseVmess, 'trojan': parseTrojan, 'ss': parseSs, 'hy2': parseHy2, 'hysteria2': parseHy2 };
|
||||
|
||||
function parseLink(line) {
|
||||
const m = /^([a-z0-9]+):\/\//i.exec(line);
|
||||
if (!m) throw new Error('это не ссылка на подключение');
|
||||
const p = PARSERS[m[1].toLowerCase()];
|
||||
if (!p) throw new Error('протокол «' + m[1] + '» не поддерживается');
|
||||
const r = p(line);
|
||||
if (!r.name) r.name = r.type + '-' + r.server;
|
||||
return r;
|
||||
}
|
||||
|
||||
// Разбирает текст: ссылки по одной на строку или base64-подписку целиком.
|
||||
function parseText(text) {
|
||||
let t = (text || '').trim();
|
||||
if (t && !/:\/\//.test(t) && looksBase64(t)) {
|
||||
try { t = b64decode(t); } catch (e) { /* не base64 — разберём как есть */ }
|
||||
}
|
||||
const ok = [];
|
||||
const errors = [];
|
||||
t.split(/\r?\n/).map((s) => s.trim()).filter((s) => s && !s.startsWith('#')).forEach((line, i) => {
|
||||
try {
|
||||
ok.push(parseLink(line));
|
||||
} catch (e) {
|
||||
errors.push({ line: i + 1, text: line.length > 60 ? line.slice(0, 57) + '…' : line, error: e.message });
|
||||
}
|
||||
});
|
||||
// Уникальные имена — клиенты требуют.
|
||||
const seen = {};
|
||||
ok.forEach((p) => {
|
||||
const base = p.name;
|
||||
let n = base, k = 2;
|
||||
while (seen[n]) n = base + ' ' + k++;
|
||||
seen[n] = true;
|
||||
p.name = n;
|
||||
});
|
||||
return { proxies: ok, errors };
|
||||
}
|
||||
|
||||
const api = { parseLink, parseText, b64decode };
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
root.PM = Object.assign(root.PM || {}, api);
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,234 @@
|
||||
// Сборка config.yaml для Mihomo (XKeen) из разобранных ссылок.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
const META = 'https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/';
|
||||
const REFILTER = 'https://github.com/legiz-ru/mihomo-rule-sets/raw/main/re-filter/';
|
||||
|
||||
// Сервисы: какие наборы правил отправлять через прокси.
|
||||
const SERVICES = [
|
||||
{ id: 'blocked', group: 'Заблокированное', label: 'Заблокированное в России', hint: 'список re:filter — основные заблокированные сайты', on: true,
|
||||
sets: [['refilter@domain', REFILTER + 'domain-rule.mrs', 'domain'], ['refilter@ipcidr', REFILTER + 'ip-rule.mrs', 'ipcidr']] },
|
||||
{ id: 'youtube', label: 'YouTube', on: true, sets: [['youtube@domain', META + 'geosite/youtube.mrs', 'domain']] },
|
||||
{ id: 'telegram', label: 'Telegram', on: true,
|
||||
sets: [['telegram@domain', META + 'geosite/telegram.mrs', 'domain'], ['telegram@ipcidr', META + 'geoip/telegram.mrs', 'ipcidr']] },
|
||||
{ id: 'meta', group: 'Meta', label: 'Instagram, Facebook, WhatsApp', on: true,
|
||||
sets: [['meta@domain', META + 'geosite/meta.mrs', 'domain'], ['facebook@ipcidr', META + 'geoip/facebook.mrs', 'ipcidr']] },
|
||||
{ id: 'twitter', group: 'X', label: 'X (Twitter)', on: true,
|
||||
sets: [['twitter@domain', META + 'geosite/twitter.mrs', 'domain'], ['twitter@ipcidr', META + 'geoip/twitter.mrs', 'ipcidr']] },
|
||||
{ id: 'discord', label: 'Discord', on: true, sets: [['discord@domain', META + 'geosite/discord.mrs', 'domain']] },
|
||||
{ id: 'ai', group: 'ИИ', label: 'ChatGPT, Claude и другие ИИ', on: true, sets: [['ai@domain', META + 'geosite/category-ai-!cn.mrs', 'domain']] },
|
||||
{ id: 'tiktok', label: 'TikTok', on: false, sets: [['tiktok@domain', META + 'geosite/tiktok.mrs', 'domain']] },
|
||||
{ id: 'spotify', label: 'Spotify', on: false, sets: [['spotify@domain', META + 'geosite/spotify.mrs', 'domain']] },
|
||||
{ id: 'netflix', label: 'Netflix', on: false,
|
||||
sets: [['netflix@domain', META + 'geosite/netflix.mrs', 'domain'], ['netflix@ipcidr', META + 'geoip/netflix.mrs', 'ipcidr']] },
|
||||
{ id: 'github', label: 'GitHub', on: false, sets: [['github@domain', META + 'geosite/github.mrs', 'domain']] },
|
||||
{ id: 'twitch', label: 'Twitch', on: false, sets: [['twitch@domain', META + 'geosite/twitch.mrs', 'domain']] },
|
||||
];
|
||||
|
||||
// ---------- YAML ----------
|
||||
|
||||
const PLAIN = /^[A-Za-z0-9_][A-Za-z0-9_.\/@:+-]*$/;
|
||||
const RESERVED = /^(true|false|yes|no|on|off|null|~|y|n|[-+]?[0-9][0-9_.eE+-]*|0x[0-9a-f]+)$/i;
|
||||
|
||||
function scalar(v) {
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
const s = String(v);
|
||||
return PLAIN.test(s) && !RESERVED.test(s) && !s.includes(': ') ? s : JSON.stringify(s);
|
||||
}
|
||||
|
||||
function flow(v) {
|
||||
if (Array.isArray(v)) return '[' + v.map(flow).join(', ') + ']';
|
||||
if (v && typeof v === 'object') return '{' + Object.keys(v).map((k) => scalar(k) + ': ' + flow(v[k])).join(', ') + '}';
|
||||
return scalar(v);
|
||||
}
|
||||
|
||||
function yaml(v, ind) {
|
||||
ind = ind || '';
|
||||
if (Array.isArray(v)) {
|
||||
if (!v.length) return ' []\n';
|
||||
return '\n' + v.map((x) => {
|
||||
if (x && typeof x === 'object' && !Array.isArray(x)) {
|
||||
const body = yaml(x, ind + ' ').replace(/^\n/, '');
|
||||
return ind + '- ' + body.slice(ind.length + 2);
|
||||
}
|
||||
return ind + '- ' + scalar(x) + '\n';
|
||||
}).join('');
|
||||
}
|
||||
if (v && typeof v === 'object') {
|
||||
return '\n' + Object.keys(v).map((k) => {
|
||||
const x = v[k];
|
||||
if (x && typeof x === 'object') {
|
||||
if (x.__flow) { const c = Object.assign({}, x); delete c.__flow; return ind + scalar(k) + ': ' + flow(c) + '\n'; }
|
||||
return ind + scalar(k) + ':' + yaml(x, ind + ' ');
|
||||
}
|
||||
return ind + scalar(k) + ': ' + scalar(x) + '\n';
|
||||
}).join('');
|
||||
}
|
||||
return ' ' + scalar(v) + '\n';
|
||||
}
|
||||
|
||||
function clean(o) {
|
||||
Object.keys(o).forEach((k) => {
|
||||
const v = o[k];
|
||||
if (v === undefined || v === '' || v === false && k !== 'tls' && k !== 'udp' || (Array.isArray(v) && !v.length)) delete o[k];
|
||||
});
|
||||
return o;
|
||||
}
|
||||
|
||||
// ---------- прокси ----------
|
||||
|
||||
function transportOpts(p, o) {
|
||||
const t = p.transport;
|
||||
if (t.network === 'tcp') {
|
||||
if (t.headerType === 'http') {
|
||||
o.network = 'http';
|
||||
o['http-opts'] = clean({ path: [t.path], headers: t.host ? { Host: [t.host] } : undefined });
|
||||
} else o.network = 'tcp';
|
||||
} else if (t.network === 'ws' || t.network === 'httpupgrade') {
|
||||
o.network = 'ws';
|
||||
o['ws-opts'] = clean({ path: t.path, headers: t.host ? { Host: t.host } : undefined, 'v2ray-http-upgrade': t.network === 'httpupgrade' || undefined });
|
||||
} else if (t.network === 'grpc') {
|
||||
o.network = 'grpc';
|
||||
o['grpc-opts'] = { 'grpc-service-name': t.serviceName };
|
||||
} else if (t.network === 'xhttp') {
|
||||
o.network = 'xhttp';
|
||||
o['xhttp-opts'] = clean({ path: t.path, host: t.host, mode: t.mode });
|
||||
}
|
||||
}
|
||||
|
||||
function tlsOpts(p, o, sniKey) {
|
||||
const s = p.tls;
|
||||
if (s.security === 'none') { o.tls = false; return; }
|
||||
o.tls = true;
|
||||
o[sniKey] = s.sni;
|
||||
o['client-fingerprint'] = s.fp || undefined;
|
||||
o.alpn = s.alpn;
|
||||
o['skip-cert-verify'] = s.insecure || undefined;
|
||||
if (s.security === 'reality') o['reality-opts'] = clean({ 'public-key': s.pbk, 'short-id': s.sid });
|
||||
}
|
||||
|
||||
function proxy(p) {
|
||||
const o = { name: p.name, type: p.type, server: p.server, port: p.port, udp: true };
|
||||
switch (p.type) {
|
||||
case 'vless':
|
||||
o.uuid = p.uuid; o.flow = p.flow || undefined;
|
||||
transportOpts(p, o); tlsOpts(p, o, 'servername');
|
||||
break;
|
||||
case 'vmess':
|
||||
o.uuid = p.uuid; o.alterId = p.alterId; o.cipher = p.cipher;
|
||||
transportOpts(p, o); tlsOpts(p, o, 'servername');
|
||||
break;
|
||||
case 'trojan':
|
||||
o.password = p.password;
|
||||
transportOpts(p, o); tlsOpts(p, o, 'sni');
|
||||
if (o.network === 'tcp') delete o.network;
|
||||
delete o.tls;
|
||||
break;
|
||||
case 'ss':
|
||||
o.cipher = p.method; o.password = p.password;
|
||||
break;
|
||||
case 'hysteria2':
|
||||
o.password = p.password; o.sni = p.sni; o.alpn = p.alpn;
|
||||
o['skip-cert-verify'] = (p.insecure && !p.pinSHA256) || undefined;
|
||||
o.fingerprint = p.pinSHA256 || undefined;
|
||||
if (p.obfs) { o.obfs = p.obfs; o['obfs-password'] = p.obfsPassword; }
|
||||
break;
|
||||
}
|
||||
return clean(o);
|
||||
}
|
||||
|
||||
function randomSecret() {
|
||||
const a = new Uint8Array(12);
|
||||
(root.crypto || require('crypto').webcrypto).getRandomValues(a);
|
||||
return Array.from(a, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function domains(text) {
|
||||
return (text || '').split(/[\s,]+/).map((d) => d.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^\*\./, ''))
|
||||
.filter((d) => /^[a-z0-9.-]+\.[a-z]{2,}$/.test(d));
|
||||
}
|
||||
|
||||
// opts: services[], subscription, perService, finalProxy, blockAds, blockQuic, proxyDomains, directDomains
|
||||
function build(proxies, opts) {
|
||||
opts = opts || {};
|
||||
const services = SERVICES.filter((s) => (opts.services || SERVICES.filter((x) => x.on).map((x) => x.id)).includes(s.id));
|
||||
if (!proxies.length && !opts.subscription) throw new Error('Добавьте хотя бы одну ссылку или подписку.');
|
||||
|
||||
const cfg = {
|
||||
'log-level': 'warning',
|
||||
'allow-lan': true,
|
||||
'redir-port': 5000,
|
||||
'tproxy-port': 5001,
|
||||
'routing-mark': 255,
|
||||
'find-process-mode': 'off',
|
||||
'unified-delay': true,
|
||||
'external-controller': '0.0.0.0:9090',
|
||||
secret: opts.secret || randomSecret(),
|
||||
'external-ui': 'zashboard',
|
||||
'external-ui-url': 'https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip',
|
||||
sniffer: {
|
||||
enable: true,
|
||||
sniff: { HTTP: { __flow: 1, ports: [80, 8080] }, TLS: { __flow: 1, ports: [443, 8443] }, QUIC: { __flow: 1, ports: [443, 8443] } },
|
||||
},
|
||||
};
|
||||
if (services.some((s) => s.id === 'telegram')) cfg.sniffer['skip-dst-address'] = ['rule-set:telegram@ipcidr'];
|
||||
|
||||
if (proxies.length) cfg.proxies = proxies.map(proxy);
|
||||
if (opts.subscription) {
|
||||
cfg['proxy-providers'] = {
|
||||
subscription: {
|
||||
type: 'http', url: opts.subscription, interval: 3600, path: './providers/subscription.yaml',
|
||||
'health-check': { enable: true, url: 'https://www.gstatic.com/generate_204', interval: 300 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const PROXY = 'Прокси';
|
||||
const groups = [
|
||||
{ name: PROXY, type: 'select', proxies: ['Авто'], 'include-all': true },
|
||||
{ name: 'Авто', type: 'url-test', url: 'https://www.gstatic.com/generate_204', interval: 300, tolerance: 50, 'include-all': true },
|
||||
];
|
||||
const target = {};
|
||||
services.forEach((s) => {
|
||||
if (opts.perService) {
|
||||
// В правилах запятая — разделитель, поэтому имя группы без запятых.
|
||||
const g = (s.group || s.label).replace(/,/g, '');
|
||||
groups.push({ name: g, type: 'select', proxies: [PROXY, 'DIRECT'], 'include-all': true });
|
||||
target[s.id] = g;
|
||||
} else target[s.id] = PROXY;
|
||||
});
|
||||
cfg['proxy-groups'] = groups;
|
||||
|
||||
const providers = {};
|
||||
const addSet = (name, url, behavior) => {
|
||||
providers[name] = { __flow: 1, type: 'http', format: 'mrs', behavior, url, path: './rules/' + name.replace('@', '-') + '.mrs', interval: 86400 };
|
||||
};
|
||||
services.forEach((s) => s.sets.forEach((x) => addSet(x[0], x[1], x[2])));
|
||||
if (opts.blockAds) addSet('ads@domain', META + 'geosite/category-ads-all.mrs', 'domain');
|
||||
const pd = domains(opts.proxyDomains), dd = domains(opts.directDomains);
|
||||
if (pd.length) providers['my-proxy@inline'] = { type: 'inline', behavior: 'domain', payload: pd.map((d) => '+.' + d) };
|
||||
if (dd.length) providers['my-direct@inline'] = { type: 'inline', behavior: 'domain', payload: dd.map((d) => '+.' + d) };
|
||||
if (Object.keys(providers).length) cfg['rule-providers'] = providers;
|
||||
|
||||
const rules = [];
|
||||
if (dd.length) rules.push('RULE-SET,my-direct@inline,DIRECT');
|
||||
if (opts.blockAds) rules.push('RULE-SET,ads@domain,REJECT');
|
||||
if (opts.blockQuic) rules.push('AND,((NETWORK,UDP),(DST-PORT,443)),REJECT');
|
||||
if (pd.length) rules.push('RULE-SET,my-proxy@inline,' + PROXY);
|
||||
services.forEach((s) => s.sets.forEach((x) => {
|
||||
rules.push('RULE-SET,' + x[0] + ',' + target[s.id] + (x[2] === 'ipcidr' ? ',no-resolve' : ''));
|
||||
}));
|
||||
rules.push('MATCH,' + (opts.finalProxy ? PROXY : 'DIRECT'));
|
||||
cfg.rules = rules;
|
||||
|
||||
const head = '# Сгенерировано: https://pinkmanxxx.github.io/pinkman_manuals/tools/mihomo/\n' +
|
||||
'# Файл для XKeen: /opt/etc/mihomo/config.yaml, затем xkeen -restart\n' +
|
||||
'# Панель управления: http://IP-роутера:9090/ui (секрет — поле secret ниже)\n';
|
||||
return { yaml: head + yaml(cfg).replace(/^\n/, ''), config: cfg, count: proxies.length };
|
||||
}
|
||||
|
||||
const api = { buildMihomo: build, MIHOMO_SERVICES: SERVICES, toYaml: (v) => yaml(v).replace(/^\n/, '') };
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
root.PM = Object.assign(root.PM || {}, api);
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,80 @@
|
||||
:root {
|
||||
--bg: #0d1117; --panel: #161b22; --panel2: #1c2330; --line: #30363d; --text: #e6edf3; --muted: #8b949e;
|
||||
--acc: #3fb950; --acc-bg: #3fb95022; --acc-ink: #0d1117; --warn: #d29922; --err: #f85149; --code: #7ee787;
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme="dark"]) {
|
||||
--bg: #f6f8fa; --panel: #ffffff; --panel2: #f0f3f6; --line: #d0d7de; --text: #1f2328; --muted: #59636e;
|
||||
--acc: #1a7f37; --acc-bg: #1a7f3718; --acc-ink: #ffffff; --warn: #9a6700; --err: #cf222e; --code: #116329;
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.55 Inter, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
a { color: var(--acc); }
|
||||
code, pre, textarea.mono { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
|
||||
|
||||
header { border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||
.bar { max-width: 1040px; margin: 0 auto; padding: 12px 16px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
||||
.brand { font-weight: 800; color: var(--text); text-decoration: none; }
|
||||
.brand span { color: var(--acc); }
|
||||
nav { display: flex; gap: 4px; margin-left: auto; }
|
||||
nav a { padding: 6px 12px; border-radius: 8px; color: var(--muted); text-decoration: none; font-weight: 600; font-size: 14px; }
|
||||
nav a.on { background: var(--acc-bg); color: var(--acc); }
|
||||
|
||||
main { max-width: 1040px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||
h1 { font-size: 26px; margin: 8px 0 4px; }
|
||||
.lead { color: var(--muted); margin: 0 0 20px; max-width: 720px; }
|
||||
.privacy { display: inline-flex; gap: 8px; align-items: center; font-size: 13px; color: var(--muted); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; margin-bottom: 24px; }
|
||||
.privacy b { color: var(--acc); }
|
||||
|
||||
section.step { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
|
||||
.step h2 { font-size: 16px; margin: 0 0 12px; display: flex; gap: 10px; align-items: center; }
|
||||
.num { width: 24px; height: 24px; border-radius: 50%; background: var(--acc); color: var(--acc-ink); display: inline-grid; place-items: center; font-size: 13px; font-weight: 800; flex: none; }
|
||||
.hint { color: var(--muted); font-size: 13px; margin: 6px 0 0; }
|
||||
|
||||
textarea, input[type=text], input[type=url] {
|
||||
width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font-size: 14px;
|
||||
}
|
||||
textarea { resize: vertical; min-height: 110px; }
|
||||
textarea:focus, input:focus { outline: 2px solid var(--acc); outline-offset: -1px; border-color: transparent; }
|
||||
label.field { display: block; font-size: 13px; font-weight: 600; margin: 14px 0 6px; }
|
||||
|
||||
.parsed { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.chip { display: inline-flex; gap: 8px; align-items: center; background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 5px 10px; font-size: 13px; max-width: 100%; }
|
||||
.chip .p { font-weight: 700; color: var(--acc); text-transform: uppercase; font-size: 11px; letter-spacing: .04em; }
|
||||
.chip .s { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chip.bad { border-color: var(--err); }
|
||||
.chip.bad .p { color: var(--err); }
|
||||
.chip.skip { border-color: var(--warn); }
|
||||
.chip.skip .p { color: var(--warn); }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 8px; }
|
||||
.opt { display: flex; gap: 10px; align-items: flex-start; padding: 9px 11px; border: 1px solid var(--line); border-radius: 10px; cursor: pointer; background: var(--bg); }
|
||||
.opt:hover { border-color: var(--muted); }
|
||||
.opt input { margin: 3px 0 0; accent-color: var(--acc); width: 16px; height: 16px; flex: none; }
|
||||
.opt small { display: block; color: var(--muted); font-size: 12px; }
|
||||
.opt.off { opacity: .5; cursor: not-allowed; }
|
||||
.sub { font-size: 13px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin: 18px 0 8px; }
|
||||
.sub:first-of-type { margin-top: 0; }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media (max-width: 640px) { .two { grid-template-columns: 1fr; } }
|
||||
|
||||
.tabs { display: flex; gap: 4px; flex-wrap: wrap; border-bottom: 1px solid var(--line); margin-bottom: 12px; }
|
||||
.tabs button { background: none; border: 0; color: var(--muted); padding: 8px 12px; font: inherit; font-weight: 600; font-size: 14px; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.tabs button.on { color: var(--text); border-bottom-color: var(--acc); }
|
||||
.out { position: relative; }
|
||||
pre.code { margin: 0; background: var(--bg); border: 1px solid var(--line); border-radius: 10px; padding: 14px; overflow: auto; max-height: 460px; color: var(--code); white-space: pre; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
button.btn { background: var(--acc); color: var(--acc-ink); border: 0; border-radius: 9px; padding: 9px 16px; font: inherit; font-weight: 700; font-size: 14px; cursor: pointer; }
|
||||
button.btn.ghost { background: var(--panel2); color: var(--text); border: 1px solid var(--line); }
|
||||
button.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.msg { font-size: 13px; margin-top: 10px; }
|
||||
.msg.err { color: var(--err); }
|
||||
.msg.warn { color: var(--warn); }
|
||||
.empty { color: var(--muted); padding: 28px; text-align: center; border: 1px dashed var(--line); border-radius: 10px; }
|
||||
ol.how { margin: 12px 0 0; padding-left: 20px; color: var(--muted); font-size: 14px; }
|
||||
ol.how li { margin: 4px 0; }
|
||||
ol.how code { color: var(--text); background: var(--panel2); padding: 1px 5px; border-radius: 5px; }
|
||||
footer { max-width: 1040px; margin: 0 auto; padding: 0 16px 40px; color: var(--muted); font-size: 13px; }
|
||||
@@ -0,0 +1,71 @@
|
||||
// Общие элементы интерфейса генераторов.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const $ = (s, el) => (el || document).querySelector(s);
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
const store = {
|
||||
get(k) { try { return localStorage.getItem('pm:' + k); } catch (e) { return null; } },
|
||||
set(k, v) { try { localStorage.setItem('pm:' + k, v); } catch (e) { /* приватный режим */ } },
|
||||
};
|
||||
|
||||
function renderParsed(el, parsed, skipped) {
|
||||
const sk = {};
|
||||
(skipped || []).forEach((s) => { sk[s.name] = s.error; });
|
||||
const chips = parsed.proxies.map((p) => {
|
||||
const why = sk[p.name];
|
||||
return '<span class="chip' + (why ? ' skip' : '') + '" title="' + esc(why || p.server + ':' + p.port) + '">' +
|
||||
'<span class="p">' + esc(p.type === 'hysteria2' ? 'hy2' : p.type) + '</span>' + esc(p.name) +
|
||||
'<span class="s">' + esc(why ? 'пропущен: ' + why : p.server + ':' + p.port) + '</span></span>';
|
||||
});
|
||||
parsed.errors.forEach((e) => {
|
||||
chips.push('<span class="chip bad" title="' + esc(e.text) + '"><span class="p">ошибка</span>строка ' + e.line +
|
||||
'<span class="s">' + esc(e.error) + '</span></span>');
|
||||
});
|
||||
el.innerHTML = chips.join('');
|
||||
}
|
||||
|
||||
function tabs(el, onChange) {
|
||||
el.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('button[data-tab]');
|
||||
if (!b) return;
|
||||
el.querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
|
||||
onChange(b.dataset.tab);
|
||||
});
|
||||
}
|
||||
|
||||
async function copy(text, btn) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (e) {
|
||||
const t = document.createElement('textarea');
|
||||
t.value = text; document.body.appendChild(t); t.select(); document.execCommand('copy'); t.remove();
|
||||
}
|
||||
const old = btn.textContent;
|
||||
btn.textContent = 'Скопировано ✓';
|
||||
setTimeout(() => { btn.textContent = old; }, 1600);
|
||||
}
|
||||
|
||||
function download(name, text) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
// Команда для SSH: записывает файлы и перезапускает XKeen.
|
||||
function routerCommand(files, dir, switchCmd) {
|
||||
const parts = ['# Вставьте целиком в SSH-консоль роутера (Entware)', '# ' + switchCmd + ' ← раскомментируйте, если сейчас работает другое ядро', 'mkdir -p ' + dir];
|
||||
Object.keys(files).forEach((name) => {
|
||||
let body = files[name];
|
||||
let tag = 'PMEOF';
|
||||
while (body.includes(tag)) tag += 'X';
|
||||
parts.push("cat > " + dir + '/' + name + " <<'" + tag + "'\n" + body.replace(/\n?$/, '\n') + tag);
|
||||
});
|
||||
parts.push('xkeen -restart');
|
||||
return parts.join('\n') + '\n';
|
||||
}
|
||||
|
||||
root.UI = { $, esc, store, renderParsed, tabs, copy, download, routerCommand };
|
||||
})(window);
|
||||
@@ -0,0 +1,178 @@
|
||||
// Сборка конфигов Xray для XKeen: 04_outbounds.json и 05_routing.json.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
// ---------- outbounds ----------
|
||||
|
||||
function clean(o) {
|
||||
Object.keys(o).forEach((k) => (o[k] === undefined || o[k] === '') && delete o[k]);
|
||||
return o;
|
||||
}
|
||||
|
||||
function stream(p) {
|
||||
const t = p.transport, s = p.tls;
|
||||
const st = { network: t.network === 'tcp' ? 'raw' : t.network, security: s.security };
|
||||
if (t.network === 'ws') st.wsSettings = clean({ path: t.path, host: t.host });
|
||||
if (t.network === 'httpupgrade') st.httpupgradeSettings = clean({ path: t.path, host: t.host });
|
||||
if (t.network === 'grpc') st.grpcSettings = { serviceName: t.serviceName, multiMode: !!t.multiMode };
|
||||
if (t.network === 'xhttp') st.xhttpSettings = clean({ path: t.path, host: t.host, mode: t.mode });
|
||||
if (t.network === 'tcp' && t.headerType === 'http') {
|
||||
st.rawSettings = { header: { type: 'http', request: { path: [t.path], headers: t.host ? { Host: [t.host] } : {} } } };
|
||||
}
|
||||
if (s.security === 'tls') {
|
||||
st.tlsSettings = clean({ serverName: s.sni, fingerprint: s.fp, alpn: s.alpn.length ? s.alpn : undefined, allowInsecure: s.insecure || undefined });
|
||||
}
|
||||
if (s.security === 'reality') {
|
||||
st.realitySettings = clean({ serverName: s.sni, fingerprint: s.fp || 'chrome', publicKey: s.pbk, shortId: s.sid, spiderX: s.spx });
|
||||
}
|
||||
return st;
|
||||
}
|
||||
|
||||
function outbound(p, tag) {
|
||||
switch (p.type) {
|
||||
case 'vless':
|
||||
return {
|
||||
tag, protocol: 'vless',
|
||||
settings: { vnext: [{ address: p.server, port: p.port, users: [clean({ id: p.uuid, encryption: 'none', flow: p.flow })] }] },
|
||||
streamSettings: stream(p),
|
||||
};
|
||||
case 'vmess':
|
||||
return {
|
||||
tag, protocol: 'vmess',
|
||||
settings: { vnext: [{ address: p.server, port: p.port, users: [{ id: p.uuid, alterId: p.alterId, security: p.cipher }] }] },
|
||||
streamSettings: stream(p),
|
||||
};
|
||||
case 'trojan':
|
||||
return {
|
||||
tag, protocol: 'trojan',
|
||||
settings: { servers: [{ address: p.server, port: p.port, password: p.password }] },
|
||||
streamSettings: stream(p),
|
||||
};
|
||||
case 'ss':
|
||||
return {
|
||||
tag, protocol: 'shadowsocks',
|
||||
settings: { servers: [{ address: p.server, port: p.port, method: p.method, password: p.password }] },
|
||||
};
|
||||
default:
|
||||
throw new Error(p.type === 'hysteria2'
|
||||
? 'Hysteria2 в XKeen работает через ядро Mihomo — возьмите генератор Mihomo'
|
||||
: 'протокол ' + p.type + ' не поддерживается Xray');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- routing ----------
|
||||
|
||||
// Геобазы, которые ставит XKeen (имена файлов в /opt/etc/xray/dat).
|
||||
const BASES = {
|
||||
zkeen: { label: 'ZKeen', hint: 'ставится XKeen по умолчанию, рекомендуется', site: 'zkeen.dat', ip: 'zkeenip.dat' },
|
||||
v2fly: { label: 'V2Fly', hint: 'нужна для отдельных сервисов и блокировки рекламы', site: 'geosite_v2fly.dat', ip: 'geoip_v2fly.dat' },
|
||||
refilter: { label: 'Re:filter', hint: 'большой список заблокированного', site: 'geosite_refilter.dat', ip: 'geoip_refilter.dat' },
|
||||
};
|
||||
|
||||
// Сервис → правила по базам. d: домены, i: IP. Берутся все доступные базы.
|
||||
const SERVICES = [
|
||||
{ id: 'blocked', label: 'Заблокированное в России', on: true,
|
||||
zkeen: { d: ['domains', 'other', 'politic'] }, refilter: { d: ['refilter'], i: ['refilter'] } },
|
||||
{ id: 'youtube', label: 'YouTube', on: true, zkeen: { d: ['youtube'], i: ['youtube'] }, v2fly: { d: ['youtube'] } },
|
||||
{ id: 'telegram', label: 'Telegram', on: true, zkeen: { i: ['telegram'] }, v2fly: { d: ['telegram'] } },
|
||||
{ id: 'meta', label: 'Instagram, Facebook, WhatsApp', on: true, zkeen: { i: ['meta'] }, v2fly: { d: ['meta'] } },
|
||||
{ id: 'discord', label: 'Discord', on: true, zkeen: { i: ['discord'] }, v2fly: { d: ['discord'] } },
|
||||
{ id: 'twitter', label: 'X (Twitter)', on: true, v2fly: { d: ['twitter'] } },
|
||||
{ id: 'ai', label: 'ChatGPT, Claude и другие ИИ', on: true, v2fly: { d: ['category-ai-!cn'] } },
|
||||
{ id: 'cdn', label: 'Зарубежные CDN и хостинги', hint: 'Cloudflare, Hetzner, DigitalOcean… — их часто замедляют', on: true,
|
||||
zkeen: { i: ['akamai', 'amazon', 'arelion', 'azure', 'bunnycdn', 'cdn77', 'cloudflare', 'cogent', 'colocrossing', 'contabo',
|
||||
'datacamp', 'digitalocean', 'fastly', 'frantech', 'gcore', 'hetzner', 'leaseweb', 'linode', 'liquidweb', 'mega', 'melbicom',
|
||||
'oracle', 'ovh', 'scaleway', 'vodafone', 'vultr'] } },
|
||||
{ id: 'tiktok', label: 'TikTok', on: false, v2fly: { d: ['tiktok'] } },
|
||||
{ id: 'spotify', label: 'Spotify', on: false, v2fly: { d: ['spotify'] } },
|
||||
{ id: 'netflix', label: 'Netflix', on: false, v2fly: { d: ['netflix'] } },
|
||||
{ id: 'github', label: 'GitHub', on: false, v2fly: { d: ['github'] } },
|
||||
{ id: 'twitch', label: 'Twitch', on: false, v2fly: { d: ['twitch'] } },
|
||||
];
|
||||
|
||||
function available(service, bases) {
|
||||
return bases.some((b) => service[b]);
|
||||
}
|
||||
|
||||
function domains(text) {
|
||||
return (text || '').split(/[\s,]+/).map((d) => d.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^\*\./, ''))
|
||||
.filter((d) => /^[a-z0-9.-]+\.[a-z]{2,}$/.test(d));
|
||||
}
|
||||
|
||||
function routing(opts, proxyTag) {
|
||||
const bases = opts.bases || ['zkeen'];
|
||||
const chosen = SERVICES.filter((s) => (opts.services || SERVICES.filter((x) => x.on).map((x) => x.id)).includes(s.id) && available(s, bases));
|
||||
const dom = [], ip = [];
|
||||
chosen.forEach((s) => bases.forEach((b) => {
|
||||
const r = s[b];
|
||||
if (!r) return;
|
||||
(r.d || []).forEach((c) => dom.push('ext:' + BASES[b].site + ':' + c));
|
||||
(r.i || []).forEach((c) => ip.push('ext:' + BASES[b].ip + ':' + c));
|
||||
}));
|
||||
const uniq = (a) => Array.from(new Set(a));
|
||||
|
||||
// Политика xkeen_full: всё с этих входов — через прокси (как в стандартном конфиге XKeen).
|
||||
const rules = [{ inboundTag: ['force-proxy-redirect', 'force-proxy-tproxy'], outboundTag: proxyTag }];
|
||||
const dd = domains(opts.directDomains), pd = domains(opts.proxyDomains);
|
||||
if (dd.length) rules.push({ domain: dd.map((d) => 'domain:' + d), outboundTag: 'direct' });
|
||||
if (opts.blockAds && bases.includes('v2fly')) rules.push({ domain: ['ext:geosite_v2fly.dat:category-ads-all'], outboundTag: 'block' });
|
||||
if (opts.blockQuic) rules.push({ network: 'udp', port: '443', outboundTag: 'block' });
|
||||
if (chosen.some((s) => s.id === 'discord') && bases.includes('zkeen')) {
|
||||
// Голосовые каналы Discord ходят по UDP на эти порты.
|
||||
rules.push({ network: 'udp', port: '19200-19400,50000-50100', ip: ['ext:zkeenip.dat:discord'], outboundTag: proxyTag });
|
||||
}
|
||||
if (pd.length) rules.push({ domain: pd.map((d) => 'domain:' + d), outboundTag: proxyTag });
|
||||
if (dom.length) rules.push({ domain: uniq(dom), outboundTag: proxyTag });
|
||||
if (ip.length) rules.push({ ip: uniq(ip), outboundTag: proxyTag });
|
||||
rules.push({ network: 'tcp,udp', outboundTag: opts.finalProxy ? proxyTag : 'direct' });
|
||||
|
||||
return { routing: { domainStrategy: 'IPIfNonMatch', rules } };
|
||||
}
|
||||
|
||||
// ---------- сборка ----------
|
||||
|
||||
// opts: tag, services[], bases[], blockAds, blockQuic, finalProxy, proxyDomains, directDomains
|
||||
function build(proxies, opts) {
|
||||
opts = opts || {};
|
||||
const mainTag = opts.tag || 'vless-reality';
|
||||
const skipped = [];
|
||||
const outs = [];
|
||||
proxies.forEach((p) => {
|
||||
try {
|
||||
const tag = outs.length === 0 ? mainTag : mainTag + '-' + (outs.length + 1);
|
||||
outs.push(outbound(p, tag));
|
||||
} catch (e) {
|
||||
skipped.push({ name: p.name, error: e.message });
|
||||
}
|
||||
});
|
||||
if (!outs.length) throw new Error(skipped.length ? skipped[0].error : 'Добавьте хотя бы одну ссылку.');
|
||||
|
||||
// Несколько серверов — балансировщик по пингу выбирает лучший.
|
||||
const multi = outs.length > 1;
|
||||
let obs = null;
|
||||
const proxyTarget = mainTag;
|
||||
if (multi) {
|
||||
obs = { observatory: { subjectSelector: [mainTag], probeUrl: 'https://www.gstatic.com/generate_204', probeInterval: '1m', enableConcurrency: true } };
|
||||
}
|
||||
outs.push({ tag: 'direct', protocol: 'freedom' });
|
||||
outs.push({ tag: 'block', protocol: 'blackhole', settings: { response: { type: 'http' } } });
|
||||
|
||||
const rt = routing(opts, proxyTarget);
|
||||
if (multi) {
|
||||
// Правила на прокси ведут в балансировщик, а не в один сервер.
|
||||
rt.routing.balancers = [{ tag: 'proxy-balancer', selector: [mainTag], strategy: { type: 'leastPing' }, fallbackTag: mainTag }];
|
||||
rt.routing.rules.forEach((r) => {
|
||||
if (r.outboundTag === mainTag) { delete r.outboundTag; r.balancerTag = 'proxy-balancer'; }
|
||||
});
|
||||
}
|
||||
const files = {
|
||||
'04_outbounds.json': Object.assign({ outbounds: outs }, obs || {}),
|
||||
'05_routing.json': rt,
|
||||
};
|
||||
return { files, skipped, count: outs.length - 2 };
|
||||
}
|
||||
|
||||
const api = { buildXray: build, XRAY_SERVICES: SERVICES, XRAY_BASES: BASES, xrayServiceAvailable: available };
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
root.PM = Object.assign(root.PM || {}, api);
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Генератор Mihomo для XKeen</title>
|
||||
<meta name="description" content="Готовый config.yaml Mihomo для XKeen из ссылок vless, hy2, trojan, ss или подписки — прямо в браузере">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🕶️</text></svg>">
|
||||
<link rel="stylesheet" href="../lib/ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="bar">
|
||||
<a class="brand" href="https://github.com/PinkmanXXX/pinkman_manuals">Pinkman <span>Manuals</span></a>
|
||||
<nav><a href="../xray/">Xray</a><a class="on" href="../mihomo/">Mihomo</a></nav>
|
||||
</div></header>
|
||||
|
||||
<main>
|
||||
<h1>Конфиг Mihomo для XKeen</h1>
|
||||
<p class="lead">Ссылки на серверы или подписка — и готовый <code>config.yaml</code> с правилами для нужных сервисов,
|
||||
автовыбором быстрого сервера и веб-панелью. Плюс команда, которая сама положит его на роутер.</p>
|
||||
<div class="privacy">🔒 <span>Всё считается <b>в вашем браузере</b> — ссылки никуда не отправляются</span></div>
|
||||
|
||||
<section class="step">
|
||||
<h2><span class="num">1</span>Серверы</h2>
|
||||
<textarea id="links" class="mono" spellcheck="false" placeholder="vless://… hy2://… По одной на строку. Можно вставить подписку в base64 целиком."></textarea>
|
||||
<div class="parsed" id="parsed"></div>
|
||||
<label class="field" for="sub">Или ссылка на подписку</label>
|
||||
<input type="url" id="sub" class="mono" spellcheck="false" placeholder="https://…">
|
||||
<p class="hint">Подписку роутер скачает сам и будет обновлять каждый час. Поддерживаются vless (REALITY, TLS, ws, gRPC, xhttp),
|
||||
vmess, trojan, shadowsocks и Hysteria2.</p>
|
||||
</section>
|
||||
|
||||
<section class="step">
|
||||
<h2><span class="num">2</span>Что пускать через прокси</h2>
|
||||
<div class="sub">Сервисы</div>
|
||||
<div class="grid" id="services"></div>
|
||||
<div class="sub">Дополнительно</div>
|
||||
<div class="grid">
|
||||
<label class="opt"><input type="checkbox" id="perService"><span>Свой выбор сервера для каждого сервиса<small>в панели можно пустить YouTube через один сервер, а Telegram — через другой</small></span></label>
|
||||
<label class="opt"><input type="checkbox" id="blockQuic" checked><span>Блокировать QUIC<small>видео через прокси грузится стабильнее</small></span></label>
|
||||
<label class="opt"><input type="checkbox" id="blockAds"><span>Блокировать рекламу</span></label>
|
||||
<label class="opt"><input type="checkbox" id="finalProxy"><span>Всё остальное — тоже через прокси<small>иначе остальное идёт напрямую</small></span></label>
|
||||
</div>
|
||||
<div class="two">
|
||||
<div><label class="field" for="proxyDomains">Свои сайты через прокси</label>
|
||||
<textarea id="proxyDomains" class="mono" rows="3" style="min-height:76px" placeholder="example.com chat.example.org"></textarea></div>
|
||||
<div><label class="field" for="directDomains">Свои сайты напрямую</label>
|
||||
<textarea id="directDomains" class="mono" rows="3" style="min-height:76px" placeholder="mybank.ru"></textarea></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step">
|
||||
<h2><span class="num">3</span>Готовый конфиг</h2>
|
||||
<div id="result"><div class="empty">Добавьте ссылку или подписку в шаг 1 — здесь появится конфиг.</div></div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>Код открыт: <a href="https://github.com/PinkmanXXX/pinkman_manuals/tree/main/tools">github.com/PinkmanXXX/pinkman_manuals</a> · Проверено на Mihomo 1.19 и XKeen 2.0</footer>
|
||||
|
||||
<script src="../lib/links.js"></script>
|
||||
<script src="../lib/mihomo.js"></script>
|
||||
<script src="../lib/ui.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const { $, esc, store } = UI;
|
||||
const S = PM.MIHOMO_SERVICES;
|
||||
const saved = JSON.parse(store.get('mihomo') || '{}');
|
||||
// Секрет панели создаётся один раз на странице, чтобы не менялся при каждой правке.
|
||||
const secret = Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
let tab = 'cmd', yaml = '';
|
||||
|
||||
$('#services').innerHTML = S.map((s) =>
|
||||
'<label class="opt"><input type="checkbox" name="svc" value="' + s.id + '"' +
|
||||
((saved.services ? saved.services.includes(s.id) : s.on) ? ' checked' : '') + '>' +
|
||||
'<span>' + esc(s.label) + (s.hint ? '<small>' + esc(s.hint) + '</small>' : '') + '</span></label>').join('');
|
||||
['perService', 'blockQuic', 'blockAds', 'finalProxy'].forEach((k) => { if (k in saved) $('#' + k).checked = saved[k]; });
|
||||
|
||||
function opts() {
|
||||
return {
|
||||
services: Array.from(document.querySelectorAll('input[name=svc]:checked')).map((i) => i.value),
|
||||
subscription: /^https?:\/\/\S+$/.test($('#sub').value.trim()) ? $('#sub').value.trim() : '',
|
||||
perService: $('#perService').checked, blockQuic: $('#blockQuic').checked, blockAds: $('#blockAds').checked,
|
||||
finalProxy: $('#finalProxy').checked, proxyDomains: $('#proxyDomains').value, directDomains: $('#directDomains').value,
|
||||
secret,
|
||||
};
|
||||
}
|
||||
|
||||
function show() {
|
||||
$('#out').textContent = tab === 'cmd' ? UI.routerCommand({ 'config.yaml': yaml }, '/opt/etc/mihomo', 'xkeen -mihomo') : yaml;
|
||||
$('#dl').style.display = tab === 'cmd' ? 'none' : '';
|
||||
}
|
||||
|
||||
function render() {
|
||||
const o = opts();
|
||||
store.set('mihomo', JSON.stringify({ services: o.services, perService: o.perService, blockQuic: o.blockQuic, blockAds: o.blockAds, finalProxy: o.finalProxy }));
|
||||
const parsed = PM.parseText($('#links').value);
|
||||
UI.renderParsed($('#parsed'), parsed, []);
|
||||
let r = null, err = '';
|
||||
if (parsed.proxies.length || o.subscription) {
|
||||
try { r = PM.buildMihomo(parsed.proxies, o); } catch (e) { err = e.message; }
|
||||
}
|
||||
if ($('#sub').value.trim() && !o.subscription) err = 'Подписка должна начинаться с http:// или https://';
|
||||
if (!r) {
|
||||
yaml = '';
|
||||
$('#result').innerHTML = err ? '<div class="msg err">' + esc(err) + '</div>' : '<div class="empty">Добавьте ссылку или подписку в шаг 1 — здесь появится конфиг.</div>';
|
||||
return;
|
||||
}
|
||||
yaml = r.yaml;
|
||||
if (!$('#out')) {
|
||||
$('#result').innerHTML =
|
||||
'<div class="tabs" id="tabs"><button data-tab="cmd" class="on">Команда для роутера</button><button data-tab="yaml">config.yaml</button></div>' +
|
||||
'<div class="out"><pre class="code" id="out"></pre></div>' +
|
||||
'<div class="actions"><button class="btn" id="cp">Скопировать</button><button class="btn ghost" id="dl">Скачать config.yaml</button></div>' +
|
||||
'<ol class="how"><li>Подключитесь к роутеру: <code>ssh root@192.168.1.1 -p 222</code></li>' +
|
||||
'<li>Если XKeen сейчас работает на Xray, сначала выполните <code>xkeen -mihomo</code></li>' +
|
||||
'<li>Вставьте команду целиком и нажмите Enter — конфиг запишется, XKeen перезапустится.</li>' +
|
||||
'<li>Панель управления: <code>http://192.168.1.1:9090/ui</code>, секрет: <code id="sec"></code></li></ol>';
|
||||
UI.tabs($('#tabs'), (t) => { tab = t; show(); });
|
||||
$('#cp').onclick = (e) => UI.copy($('#out').textContent, e.target);
|
||||
$('#dl').onclick = () => UI.download('config.yaml', yaml);
|
||||
}
|
||||
$('#sec').textContent = secret;
|
||||
show();
|
||||
}
|
||||
|
||||
document.addEventListener('input', render);
|
||||
document.addEventListener('change', render);
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
// Тесты разбора ссылок и сборки конфигов: node --test tools/test/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { parseLink, parseText } = require('../lib/links.js');
|
||||
const { buildXray } = require('../lib/xray.js');
|
||||
const { buildMihomo } = require('../lib/mihomo.js');
|
||||
|
||||
const REALITY = 'vless://5f2c1e7a-0b3d-4c6e-9a8f-1234567890ab@203.0.113.10:443?type=tcp&security=reality&pbk=Fj98Liz-8fJCAaK_hcsGDJRSd9ztkw0TDR2v4kqK7TA&fp=chrome&sni=www.microsoft.com&sid=2831cdcf4a278ac2&spx=%2F&flow=xtls-rprx-vision#%D0%93%D0%B5%D1%80%D0%BC%D0%B0%D0%BD%D0%B8%D1%8F';
|
||||
const WS_TLS = 'vless://11111111-2222-3333-4444-555555555555@cdn.example.com:443?type=ws&security=tls&path=%2Fws%3Fed%3D2048&host=cdn.example.com&sni=cdn.example.com&alpn=h2%2Chttp%2F1.1#ws';
|
||||
const XHTTP = 'vless://11111111-2222-3333-4444-555555555555@x.example.com:443?type=xhttp&security=reality&pbk=abc&sid=01&sni=www.apple.com&path=%2Fx&mode=packet-up#xhttp';
|
||||
const GRPC = 'trojan://p%40ss@t.example.com:443?type=grpc&serviceName=gun&security=tls&sni=t.example.com#grpc';
|
||||
const VMESS = 'vmess://' + Buffer.from(JSON.stringify({ v: '2', ps: 'vm', add: 'v.example.com', port: '8443', id: '11111111-2222-3333-4444-555555555555', aid: '0', scy: 'auto', net: 'ws', host: 'v.example.com', path: '/vm', tls: 'tls', sni: 'v.example.com' })).toString('base64');
|
||||
const SS_B64 = 'ss://' + Buffer.from('chacha20-ietf-poly1305:secret').toString('base64url') + '@198.51.100.7:8388#ss';
|
||||
const SS_2022 = 'ss://2022-blake3-aes-128-gcm:YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D@198.51.100.8:443#ss22';
|
||||
const SS_OLD = 'ss://' + Buffer.from('aes-256-gcm:pw@198.51.100.9:1234').toString('base64') + '#old';
|
||||
const HY2 = 'hy2://admin:9c1e5b@hy.example.com:443/?sni=hy.example.com&insecure=1&pinSHA256=AB:CD:EF&obfs=salamander&obfs-password=x#hy';
|
||||
|
||||
test('REALITY', () => {
|
||||
const p = parseLink(REALITY);
|
||||
assert.equal(p.type, 'vless');
|
||||
assert.equal(p.name, 'Германия');
|
||||
assert.equal(p.tls.security, 'reality');
|
||||
assert.equal(p.tls.pbk, 'Fj98Liz-8fJCAaK_hcsGDJRSd9ztkw0TDR2v4kqK7TA');
|
||||
assert.equal(p.tls.spx, '/');
|
||||
assert.equal(p.flow, 'xtls-rprx-vision');
|
||||
});
|
||||
|
||||
test('WS + TLS с путём и ALPN', () => {
|
||||
const p = parseLink(WS_TLS);
|
||||
assert.equal(p.transport.path, '/ws?ed=2048');
|
||||
assert.deepEqual(p.tls.alpn, ['h2', 'http/1.1']);
|
||||
});
|
||||
|
||||
test('xhttp, gRPC trojan, vmess', () => {
|
||||
assert.equal(parseLink(XHTTP).transport.mode, 'packet-up');
|
||||
const g = parseLink(GRPC);
|
||||
assert.equal(g.password, 'p@ss');
|
||||
assert.equal(g.transport.serviceName, 'gun');
|
||||
const v = parseLink(VMESS);
|
||||
assert.equal(v.port, 8443);
|
||||
assert.equal(v.transport.path, '/vm');
|
||||
});
|
||||
|
||||
test('Shadowsocks во всех трёх форматах', () => {
|
||||
assert.equal(parseLink(SS_B64).method, 'chacha20-ietf-poly1305');
|
||||
assert.equal(parseLink(SS_2022).password, 'YctPZ6U7xPPcU+gp3u+0tx/tRizJN9K8y+uKlW2qjlI=');
|
||||
const o = parseLink(SS_OLD);
|
||||
assert.equal(o.server, '198.51.100.9');
|
||||
assert.equal(o.port, 1234);
|
||||
});
|
||||
|
||||
test('Hysteria2: логин:пароль и отпечаток', () => {
|
||||
const h = parseLink(HY2);
|
||||
assert.equal(h.password, 'admin:9c1e5b');
|
||||
assert.equal(h.pinSHA256, 'abcdef');
|
||||
assert.equal(h.obfs, 'salamander');
|
||||
});
|
||||
|
||||
test('подписка в base64 и ошибки по строкам', () => {
|
||||
const sub = Buffer.from([REALITY, 'garbage', 'tuic://x@y:1', HY2].join('\n')).toString('base64');
|
||||
const r = parseText(sub);
|
||||
assert.equal(r.proxies.length, 2);
|
||||
assert.equal(r.errors.length, 2);
|
||||
assert.match(r.errors[1].error, /tuic/);
|
||||
});
|
||||
|
||||
test('одинаковые имена становятся уникальными', () => {
|
||||
const r = parseText(REALITY + '\n' + REALITY);
|
||||
assert.deepEqual(r.proxies.map((p) => p.name), ['Германия', 'Германия 2']);
|
||||
});
|
||||
|
||||
test('битые ссылки дают понятную ошибку', () => {
|
||||
assert.throws(() => parseLink('vless://@h:443'), /UUID/);
|
||||
assert.throws(() => parseLink('vless://id@h:443?security=reality'), /pbk/);
|
||||
assert.throws(() => parseLink('hy2://pw@h:443-500'), /диапазон/);
|
||||
assert.throws(() => parseLink('vless://id@h:99999'), /порт|Invalid/);
|
||||
});
|
||||
|
||||
test('Xray: теги, балансировщик и маршрутизация', () => {
|
||||
const one = buildXray(parseText(REALITY).proxies, {});
|
||||
assert.equal(one.files['04_outbounds.json'].outbounds[0].tag, 'vless-reality');
|
||||
assert.equal(one.files['04_outbounds.json'].outbounds[0].streamSettings.realitySettings.publicKey, 'Fj98Liz-8fJCAaK_hcsGDJRSd9ztkw0TDR2v4kqK7TA');
|
||||
const rules = one.files['05_routing.json'].routing.rules;
|
||||
assert.equal(rules[rules.length - 1].outboundTag, 'direct');
|
||||
const multi = buildXray(parseText([REALITY, WS_TLS, HY2].join('\n')).proxies, {});
|
||||
assert.equal(multi.count, 2);
|
||||
assert.equal(multi.skipped.length, 1);
|
||||
assert.ok(multi.files['05_routing.json'].routing.balancers);
|
||||
});
|
||||
|
||||
test('Xray: сервисы без нужной геобазы не попадают в правила', () => {
|
||||
const r = buildXray(parseText(REALITY).proxies, { bases: ['zkeen'], services: ['twitter', 'youtube'] });
|
||||
const s = JSON.stringify(r.files['05_routing.json']);
|
||||
assert.ok(!s.includes('twitter'));
|
||||
assert.ok(s.includes('ext:zkeen.dat:youtube'));
|
||||
});
|
||||
|
||||
test('Mihomo: порты XKeen и прокси', () => {
|
||||
const r = buildMihomo(parseText([REALITY, HY2, SS_2022].join('\n')).proxies, { secret: 'x' });
|
||||
assert.equal(r.config['redir-port'], 5000);
|
||||
assert.equal(r.config['tproxy-port'], 5001);
|
||||
assert.equal(r.config['routing-mark'], 255);
|
||||
assert.equal(r.config.proxies.length, 3);
|
||||
assert.equal(r.config.proxies[1].fingerprint, 'abcdef');
|
||||
assert.match(r.yaml, /name: "Германия"/);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Генератор Xray для XKeen</title>
|
||||
<meta name="description" content="Готовые 04_outbounds.json и 05_routing.json для XKeen из ссылки vless://, vmess://, trojan://, ss:// — прямо в браузере">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🕶️</text></svg>">
|
||||
<link rel="stylesheet" href="../lib/ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="bar">
|
||||
<a class="brand" href="https://github.com/PinkmanXXX/pinkman_manuals">Pinkman <span>Manuals</span></a>
|
||||
<nav><a class="on" href="../xray/">Xray</a><a href="../mihomo/">Mihomo</a></nav>
|
||||
</div></header>
|
||||
|
||||
<main>
|
||||
<h1>Конфиг Xray для XKeen</h1>
|
||||
<p class="lead">Вставьте ссылку на свой сервер — получите готовые <code>04_outbounds.json</code> и <code>05_routing.json</code>
|
||||
и одну команду, которая сама положит их на роутер.</p>
|
||||
<div class="privacy">🔒 <span>Всё считается <b>в вашем браузере</b> — ссылки никуда не отправляются</span></div>
|
||||
|
||||
<section class="step">
|
||||
<h2><span class="num">1</span>Ссылки на серверы</h2>
|
||||
<textarea id="links" class="mono" spellcheck="false" placeholder="vless://… По одной на строку. Можно вставить подписку в base64 целиком."></textarea>
|
||||
<div class="parsed" id="parsed"></div>
|
||||
<p class="hint">Поддерживаются vless (REALITY, TLS, ws, gRPC, xhttp, httpupgrade), vmess, trojan и shadowsocks.
|
||||
Несколько серверов — Xray сам выберет самый быстрый. Hysteria2 работает через <a href="../mihomo/">Mihomo</a>.</p>
|
||||
</section>
|
||||
|
||||
<section class="step">
|
||||
<h2><span class="num">2</span>Что пускать через прокси</h2>
|
||||
<div class="sub">Геобазы на роутере</div>
|
||||
<div class="grid" id="bases"></div>
|
||||
<p class="hint">Какие базы вы выбрали при установке XKeen. Не уверены — оставьте ZKeen: её XKeen ставит по умолчанию.</p>
|
||||
<div class="sub">Сервисы</div>
|
||||
<div class="grid" id="services"></div>
|
||||
<div class="sub">Дополнительно</div>
|
||||
<div class="grid">
|
||||
<label class="opt"><input type="checkbox" id="blockQuic" checked><span>Блокировать QUIC<small>видео через прокси грузится стабильнее</small></span></label>
|
||||
<label class="opt" id="adsOpt"><input type="checkbox" id="blockAds"><span>Блокировать рекламу<small>нужна база V2Fly</small></span></label>
|
||||
<label class="opt"><input type="checkbox" id="finalProxy"><span>Всё остальное — тоже через прокси<small>иначе остальное идёт напрямую</small></span></label>
|
||||
</div>
|
||||
<div class="two">
|
||||
<div><label class="field" for="proxyDomains">Свои сайты через прокси</label>
|
||||
<textarea id="proxyDomains" class="mono" rows="3" style="min-height:76px" placeholder="example.com chat.example.org"></textarea></div>
|
||||
<div><label class="field" for="directDomains">Свои сайты напрямую</label>
|
||||
<textarea id="directDomains" class="mono" rows="3" style="min-height:76px" placeholder="mybank.ru"></textarea></div>
|
||||
</div>
|
||||
<label class="field" for="tag">Тег основного сервера</label>
|
||||
<input type="text" id="tag" value="vless-reality" spellcheck="false">
|
||||
<p class="hint">XKeen по умолчанию ожидает <code>vless-reality</code> — менять стоит, только если вы правили правила вручную.</p>
|
||||
</section>
|
||||
|
||||
<section class="step">
|
||||
<h2><span class="num">3</span>Готовый конфиг</h2>
|
||||
<div id="result"><div class="empty">Вставьте ссылку в шаг 1 — здесь появятся файлы.</div></div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>Код открыт: <a href="https://github.com/PinkmanXXX/pinkman_manuals/tree/main/tools">github.com/PinkmanXXX/pinkman_manuals</a> · Проверено на Xray 26 и XKeen 2.0</footer>
|
||||
|
||||
<script src="../lib/links.js"></script>
|
||||
<script src="../lib/xray.js"></script>
|
||||
<script src="../lib/ui.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const { $, esc, store } = UI;
|
||||
const S = PM.XRAY_SERVICES, B = PM.XRAY_BASES;
|
||||
const saved = JSON.parse(store.get('xray') || '{}');
|
||||
let tab = 'cmd', files = null;
|
||||
|
||||
$('#bases').innerHTML = Object.keys(B).map((k) =>
|
||||
'<label class="opt"><input type="checkbox" name="base" value="' + k + '"' + ((saved.bases || ['zkeen']).includes(k) ? ' checked' : '') + '>' +
|
||||
'<span>' + esc(B[k].label) + '<small>' + esc(B[k].hint) + '</small></span></label>').join('');
|
||||
$('#services').innerHTML = S.map((s) =>
|
||||
'<label class="opt" data-svc="' + s.id + '"><input type="checkbox" name="svc" value="' + s.id + '"' +
|
||||
((saved.services ? saved.services.includes(s.id) : s.on) ? ' checked' : '') + '>' +
|
||||
'<span>' + esc(s.label) + '<small>' + esc(s.hint || '') + '</small></span></label>').join('');
|
||||
['blockQuic', 'blockAds', 'finalProxy'].forEach((k) => { if (k in saved) $('#' + k).checked = saved[k]; });
|
||||
if (saved.tag) $('#tag').value = saved.tag;
|
||||
|
||||
function opts() {
|
||||
const checked = (n) => Array.from(document.querySelectorAll('input[name=' + n + ']:checked')).map((i) => i.value);
|
||||
return {
|
||||
bases: checked('base'), services: checked('svc'), tag: $('#tag').value.trim() || 'vless-reality',
|
||||
blockQuic: $('#blockQuic').checked, blockAds: $('#blockAds').checked, finalProxy: $('#finalProxy').checked,
|
||||
proxyDomains: $('#proxyDomains').value, directDomains: $('#directDomains').value,
|
||||
};
|
||||
}
|
||||
|
||||
function syncAvailability(o) {
|
||||
S.forEach((s) => {
|
||||
const el = document.querySelector('[data-svc="' + s.id + '"]');
|
||||
const ok = PM.xrayServiceAvailable(s, o.bases);
|
||||
const box = el.querySelector('input');
|
||||
// Без нужной базы галочку снимаем, но помним выбор — вернём, когда база появится.
|
||||
if (!ok && !box.disabled) { box.dataset.want = box.checked ? '1' : ''; box.checked = false; }
|
||||
if (ok && box.disabled) box.checked = box.dataset.want === '1';
|
||||
el.classList.toggle('off', !ok);
|
||||
box.disabled = !ok;
|
||||
const need = Object.keys(B).filter((b) => s[b]).map((b) => B[b].label).join(' или ');
|
||||
el.querySelector('small').textContent = ok ? (s.hint || '') : 'нужна база ' + need;
|
||||
});
|
||||
const ads = o.bases.includes('v2fly');
|
||||
if (!ads && !$('#blockAds').disabled) { $('#blockAds').dataset.want = $('#blockAds').checked ? '1' : ''; $('#blockAds').checked = false; }
|
||||
if (ads && $('#blockAds').disabled) $('#blockAds').checked = $('#blockAds').dataset.want === '1';
|
||||
$('#adsOpt').classList.toggle('off', !ads);
|
||||
$('#blockAds').disabled = !ads;
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (!files) return;
|
||||
const names = Object.keys(files);
|
||||
const body = tab === 'cmd'
|
||||
? UI.routerCommand(files, '/opt/etc/xray/configs', 'xkeen -xray')
|
||||
: files[tab];
|
||||
$('#out').textContent = body;
|
||||
$('#dl').style.display = tab === 'cmd' ? 'none' : '';
|
||||
$('#how').style.display = tab === 'cmd' ? '' : 'none';
|
||||
return names;
|
||||
}
|
||||
|
||||
function render() {
|
||||
syncAvailability(opts());
|
||||
const o = opts();
|
||||
store.set('xray', JSON.stringify({ bases: o.bases, services: o.services, tag: o.tag, blockQuic: o.blockQuic, blockAds: o.blockAds, finalProxy: o.finalProxy }));
|
||||
const parsed = PM.parseText($('#links').value);
|
||||
let r = null, err = '';
|
||||
if (parsed.proxies.length) {
|
||||
try { r = PM.buildXray(parsed.proxies, o); } catch (e) { err = e.message; }
|
||||
}
|
||||
UI.renderParsed($('#parsed'), parsed, r ? r.skipped : []);
|
||||
if (!r) {
|
||||
files = null;
|
||||
$('#result').innerHTML = err ? '<div class="msg err">' + esc(err) + '</div>' : '<div class="empty">Вставьте ссылку в шаг 1 — здесь появятся файлы.</div>';
|
||||
return;
|
||||
}
|
||||
files = {};
|
||||
Object.keys(r.files).forEach((n) => { files[n] = JSON.stringify(r.files[n], null, 2) + '\n'; });
|
||||
if (!$('#out')) {
|
||||
$('#result').innerHTML =
|
||||
'<div class="tabs" id="tabs"><button data-tab="cmd" class="on">Команда для роутера</button>' +
|
||||
'<button data-tab="04_outbounds.json">04_outbounds.json</button><button data-tab="05_routing.json">05_routing.json</button></div>' +
|
||||
'<div class="out"><pre class="code" id="out"></pre></div>' +
|
||||
'<div class="actions"><button class="btn" id="cp">Скопировать</button><button class="btn ghost" id="dl">Скачать файл</button></div>' +
|
||||
'<ol class="how" id="how"><li>Подключитесь к роутеру: <code>ssh root@192.168.1.1 -p 222</code></li>' +
|
||||
'<li>Вставьте команду целиком и нажмите Enter — файлы запишутся, XKeen перезапустится.</li>' +
|
||||
'<li>Готово. Проверить: <code>xkeen -status</code></li></ol><div id="note"></div>';
|
||||
UI.tabs($('#tabs'), (t) => { tab = t; show(); });
|
||||
$('#cp').onclick = (e) => UI.copy($('#out').textContent, e.target);
|
||||
$('#dl').onclick = () => UI.download(tab, files[tab]);
|
||||
}
|
||||
show();
|
||||
const notes = [];
|
||||
if (r.count > 1) notes.push('Серверов: ' + r.count + '. Xray будет каждую минуту проверять их и выбирать самый быстрый.');
|
||||
if (r.skipped.length) notes.push('Пропущено: ' + r.skipped.map((s) => s.name + ' — ' + s.error).join('; '));
|
||||
$('#note').innerHTML = notes.map((n) => '<div class="msg warn">' + esc(n) + '</div>').join('');
|
||||
}
|
||||
|
||||
document.addEventListener('input', render);
|
||||
document.addEventListener('change', render);
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user