Матрица протоколов 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
+14
View File
@@ -0,0 +1,14 @@
# Матрица «протокол × клиент»
Проверяет настоящим подключением, что каждый протокол 3X-UI работает с каждым клиентом:
Xray, Mihomo, sing-box и официальным клиентом AmneziaWG. Прогонять на каждой новой версии 3X-UI и Xray.
1. Сервер: чистый Ubuntu в Docker с systemd, на нём `scripts/3x-ui.sh`, затем `mk-inbounds.sh` —
создаёт через API подключения VLESS XHTTP, WS+TLS, Trojan gRPC, VMess, Shadowsocks 2022,
Hysteria2, TUIC, WireGuard, AmneziaWG и MTProto.
2. Ссылки: `GET /panel/api/inbounds/allLinks` → `links.txt`.
3. Конфиги клиентов: `PIN=<sha256 сертификата> node matrix-gen.js ../../tools/lib links.txt cases`.
4. Прогон в клиентском контейнере с бинарниками в `/cl`: `bash matrix-run.sh`.
5. AmneziaWG: `awg-set.sh classic|mid|full` меняет обфускацию на сервере.
Результат на 2026-09-27 (3X-UI 3.8.5, Xray 26.6.27, Mihomo 1.19.31, sing-box 1.14.2) — в описании коммита.
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# awg-set.sh classic|full — выставляет обфускацию AmneziaWG на подключении и печатает ссылку.
. /etc/x-ui/install-result.env
API="http://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api"; H=(-H "Authorization: Bearer $XUI_API_TOKEN" -H 'Content-Type: application/json')
ID=$(curl -fsS "${H[@]}" "$API/inbounds/list" | jq -r '.obj[] | select(.protocol=="amneziawg") | .id')
IN=$(curl -fsS "${H[@]}" "$API/inbounds/get/$ID" | jq -c .obj)
r() { shuf -i "$1-$2" -n 1; }
JMIN=$(r 40 89); S1=$(r 15 150); S2=$(r 15 150); while [[ $((S1+56)) == "$S2" ]]; do S2=$(r 15 150); done
OBF=$(jq -nc --argjson jc "$(r 3 6)" --argjson jmin $JMIN --argjson jmax $((JMIN + $(r 50 250))) --argjson s1 $S1 --argjson s2 $S2 \
--arg h1 "$(r 5 536870911)" --arg h2 "$(r 536870912 1073741823)" --arg h3 "$(r 1073741824 1610612735)" --arg h4 "$(r 1610612736 2147483647)" \
'{jc:$jc, jmin:$jmin, jmax:$jmax, s1:$s1, s2:$s2, h1:$h1, h2:$h2, h3:$h3, h4:$h4}')
if [[ $1 == mid ]]; then
OBF=$(jq -c --argjson s3 "$(r 12 55)" --argjson s4 "$(r 12 27)" --arg i1 "<r $(r 32 256)>" ". + {s3:\$s3, s4:\$s4, i1:\$i1}" <<<"$OBF")
fi
if [[ $1 == full ]]; then
OBF=$(jq -c --argjson s3 "$(r 12 55)" --argjson s4 "$(r 12 27)" --arg i1 "<r $(r 32 256)>" --arg hp "$(openssl rand -base64 32)" \
'. + {s3:$s3, s4:$s4, i1:$i1, headerProtectionKey:$hp, contentPaddingAddition:"12-40", rekeyAfterTime:"110-140", rejectAfterTime:"180-250", rekeyTimeout:"4-7", keepaliveTimeout:"10-16", maxHandshakeAttempts:"20-40"}' <<<"$OBF")
fi
SET=$(jq -c --argjson o "$OBF" '(.settings | if type=="string" then fromjson else . end) | .server |= (del(.s3, .s4, .i1, .i2, .i3, .i4, .i5, .headerProtectionKey, .contentPaddingAddition, .rekeyAfterTime, .rejectAfterTime, .rekeyTimeout, .keepaliveTimeout, .maxHandshakeAttempts) + $o)' <<<"$IN")
BODY=$(jq -c --arg s "$SET" '.settings = $s | .streamSettings = (.streamSettings | if type=="string" then . else tojson end) | .sniffing = (.sniffing | if type=="string" then . else tojson end)' <<<"$IN")
curl -fsS "${H[@]}" -X POST -d "$BODY" "$API/inbounds/update/$ID" | jq -r '.success, (.msg|tostring)' | tr '\n' ' '; echo
sleep 2
curl -fsS "${H[@]}" -H "Host: xs" "$API/inbounds/allLinks" | jq -r '.obj[] | select(startswith("vpn://"))'
+34
View File
@@ -0,0 +1,34 @@
// Генерирует конфиги клиентов для каждой пары «протокол × клиент».
const fs = require('fs'), path = require('path');
const [lib, linksFile, out] = process.argv.slice(2);
const PIN = process.env.PIN || ''; // отпечаток тестового сертификата сервера
const L = require(lib + '/links.js'), X = require(lib + '/xray.js'), M = require(lib + '/mihomo.js'), SB = require(lib + '/singbox.js');
const { proxies } = L.parseText(fs.readFileSync(linksFile, 'utf8'));
fs.rmSync(out, { recursive: true, force: true });
const rows = [];
proxies.filter((p) => p.type !== 'mtproto').forEach((p, i) => {
// Тестовый сервер с самоподписанным сертификатом — разрешаем клиентам его принять.
if (p.tls && p.tls.security === 'tls') { p.tls.insecure = true; p.tls.pin = PIN; }
if (p.type === 'hysteria2' || p.type === 'tuic') p.insecure = true;
if (p.type === 'hysteria2') p.pinSHA256 = PIN;
const id = String(i).padStart(2, '0') + '-' + p.name.replace(/[^a-z0-9-]/gi, '').slice(0, 24);
const dir = path.join(out, id);
fs.mkdirSync(dir, { recursive: true });
const res = { id, type: p.type, name: p.name };
try {
const x = X.buildXray([p], { finalProxy: true });
const o = x.files['04_outbounds.json'];
o.inbounds = [{ listen: '127.0.0.1', port: 1080, protocol: 'socks', settings: { udp: true } }];
o.log = { loglevel: 'warning' };
fs.writeFileSync(path.join(dir, 'xray.json'), JSON.stringify(o));
} catch (e) { res.xray = 'n/a: ' + e.message; }
try {
fs.writeFileSync(path.join(dir, 'mihomo.yaml'), M.buildMihomo([p], { finalProxy: true, secret: 't', services: [] }).yaml + 'mixed-port: 1081\n');
} catch (e) { res.mihomo = 'n/a: ' + e.message; }
try {
fs.writeFileSync(path.join(dir, 'singbox.json'), JSON.stringify(SB.buildSingbox([p], { inbound: { port: 1082 } }).config));
} catch (e) { res.singbox = 'n/a: ' + e.message; }
rows.push(res);
});
fs.writeFileSync(path.join(out, 'index.json'), JSON.stringify(rows, null, 1));
console.log(rows.map((r) => r.id + (r.xray ? ' [xray ' + r.xray + ']' : '') + (r.singbox ? ' [sb ' + r.singbox + ']' : '')).join('\n'));
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Прогон матрицы: каждый конфиг — отдельный клиент, через него открываем сайт.
cd /lab/cases
t() { # client port cmd...
local port=$1; shift
("$@" >/tmp/c.log 2>&1 &)
local code=000
for i in 1 2 3 4 5 6; do
sleep 1
code=$(curl -s -m 8 -x socks5h://127.0.0.1:$port -o /dev/null -w '%{http_code}' https://www.google.com/generate_204)
[[ $code == 204 ]] && break
done
pkill -x xray; pkill -x mihomo; pkill -x sing-box; sleep 0.5
if [[ $code == 204 ]]; then echo "✓"; else echo "✗ $(grep -i -m1 -E 'error|fail|fatal|invalid' /tmp/c.log | cut -c1-90)"; fi
}
printf '%-32s %-40s %-40s %s\n' "протокол" "Xray 26.6.27" "Mihomo" "sing-box"
for d in */; do
d=${d%/}
x='—'; m='—'; s='—'
[[ -f $d/xray.json ]] && x=$(t 1080 /cl/xray run -c $d/xray.json)
[[ -f $d/mihomo.yaml ]] && m=$(t 1081 /cl/mihomo -d /tmp/mh -f $d/mihomo.yaml)
[[ -f $d/singbox.json ]] && s=$(t 1082 /cl/sing-box run -c $d/singbox.json)
printf '%-32s %-40s %-40s %s\n' "$d" "$x" "$m" "$s"
done
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Создаёт по подключению на каждый протокол через API 3x-ui. Запускать на сервере.
set -uo pipefail
. /etc/x-ui/install-result.env
API="http://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api"
H=(-H "Authorization: Bearer $XUI_API_TOKEN" -H 'Content-Type: application/json')
mkdir -p /root/cert
[[ -f /root/cert/crt ]] || openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout /root/cert/key -out /root/cert/crt -subj /CN=test.pm -addext subjectAltName=DNS:test.pm -days 365 2>/dev/null
chmod 644 /root/cert/*
uuid() { cat /proc/sys/kernel/random/uuid; }
b64k() { openssl rand -base64 "$1"; }
cb() { jq -nc --arg e "$1" '{email:$e, limitIp:0, totalGB:0, expiryTime:0, enable:true, tgId:0, subId:$e, comment:"", reset:0}'; }
TLS=$(jq -nc '{serverName:"test.pm", certificates:[{certificateFile:"/root/cert/crt", keyFile:"/root/cert/key"}]}')
KEYS=$(curl -fsS "${H[@]}" "$API/server/getNewX25519Cert" | jq -c .obj)
add() { # remark port protocol settings stream
local body r
body=$(jq -nc --arg rm "$1" --argjson port "$2" --arg p "$3" --arg s "$4" --arg st "$5" \
'{remark:$rm, enable:true, listen:"", port:$port, protocol:$p, settings:$s, streamSettings:$st,
sniffing:"{\"enabled\":true,\"destOverride\":[\"http\",\"tls\",\"quic\"]}", expiryTime:0, total:0}')
r=$(curl -sS "${H[@]}" -X POST -d "$body" "$API/inbounds/add")
printf '%-22s %s\n' "$1" "$(jq -r 'if .success then "создан" else "ОШИБКА: " + (.msg|tostring) end' <<<"$r" | head -c 300)"
}
add vless-xhttp-reality 8443 vless \
"$(jq -nc --arg id "$(uuid)" --argjson c "$(cb t-xhttp)" '{clients:[$c + {id:$id, flow:""}], decryption:"none"}')" \
"$(jq -nc --argjson k "$KEYS" --arg sid "$(openssl rand -hex 8)" '{network:"xhttp", security:"reality", xhttpSettings:{path:"/x", mode:"auto"},
realitySettings:{target:"dl.google.com:443", serverNames:["dl.google.com"], privateKey:$k.privateKey, shortIds:[$sid], settings:{publicKey:$k.publicKey, fingerprint:"chrome", spiderX:"/"}}}')"
add vless-ws-tls 2053 vless \
"$(jq -nc --arg id "$(uuid)" --argjson c "$(cb t-ws)" '{clients:[$c + {id:$id, flow:""}], decryption:"none"}')" \
"$(jq -nc --argjson t "$TLS" '{network:"ws", security:"tls", wsSettings:{path:"/ws"}, tlsSettings:($t + {alpn:["http/1.1"]})}')"
add trojan-grpc-tls 2083 trojan \
"$(jq -nc --argjson c "$(cb t-trojan)" --arg pw "$(openssl rand -hex 8)" '{clients:[$c + {password:$pw}]}')" \
"$(jq -nc --argjson t "$TLS" '{network:"grpc", security:"tls", grpcSettings:{serviceName:"gun"}, tlsSettings:($t + {alpn:["h2"]})}')"
add vmess-ws-tls 2087 vmess \
"$(jq -nc --arg id "$(uuid)" --argjson c "$(cb t-vmess)" '{clients:[$c + {id:$id, security:"auto", alterId:0}]}')" \
"$(jq -nc --argjson t "$TLS" '{network:"ws", security:"tls", wsSettings:{path:"/vm"}, tlsSettings:($t + {alpn:["http/1.1"]})}')"
add shadowsocks-2022 8388 shadowsocks \
"$(jq -nc --arg pw "$(b64k 16)" --arg upw "$(b64k 16)" --argjson c "$(cb t-ss)" '{method:"2022-blake3-aes-128-gcm", password:$pw, network:"tcp,udp", clients:[$c + {method:"", password:$upw}]}')" \
'{"network":"tcp","security":"none"}'
add hysteria2 443 hysteria \
"$(jq -nc --argjson c "$(cb t-hy2)" --arg a "$(openssl rand -hex 8)" '{version:2, clients:[$c + {auth:$a}]}')" \
"$(jq -nc --argjson t "$TLS" '{network:"hysteria", hysteriaSettings:{version:2}, security:"tls", tlsSettings:($t + {alpn:["h3"]})}')"
add tuic 8444 tuic \
"$(jq -nc --arg id "$(uuid)" --arg pw "$(openssl rand -hex 8)" --argjson c "$(cb t-tuic)" '{server:{certificate:"/root/cert/crt", private_key:"/root/cert/key", congestion_control:"bbr", alpn:["h3"], udp_relay_mode:"native", zero_rtt_handshake:false, log_level:"info", sni:""}, clients:[$c + {uuid:$id, id:$id, password:$pw}]}')" \
'{}'
wgkey() { /usr/local/x-ui/bin/xray-linux-* wg | awk -v w="$1" 'tolower($0) ~ w {print $NF}'; }
WGK=$(/usr/local/x-ui/bin/xray-linux-* wg); WPRIV=$(awk '/Private/ {print $NF}' <<<"$WGK"); WPUB=$(awk '/Public|Password/ {print $NF}' <<<"$WGK" | head -1)
AGK=$(/usr/local/x-ui/bin/xray-linux-* wg); APRIV=$(awk '/Private/ {print $NF}' <<<"$AGK"); APUB=$(awk '/Public|Password/ {print $NF}' <<<"$AGK" | head -1)
add wireguard 51820 wireguard \
"$(jq -nc --arg k "$(/usr/local/x-ui/bin/xray-linux-* wg | awk '/Private/ {print $NF}')" --arg pr "$WPRIV" --arg pu "$WPUB" --argjson c "$(cb t-wg)" '{mtu:1420, secretKey:$k, peers:[], clients:[$c + {privateKey:$pr, publicKey:$pu, allowedIPs:["10.0.0.2/32"]}], subnetIp:"10.0.0.0", subnetCidr:24}')" \
'{"network":"tcp","security":"none"}'
add amneziawg 51821 amneziawg \
"$(jq -nc --arg pr "$APRIV" --arg pu "$APUB" --argjson c "$(cb t-awg)" '{server:{subnetIp:"10.8.1.0", subnetCidr:24, primaryDns:"8.8.8.8", secondaryDns:"8.8.4.4"}, clients:[$c + {privateKey:$pr, publicKey:$pu, allowedIPs:["10.8.1.2/32"]}]}')" \
'{}'
add mtproto 8445 mtproto \
"$(jq -nc --argjson c "$(cb t-mtproto)" '{fakeTlsDomain:"www.cloudflare.com", clients:[$c + {secret:""}]}')" \
'{}'
+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('');
}