Files
3X-UI_autoVPS/tools/lib/xray.js
T
Claude 0a19858efa 1.1: подписанное автообновление, kit backup и --restore, случайная заглушка
- kit update ставит только релизы, у которых SHA256SUMS подписан ключом автора
  (ssh-keygen -Y verify), версия внутри подписи совпадает, файлы сошлись с хешами;
  откат на старую версию запрещён; после обновления проверяется подписка, иначе возврат
- автообновление: systemd-таймер раз в сутки ночью со случайной задержкой,
  kit update --manual / --auto, журнал /var/log/kit-update.log
- hy2 update тоже проверяет подпись
- tools/release.sh: проверка версий и хеша установщика 3X-UI, SHA256SUMS и подпись
- kit backup и 3x-ui.sh --restore: архив 600, строгая проверка архива, конфиги nginx
  и kit-sub собираются заново, смена IP в подключениях
- сайт-заглушка: случайный из шести шаблонов внутри скрипта
- zashboard закреплён на v3.29.1, короткие тире и 192.168.x.x в текстах и картинках
- CI: ключ подписи одинаковый в kit и hy2, проверка подписи релиза
- CHANGELOG, README, инструкция, SECURITY.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7GrBSRPVXhrz3RAprWDnt
2026-09-30 19:15:19 +00:00

199 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Сборка конфигов 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') {
// В 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 });
}
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 }] },
};
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({ tuic: 'TUIC не поддерживается Xray – возьмите генератор Mihomo',
amneziawg: 'AmneziaWG не поддерживается Xray – возьмите генератор Mihomo',
mtproto: 'MTProto – это прокси для Telegram, в Xray он не нужен' }[p.type] || 'протокол ' + 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 = [];
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));
} 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, warnings, 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);