Files
3X-UI_autoVPS/tools/lib/xray.js
T
PinkmanXXX aebfb15780 Генераторы конфигов 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.
2026-09-25 22:50:16 +03:00

179 lines
9.4 KiB
JavaScript

// Сборка конфигов 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);