Матрица протоколов 3X-UI: все протоколы × клиенты, исправление allowInsecure для Xray 26

Проверено настоящим подключением (3X-UI 3.8.5, Xray 26.6.27, Mihomo 1.19.31,
sing-box 1.14.2, amneziawg-tools 3.1):

  протокол              Xray   Mihomo  sing-box
  VLESS REALITY          ✓      ✓       ✓
  VLESS XHTTP REALITY    ✓      ✓       —
  VLESS WS + TLS         ✓      ✓       ✓
  Trojan gRPC + TLS      ✓      ✓       ✓
  VMess WS + TLS         ✓      ✓       ✓
  Shadowsocks 2022       ✓      ✓       ✓
  Hysteria2              ✓      ✓       ✓
  TUIC v5                —      ✓       ✓
  WireGuard              ✓      ✓       ✓
  AmneziaWG классика     —      ✓       —    (официальный клиент 3.1: ✗)
  AmneziaWG 3.1          —      ✗       —    (официальный клиент 3.1: ✓)
  MTProto                mtg doctor: все DC Telegram доступны

- Генератор Xray: allowInsecure удалён в Xray 26 и ронял конфиг — теперь
  отпечаток сертификата (pcs/pinSHA256) или предупреждение на странице
- Генераторы: Hysteria2 и WireGuard для Xray; TUIC, WireGuard, AmneziaWG для Mihomo
- tools/lib/singbox.js — сборка конфигов sing-box (для будущей единой подписки)
- tests/matrix — стенд матрицы для прогона на каждой версии
This commit is contained in:
itsnotkubrick
2026-09-27 17:03:56 +03:00
parent 2b938baa31
commit 78935727be
12 changed files with 410 additions and 13 deletions
+83 -2
View File
@@ -78,6 +78,8 @@
s.fp = q.get('fp') || (security === 'reality' ? 'chrome' : '');
s.alpn = splitList(q.get('alpn'));
s.insecure = truthy(q.get('allowInsecure')) || truthy(q.get('insecure'));
// Отпечаток сертификата: pcs — имя параметра в ссылках Xray 26, pinSHA256 — в ссылках Hysteria.
s.pin = (q.get('pcs') || q.get('pinSHA256') || '').replace(/:/g, '').toLowerCase();
}
if (security === 'reality') {
s.pbk = q.get('pbk') || '';
@@ -194,9 +196,88 @@
}, hp);
}
const PARSERS = { 'vless': parseVless, 'vmess': parseVmess, 'trojan': parseTrojan, 'ss': parseSs, 'hy2': parseHy2, 'hysteria2': parseHy2 };
function parseTuic(link) {
const u = new URL(link);
const uuid = dec(u.username), password = dec(u.password);
if (!uuid || !password) throw new Error('в ссылке TUIC нет UUID или пароля');
const q = u.searchParams, hp = hostPort(u);
return Object.assign({
type: 'tuic', name: dec(u.hash.slice(1)), uuid, password,
sni: dec(q.get('sni') || '') || hp.server,
alpn: splitList(q.get('alpn')).length ? splitList(q.get('alpn')) : ['h3'],
congestion: q.get('congestion_control') || 'bbr',
udpRelay: q.get('udp_relay_mode') || 'native',
insecure: truthy(q.get('allow_insecure')) || truthy(q.get('insecure')),
}, hp);
}
function wgAddr(v) {
return splitList(v).map((a) => a.replace(/\/\d+$/, ''));
}
function parseWireguard(link) {
const u = new URL(link.replace(/^wg:/, 'wireguard:'));
const q = u.searchParams, hp = hostPort(u);
const priv = dec(u.username);
const pub = dec(q.get('publickey') || q.get('publicKey') || '');
if (!priv || !pub) throw new Error('в ссылке WireGuard нет ключей');
return Object.assign({
type: 'wireguard', name: dec(u.hash.slice(1)), privateKey: priv, publicKey: pub,
preSharedKey: dec(q.get('presharedkey') || ''), address: wgAddr(q.get('address') || q.get('ip') || ''),
mtu: Number(q.get('mtu') || 1420), reserved: q.get('reserved') ? splitList(q.get('reserved')).map(Number) : null,
}, hp);
}
// Конфиг wg-quick / AmneziaWG: [Interface] + [Peer]. Параметры Jc…H4 — обфускация AmneziaWG.
function parseWgConf(text, name) {
const sec = {};
let cur = null;
text.split(/\r?\n/).forEach((line) => {
const l = line.trim();
const m = /^\[(\w+)\]$/.exec(l);
if (m) { cur = m[1].toLowerCase(); sec[cur] = sec[cur] || {}; return; }
const kv = /^([A-Za-z0-9]+)\s*=\s*(.*)$/.exec(l);
if (kv && cur) sec[cur][kv[1].toLowerCase()] = kv[2].trim();
const c = /^#\s*(.+)$/.exec(l);
if (c && !name) name = c[1];
});
const i = sec.interface || {}, p = sec.peer || {};
if (!i.privatekey || !p.publickey || !p.endpoint) throw new Error('конфиг WireGuard неполный');
const ep = /^\[?([^\]]+?)\]?:(\d+)$/.exec(p.endpoint);
if (!ep) throw new Error('в конфиге WireGuard неверный Endpoint');
const awgKeys = ['jc', 'jmin', 'jmax', 's1', 's2', 's3', 's4', 'h1', 'h2', 'h3', 'h4', 'i1', 'i2', 'i3', 'i4', 'i5'];
const awg = {};
awgKeys.forEach((k) => { if (i[k] !== undefined && i[k] !== '') awg[k] = /^\d+$/.test(i[k]) ? Number(i[k]) : i[k]; });
const isAwg = Object.keys(awg).length > 0;
return {
type: isAwg ? 'amneziawg' : 'wireguard', name: name || (isAwg ? 'amneziawg' : 'wireguard') + '-' + ep[1],
server: ep[1], port: int(ep[2], 'порт'), privateKey: i.privatekey, publicKey: p.publickey,
preSharedKey: p.presharedkey || '', address: wgAddr(i.address || ''), mtu: Number(i.mtu || 1420),
dns: splitList(i.dns || ''), awg: isAwg ? awg : null, reserved: null,
};
}
function parseVpn(link) {
let text;
try { text = b64decode(link.slice('vpn://'.length).split('#')[0]); } catch (e) { throw new Error('ссылка vpn:// повреждена'); }
if (!/\[Interface\]/.test(text)) throw new Error('формат vpn:// не поддерживается — нужен конфиг AmneziaWG');
return parseWgConf(text, dec(link.split('#')[1] || ''));
}
function parseTg(link) {
const u = new URL(link.replace(/^https:\/\/t\.me\/proxy/, 'tg://proxy'));
const q = u.searchParams;
if (!q.get('server') || !q.get('secret')) throw new Error('в ссылке MTProto нет сервера или секрета');
return { type: 'mtproto', name: 'mtproto-' + q.get('server'), server: q.get('server'), port: int(q.get('port') || '443', 'порт'), secret: q.get('secret') };
}
const PARSERS = {
'vless': parseVless, 'vmess': parseVmess, 'trojan': parseTrojan, 'ss': parseSs, 'hy2': parseHy2, 'hysteria2': parseHy2,
'tuic': parseTuic, 'wireguard': parseWireguard, 'wg': parseWireguard, 'vpn': parseVpn, 'tg': parseTg,
};
function parseLink(line) {
if (/^https:\/\/t\.me\/proxy\?/.test(line)) line = line.replace(/^https:\/\/t\.me\/proxy/, 'tg://proxy');
const m = /^([a-z0-9]+):\/\//i.exec(line);
if (!m) throw new Error('это не ссылка на подключение');
const p = PARSERS[m[1].toLowerCase()];
@@ -233,7 +314,7 @@
return { proxies: ok, errors };
}
const api = { parseLink, parseText, b64decode };
const api = { parseLink, parseText, parseWgConf, b64decode };
if (typeof module === 'object' && module.exports) module.exports = api;
root.PM = Object.assign(root.PM || {}, api);
})(typeof window !== 'undefined' ? window : globalThis);
+16
View File
@@ -128,6 +128,21 @@
case 'ss':
o.cipher = p.method; o.password = p.password;
break;
case 'tuic':
o.uuid = p.uuid; o.password = p.password; o.sni = p.sni; o.alpn = p.alpn;
o['congestion-controller'] = p.congestion; o['udp-relay-mode'] = p.udpRelay;
o['skip-cert-verify'] = p.insecure || undefined;
break;
case 'wireguard':
case 'amneziawg':
o.type = 'wireguard';
o.ip = p.address.find((a) => a.includes('.'));
o.ipv6 = p.address.find((a) => a.includes(':'));
o['private-key'] = p.privateKey; o['public-key'] = p.publicKey;
o['pre-shared-key'] = p.preSharedKey || undefined; o.mtu = p.mtu;
o.reserved = p.reserved || undefined;
if (p.awg) o['amnezia-wg-option'] = p.awg;
break;
case 'hysteria2':
o.password = p.password; o.sni = p.sni; o.alpn = p.alpn;
o['skip-cert-verify'] = (p.insecure && !p.pinSHA256) || undefined;
@@ -174,6 +189,7 @@
};
if (services.some((s) => s.id === 'telegram')) cfg.sniffer['skip-dst-address'] = ['rule-set:telegram@ipcidr'];
proxies = proxies.filter((p) => p.type !== 'mtproto');
if (proxies.length) cfg.proxies = proxies.map(proxy);
if (opts.subscription) {
cfg['proxy-providers'] = {
+93
View File
@@ -0,0 +1,93 @@
// Сборка конфига sing-box (Hiddify, Karing, NekoBox, sing-box) из разобранных ссылок.
(function (root) {
'use strict';
function clean(o) {
Object.keys(o).forEach((k) => (o[k] === undefined || o[k] === '' || (Array.isArray(o[k]) && !o[k].length)) && delete o[k]);
return o;
}
function tls(p) {
const s = p.tls;
if (!s || s.security === 'none') return undefined;
const t = clean({ enabled: true, server_name: s.sni, alpn: s.alpn, insecure: s.insecure || undefined });
if (s.fp) t.utls = { enabled: true, fingerprint: s.fp };
if (s.security === 'reality') t.reality = clean({ enabled: true, public_key: s.pbk, short_id: s.sid });
return t;
}
function transport(p) {
const t = p.transport;
if (!t || t.network === 'tcp') {
if (t && t.headerType === 'http') return clean({ type: 'http', path: t.path, host: t.host ? [t.host] : undefined });
return undefined;
}
if (t.network === 'ws') {
const o = { type: 'ws', path: t.path };
if (t.host) o.headers = { Host: t.host };
return o;
}
if (t.network === 'httpupgrade') return clean({ type: 'httpupgrade', path: t.path, host: t.host });
if (t.network === 'grpc') return { type: 'grpc', service_name: t.serviceName };
throw new Error('sing-box не поддерживает транспорт ' + t.network);
}
// Возвращает { outbound } или { endpoint } (WireGuard в sing-box — endpoint).
function convert(p) {
const base = { tag: p.name, server: p.server, server_port: p.port };
switch (p.type) {
case 'vless':
return { outbound: clean(Object.assign(base, { type: 'vless', uuid: p.uuid, flow: p.flow, tls: tls(p), transport: transport(p) })) };
case 'vmess':
return { outbound: clean(Object.assign(base, { type: 'vmess', uuid: p.uuid, security: p.cipher, alter_id: p.alterId, tls: tls(p), transport: transport(p) })) };
case 'trojan':
return { outbound: clean(Object.assign(base, { type: 'trojan', password: p.password, tls: tls(p), transport: transport(p) })) };
case 'ss':
return { outbound: Object.assign(base, { type: 'shadowsocks', method: p.method, password: p.password }) };
case 'hysteria2': {
// sing-box не умеет закреплять отпечаток сертификата — при pinSHA256 проверку отключаем.
const t = clean({ enabled: true, server_name: p.sni, alpn: p.alpn, insecure: (p.insecure || !!p.pinSHA256) || undefined });
const o = clean(Object.assign(base, { type: 'hysteria2', password: p.password, tls: t }));
if (p.obfs) o.obfs = { type: p.obfs, password: p.obfsPassword };
return { outbound: o };
}
case 'tuic':
return { outbound: clean(Object.assign(base, { type: 'tuic', uuid: p.uuid, password: p.password, congestion_control: p.congestion,
udp_relay_mode: p.udpRelay, tls: clean({ enabled: true, server_name: p.sni, alpn: p.alpn, insecure: p.insecure || undefined }) })) };
case 'wireguard':
return { endpoint: clean({ type: 'wireguard', tag: p.name, address: p.address.map((a) => a + (a.includes(':') ? '/128' : '/32')),
private_key: p.privateKey, mtu: p.mtu,
peers: [clean({ address: p.server, port: p.port, public_key: p.publicKey, pre_shared_key: p.preSharedKey, allowed_ips: ['0.0.0.0/0', '::/0'], reserved: p.reserved || undefined })] }) };
case 'amneziawg':
throw new Error('sing-box не поддерживает AmneziaWG');
default:
throw new Error('sing-box не поддерживает ' + p.type);
}
}
// opts: { inbound: {type:'mixed', port} } — для тестов и десктопных клиентов.
function build(proxies, opts) {
opts = opts || {};
const outbounds = [], endpoints = [], skipped = [];
proxies.filter((p) => p.type !== 'mtproto').forEach((p) => {
try {
const r = convert(p);
if (r.outbound) outbounds.push(r.outbound); else endpoints.push(r.endpoint);
} catch (e) { skipped.push({ name: p.name, error: e.message }); }
});
const tags = outbounds.map((o) => o.tag).concat(endpoints.map((e) => e.tag));
if (!tags.length) throw new Error(skipped.length ? skipped[0].error : 'Нет подходящих серверов для sing-box.');
const cfg = { log: { level: 'warn' } };
if (opts.inbound) cfg.inbounds = [{ type: 'mixed', tag: 'in', listen: '127.0.0.1', listen_port: opts.inbound.port }];
cfg.outbounds = [{ type: 'selector', tag: 'Прокси', outbounds: ['Авто'].concat(tags) },
{ type: 'urltest', tag: 'Авто', outbounds: tags, url: 'https://www.gstatic.com/generate_204', interval: '5m' }]
.concat(outbounds, [{ type: 'direct', tag: 'direct' }]);
if (endpoints.length) cfg.endpoints = endpoints;
cfg.route = { final: opts.final || 'Прокси', auto_detect_interface: true };
return { config: cfg, skipped, count: tags.length };
}
const api = { buildSingbox: build, singboxConvert: convert };
if (typeof module === 'object' && module.exports) module.exports = api;
root.PM = Object.assign(root.PM || {}, api);
})(typeof window !== 'undefined' ? window : globalThis);
+25 -5
View File
@@ -20,7 +20,8 @@
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 });
// В Xray 26 allowInsecure удалён: самоподписанный сертификат принимается только по отпечатку.
st.tlsSettings = clean({ serverName: s.sni, fingerprint: s.fp, alpn: s.alpn.length ? s.alpn : undefined, pinnedPeerCertSha256: s.pin || undefined });
}
if (s.security === 'reality') {
st.realitySettings = clean({ serverName: s.sni, fingerprint: s.fp || 'chrome', publicKey: s.pbk, shortId: s.sid, spiderX: s.spx });
@@ -53,10 +54,26 @@
tag, protocol: 'shadowsocks',
settings: { servers: [{ address: p.server, port: p.port, method: p.method, password: p.password }] },
};
case 'hysteria2':
return {
tag, protocol: 'hysteria',
settings: { address: p.server, port: p.port, version: 2 },
streamSettings: {
network: 'hysteria', security: 'tls',
hysteriaSettings: { version: 2, auth: p.password, udpIdleTimeout: 60 },
tlsSettings: clean({ serverName: p.sni, alpn: p.alpn.length ? p.alpn : ['h3'], pinnedPeerCertSha256: p.pinSHA256 || undefined }),
},
};
case 'wireguard':
return {
tag, protocol: 'wireguard',
settings: clean({ secretKey: p.privateKey, address: p.address, mtu: p.mtu, reserved: p.reserved || undefined,
peers: [clean({ publicKey: p.publicKey, preSharedKey: p.preSharedKey, endpoint: p.server + ':' + p.port, allowedIPs: ['0.0.0.0/0', '::/0'] })] }),
};
default:
throw new Error(p.type === 'hysteria2'
? 'Hysteria2 в XKeen работает через ядро Mihomo — возьмите генератор Mihomo'
: 'протокол ' + p.type + ' не поддерживается Xray');
throw new Error({ tuic: 'TUIC не поддерживается Xray — возьмите генератор Mihomo',
amneziawg: 'AmneziaWG не поддерживается Xray — возьмите генератор Mihomo',
mtproto: 'MTProto — это прокси для Telegram, в Xray он не нужен' }[p.type] || 'протокол ' + p.type + ' не поддерживается Xray');
}
}
@@ -137,7 +154,10 @@
const mainTag = opts.tag || 'vless-reality';
const skipped = [];
const outs = [];
const warnings = [];
proxies.forEach((p) => {
const insecure = (p.tls && p.tls.insecure && !p.tls.pin) || (p.type === 'hysteria2' && p.insecure && !p.pinSHA256);
if (insecure) warnings.push(p.name + ': в ссылке отключена проверка сертификата, а Xray 26 так не умеет — нужен отпечаток (pcs) или настоящий сертификат');
try {
const tag = outs.length === 0 ? mainTag : mainTag + '-' + (outs.length + 1);
outs.push(outbound(p, tag));
@@ -169,7 +189,7 @@
'04_outbounds.json': Object.assign({ outbounds: outs }, obs || {}),
'05_routing.json': rt,
};
return { files, skipped, count: outs.length - 2 };
return { files, skipped, warnings, count: outs.length - 2 };
}
const api = { buildXray: build, XRAY_SERVICES: SERVICES, XRAY_BASES: BASES, xrayServiceAvailable: available };
+1 -1
View File
@@ -27,7 +27,7 @@
<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>
vmess, trojan, shadowsocks, Hysteria2, TUIC, WireGuard и AmneziaWG (ссылки wireguard:// и vpn:// из 3X-UI).</p>
</section>
<section class="step">
+27 -3
View File
@@ -61,7 +61,7 @@ test('подписка в base64 и ошибки по строкам', () => {
const r = parseText(sub);
assert.equal(r.proxies.length, 2);
assert.equal(r.errors.length, 2);
assert.match(r.errors[1].error, /tuic/);
assert.match(r.errors[1].error, /TUIC/);
});
test('одинаковые имена становятся уникальными', () => {
@@ -83,8 +83,8 @@ test('Xray: теги, балансировщик и маршрутизация',
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.equal(multi.count, 3);
assert.equal(multi.skipped.length, 0);
assert.ok(multi.files['05_routing.json'].routing.balancers);
});
@@ -95,6 +95,30 @@ test('Xray: сервисы без нужной геобазы не попада
assert.ok(s.includes('ext:zkeen.dat:youtube'));
});
test('TUIC, WireGuard, AmneziaWG (vpn://) и MTProto', () => {
const t = parseLink('tuic://d017584f-52ea-43c8-a420-9d559f27b96a:secret@t.example.com:8444?alpn=h3&congestion_control=bbr&udp_relay_mode=native#tuic');
assert.equal(t.type, 'tuic');
assert.equal(t.password, 'secret');
const w = parseLink('wireguard://cHJpdg%3D%3D@w.example.com:51820?address=10.0.0.2%2F32&mtu=1420&publickey=cHVi#wg');
assert.deepEqual(w.address, ['10.0.0.2']);
const conf = '[Interface]\nPrivateKey = cHJpdg==\nAddress = 10.8.1.2/32\nJc = 4\nJmin = 40\nJmax = 70\nS1 = 20\nS2 = 30\nH1 = 11\nH2 = 22\nH3 = 33\nH4 = 44\n[Peer]\nPublicKey = cHVi\nEndpoint = a.example.com:51821\n';
const a = parseLink('vpn://' + Buffer.from(conf).toString('base64'));
assert.equal(a.type, 'amneziawg');
assert.equal(a.awg.jc, 4);
assert.equal(parseLink('tg://proxy?server=m.example.com&port=8445&secret=ee00').type, 'mtproto');
assert.equal(parseLink('https://t.me/proxy?server=m.example.com&port=8445&secret=ee00').port, 8445);
});
test('Xray 26: вместо allowInsecure — отпечаток или предупреждение', () => {
const insecure = WS_TLS.replace('#', '&allowInsecure=1#');
const r = buildXray(parseText(insecure).proxies, {});
assert.ok(!JSON.stringify(r.files).includes('allowInsecure'));
assert.equal(r.warnings.length, 1);
const pinned = buildXray(parseText(WS_TLS.replace('#', '&pcs=AB:CD#')).proxies, {});
assert.equal(pinned.files['04_outbounds.json'].outbounds[0].streamSettings.tlsSettings.pinnedPeerCertSha256, 'abcd');
assert.equal(pinned.warnings.length, 0);
});
test('Mihomo: порты XKeen и прокси', () => {
const r = buildMihomo(parseText([REALITY, HY2, SS_2022].join('\n')).proxies, { secret: 'x' });
assert.equal(r.config['redir-port'], 5000);
+3 -2
View File
@@ -24,8 +24,8 @@
<h2><span class="num">1</span>Ссылки на серверы</h2>
<textarea id="links" class="mono" spellcheck="false" placeholder="vless://…&#10;По одной на строку. Можно вставить подписку в 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>
<p class="hint">Поддерживаются vless (REALITY, TLS, ws, gRPC, xhttp, httpupgrade), vmess, trojan, shadowsocks, Hysteria2 и WireGuard.
Несколько серверов — Xray сам выберет самый быстрый. TUIC и AmneziaWG работают через <a href="../mihomo/">Mihomo</a>.</p>
</section>
<section class="step">
@@ -154,6 +154,7 @@
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('; '));
(r.warnings || []).forEach((w) => notes.push(w));
$('#note').innerHTML = notes.map((n) => '<div class="msg warn">' + esc(n) + '</div>').join('');
}