- kit check и kit fix: проверка служб, сертификата, подписки, сайта маскировки, прав на файлы; безопасная починка - kit-sub: пропускает заголовки Happ (Routing и др.) до клиентов - ядро Xray: запасная загрузка через зеркало только с проверкой SHA256, tools/release.sh сверяет суммы с официальными - установка со своим доменом: при отказе Let's Encrypt предлагается самоподписанный сертификат - README: инструкция XKeen, команды kit, новый баннер; правки инструкций и changelog Co-authored-by: xlok1e <124461726+xlok1e@users.noreply.github.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
199 lines
11 KiB
JavaScript
199 lines
11 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') {
|
||
// В 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: 'Сайты из списка re:filter', 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);
|