- 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.
240 lines
10 KiB
JavaScript
240 lines
10 KiB
JavaScript
// Разбор ссылок 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);
|