diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 5cfa330..1b5b288 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Инструкций](https://img.shields.io/badge/инструкций-3-3fb950)](#инструкции) [![Обновлено](https://img.shields.io/github/last-commit/PinkmanXXX/pinkman_manuals?label=обновлено&color=3fb950)](https://github.com/PinkmanXXX/pinkman_manuals/commits) -[Инструкции](#инструкции) · [Что понадобится](#что-понадобится) · [Полезное](#полезное) · [Поддержать](#поддержать-проект) +[Инструкции](#инструкции) · [Генераторы](#генераторы-конфигов-для-xkeen) · [Что понадобится](#что-понадобится) · [Полезное](#полезное) · [Поддержать](#поддержать-проект) @@ -30,6 +30,17 @@ > Инструкции подготовлены в образовательных целях. Убедитесь, что ваши > действия соответствуют законодательству вашей страны. +## Генераторы конфигов для XKeen + +Вставьте ссылку на сервер или подписку, отметьте нужные сервисы — и получите +готовый конфиг и одну команду, которая сама положит его на роутер. +Всё считается в браузере, ссылки никуда не отправляются. + +| | Генератор | Что получится | +|---|---|---| +| ⚙️ | [Xray](https://pinkmanxxx.github.io/pinkman_manuals/tools/xray/) | `04_outbounds.json` и `05_routing.json`: серверы, выбор сервисов, реклама, свои сайты | +| 🧩 | [Mihomo](https://pinkmanxxx.github.io/pinkman_manuals/tools/mihomo/) | `config.yaml` с автовыбором сервера, подпиской, Hysteria2 и веб-панелью | + ## Что понадобится - VPS с **Ubuntu 22.04/24.04** или **Debian 12/13** и доступом root по SSH @@ -40,8 +51,6 @@ - [XKeen](https://github.com/jameszeroX/XKeen) и его [вики](https://github.com/jameszeroX/XKeen/wiki) — документация по маршрутизации на Keenetic - [XKeen UI](https://github.com/zxc-rv/XKeen-UI) — веб-интерфейс для XKeen -- [Генератор Outbound](https://zxc-rv.github.io/XKeen-UI/Outbound_Generator/) — превращает ссылку `vless://` в `04_outbounds.json` -- [Генератор конфигураций Mihomo](https://rockblack.info/mihomo_generator) от RockBlack - [IP-адреса для AmneziaWG](https://github.com/RockBlack-VPN/ip-address) — актуальные списки от RockBlack ## Как устроены скрипты diff --git a/index.html b/index.html new file mode 100644 index 0000000..7e39744 --- /dev/null +++ b/index.html @@ -0,0 +1,5 @@ + +Pinkman Manuals + + +Инструкции — на GitHub diff --git a/manuals/assets/xkeen-configs.svg b/manuals/assets/xkeen-configs.svg index 5f1c0a2..375d414 100644 --- a/manuals/assets/xkeen-configs.svg +++ b/manuals/assets/xkeen-configs.svg @@ -1 +1 @@ -root@Keenetic: /opt/etc/xray/configsroot@Keenetic: /opt/etc/xray/configs~ # ls /opt/etc/xray/configs/01_log.json02_dns.json03_inbounds.json # режим: Hybrid по умолчанию, TProxy — заменой файла104_outbounds.json # подключение к вашему серверу205_routing.json # что пускать через прокси06_policy.json~ # xkeen -restart \ No newline at end of file +root@Keenetic: /opt/etc/xray/configsroot@Keenetic: /opt/etc/xray/configs~ # ls /opt/etc/xray/configs/03_inbounds.json # режим работы — оставьте как есть104_outbounds.json # подключение к вашему серверу205_routing.json # что пускать через прокси~ # xkeen -restart \ No newline at end of file diff --git a/manuals/xkeen-keenetic.md b/manuals/xkeen-keenetic.md index 13881a0..a3d24ba 100644 --- a/manuals/xkeen-keenetic.md +++ b/manuals/xkeen-keenetic.md @@ -69,19 +69,20 @@ sh -c "$(curl -sSL https://raw.githubusercontent.com/jameszeroX/XKeen/main/insta ## 5. Подключите свой сервер -Для Xray нужно отредактировать два файла в `/opt/etc/xray/configs/`: +Для Xray нужны два файла в `/opt/etc/xray/configs/`: ![Конфигурационные файлы Xray](assets/xkeen-configs.svg) -1. `04_outbounds.json` — подключение к вашему серверу. Проще всего получить - его из ссылки `vless://` в [генераторе Outbound](https://zxc-rv.github.io/XKeen-UI/Outbound_Generator/). -2. `05_routing.json` — какие сайты и IP пускать через прокси. +1. `04_outbounds.json` — подключение к вашему серверу. +2. `05_routing.json` — какие сайты и сервисы пускать через прокси. -Запустите проксирование: +Оба файла собирает наш **[генератор Xray](https://pinkmanxxx.github.io/pinkman_manuals/tools/xray/)**: +вставьте ссылку `vless://`, отметьте сервисы — и получите одну команду, +которая сама запишет файлы на роутер и перезапустит XKeen. Для ядра Mihomo +и ссылок Hysteria2 есть **[генератор Mihomo](https://pinkmanxxx.github.io/pinkman_manuals/tools/mihomo/)**. -```bash -xkeen -start -``` +> [!NOTE] +> Генераторы работают прямо в браузере — ссылки с паролями никуда не отправляются. Чтобы через прокси ходили только нужные устройства, в веб-интерфейсе откройте **Приоритеты подключений → Политики доступа в Интернет**, создайте политику diff --git a/tools/index.html b/tools/index.html new file mode 100644 index 0000000..a9055f8 --- /dev/null +++ b/tools/index.html @@ -0,0 +1,4 @@ + +Генераторы конфигов + +Генератор Xray · Генератор Mihomo diff --git a/tools/lib/links.js b/tools/lib/links.js new file mode 100644 index 0000000..db510a8 --- /dev/null +++ b/tools/lib/links.js @@ -0,0 +1,239 @@ +// Разбор ссылок vless:// vmess:// trojan:// ss:// hy2:// в общий формат. +// Работает в браузере (window.PM) и в Node (module.exports). Всё локально — +// ссылки никуда не отправляются. +(function (root) { + 'use strict'; + + function b64decode(s) { + s = s.trim().replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, ''); + while (s.length % 4) s += '='; + const bin = typeof atob === 'function' ? atob(s) : Buffer.from(s, 'base64').toString('binary'); + // UTF-8 из бинарной строки + const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } + + function looksBase64(s) { + return /^[A-Za-z0-9+/=_\-\s]+$/.test(s) && s.replace(/\s/g, '').length >= 16; + } + + function dec(s) { + try { return decodeURIComponent(s); } catch (e) { return s; } + } + + function int(v, what) { + const n = Number(v); + if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error('неверный ' + what + ': ' + v); + return n; + } + + function splitList(v) { + return v ? dec(v).split(',').map((x) => x.trim()).filter(Boolean) : []; + } + + function truthy(v) { + return v === '1' || v === 'true' || v === 'True'; + } + + // Разбор адреса «хост:порт» из URL: поддерживает IPv6 в квадратных скобках. + function hostPort(u) { + const host = u.hostname.replace(/^\[|\]$/g, ''); + if (!host) throw new Error('нет адреса сервера'); + return { server: host, port: int(u.port || '443', 'порт') }; + } + + // Общие параметры транспорта и шифрования из query (формат Xray share link). + function transportFromQuery(q, fallbackHost) { + let network = (q.get('type') || 'tcp').toLowerCase(); + if (network === 'raw') network = 'tcp'; + if (network === 'http' && q.get('headerType') !== 'http') network = 'xhttp'; + const t = { network }; + const path = q.get('path') ? dec(q.get('path')) : ''; + const host = q.get('host') ? dec(q.get('host')) : ''; + if (network === 'ws' || network === 'httpupgrade') { + t.path = path || '/'; + if (host) t.host = host; + } else if (network === 'grpc') { + t.serviceName = dec(q.get('serviceName') || q.get('path') || ''); + t.multiMode = q.get('mode') === 'multi'; + } else if (network === 'xhttp' || network === 'splithttp') { + t.network = 'xhttp'; + t.path = path || '/'; + if (host) t.host = host; + t.mode = q.get('mode') || 'auto'; + } else if (network === 'tcp') { + if (q.get('headerType') === 'http') { + t.headerType = 'http'; + t.path = path || '/'; + if (host) t.host = host; + } + } else { + throw new Error('транспорт «' + network + '» не поддерживается'); + } + + const security = (q.get('security') || 'none').toLowerCase(); + const s = { security }; + if (security === 'tls' || security === 'reality') { + s.sni = dec(q.get('sni') || q.get('peer') || '') || host || fallbackHost; + s.fp = q.get('fp') || (security === 'reality' ? 'chrome' : ''); + s.alpn = splitList(q.get('alpn')); + s.insecure = truthy(q.get('allowInsecure')) || truthy(q.get('insecure')); + } + if (security === 'reality') { + s.pbk = q.get('pbk') || ''; + if (!s.pbk) throw new Error('в ссылке REALITY нет публичного ключа (pbk)'); + s.sid = q.get('sid') || ''; + s.spx = q.get('spx') ? dec(q.get('spx')) : ''; + } else if (security !== 'tls' && security !== 'none') { + throw new Error('шифрование «' + security + '» не поддерживается'); + } + return { transport: t, tls: s }; + } + + function parseVless(link) { + const u = new URL(link); + const id = dec(u.username); + if (!id) throw new Error('нет UUID'); + const hp = hostPort(u); + const q = u.searchParams; + const enc = q.get('encryption'); + if (enc && enc !== 'none') throw new Error('шифрование VLESS «' + enc + '» не поддерживается'); + return Object.assign({ + type: 'vless', name: dec(u.hash.slice(1)), uuid: id, flow: q.get('flow') || '', + }, hp, transportFromQuery(q, hp.server)); + } + + function parseTrojan(link) { + const u = new URL(link); + const pw = dec(u.username); + if (!pw) throw new Error('нет пароля'); + const hp = hostPort(u); + const q = u.searchParams; + if (!q.get('security')) q.set('security', 'tls'); + return Object.assign({ type: 'trojan', name: dec(u.hash.slice(1)), password: pw }, hp, transportFromQuery(q, hp.server)); + } + + function parseVmess(link) { + const raw = b64decode(link.slice('vmess://'.length).split('#')[0]); + let j; + try { j = JSON.parse(raw); } catch (e) { throw new Error('ссылка vmess повреждена'); } + if (!j.id || !j.add) throw new Error('в ссылке vmess нет адреса или UUID'); + const q = new URLSearchParams(); + q.set('type', j.net || 'tcp'); + if (j.type && j.type !== 'none') q.set(j.net === 'grpc' ? 'mode' : 'headerType', j.type); + if (j.path) q.set(j.net === 'grpc' ? 'serviceName' : 'path', j.path); + if (j.host) q.set('host', j.host); + q.set('security', j.tls === 'tls' || j.tls === 'reality' ? j.tls : 'none'); + if (j.sni) q.set('sni', j.sni); + if (j.alpn) q.set('alpn', j.alpn); + if (j.fp) q.set('fp', j.fp); + if (j.pbk) q.set('pbk', j.pbk); + if (j.sid) q.set('sid', j.sid); + return Object.assign({ + type: 'vmess', name: j.ps || '', uuid: j.id, alterId: Number(j.aid || 0), + cipher: j.scy || 'auto', server: String(j.add), port: int(j.port, 'порт'), + }, transportFromQuery(q, String(j.add))); + } + + const SS_METHODS = [ + 'aes-128-gcm', 'aes-256-gcm', 'chacha20-poly1305', 'chacha20-ietf-poly1305', + 'xchacha20-poly1305', 'xchacha20-ietf-poly1305', + '2022-blake3-aes-128-gcm', '2022-blake3-aes-256-gcm', '2022-blake3-chacha20-poly1305', + ]; + + function parseSs(link) { + let body = link.slice('ss://'.length); + let name = ''; + const h = body.indexOf('#'); + if (h >= 0) { name = dec(body.slice(h + 1)); body = body.slice(0, h); } + const qi = body.indexOf('?'); + let query = ''; + if (qi >= 0) { query = body.slice(qi + 1); body = body.slice(0, qi); } + body = body.replace(/\/$/, ''); + if (new URLSearchParams(query).get('plugin')) throw new Error('плагины Shadowsocks не поддерживаются'); + let userinfo, hostport; + const at = body.lastIndexOf('@'); + if (at >= 0) { + userinfo = body.slice(0, at); + hostport = body.slice(at + 1); + userinfo = userinfo.includes(':') ? dec(userinfo) : b64decode(dec(userinfo)); + } else { + const d = b64decode(body); + const a = d.lastIndexOf('@'); + if (a < 0) throw new Error('ссылка ss повреждена'); + userinfo = d.slice(0, a); + hostport = d.slice(a + 1); + } + const c = userinfo.indexOf(':'); + if (c < 0) throw new Error('в ссылке ss нет метода или пароля'); + const method = userinfo.slice(0, c).toLowerCase(); + const password = userinfo.slice(c + 1); + if (!SS_METHODS.includes(method)) throw new Error('метод Shadowsocks «' + method + '» не поддерживается'); + const u = new URL('ss://x@' + hostport); + return Object.assign({ type: 'ss', name, method, password }, hostPort(u)); + } + + function parseHy2(link) { + const authority = link.replace(/^[a-z0-9]+:\/\//i, '').split(/[/?#]/)[0]; + if (/:\d+[-,]/.test(authority)) throw new Error('диапазоны портов Hysteria2 не поддерживаются'); + const u = new URL(link.replace(/^hysteria2:/, 'hy2:')); + let auth = dec(u.username); + if (u.password) auth += ':' + dec(u.password); + if (!auth) throw new Error('нет пароля'); + const q = u.searchParams; + const hp = hostPort(u); + const obfs = q.get('obfs') || ''; + if (obfs && obfs !== 'salamander') throw new Error('обфускация «' + obfs + '» не поддерживается'); + return Object.assign({ + type: 'hysteria2', name: dec(u.hash.slice(1)), password: auth, + sni: dec(q.get('sni') || '') || hp.server, + insecure: truthy(q.get('insecure')), + pinSHA256: (q.get('pinSHA256') || '').replace(/:/g, '').toLowerCase(), + obfs, obfsPassword: dec(q.get('obfs-password') || ''), + alpn: splitList(q.get('alpn')), + }, hp); + } + + const PARSERS = { 'vless': parseVless, 'vmess': parseVmess, 'trojan': parseTrojan, 'ss': parseSs, 'hy2': parseHy2, 'hysteria2': parseHy2 }; + + function parseLink(line) { + const m = /^([a-z0-9]+):\/\//i.exec(line); + if (!m) throw new Error('это не ссылка на подключение'); + const p = PARSERS[m[1].toLowerCase()]; + if (!p) throw new Error('протокол «' + m[1] + '» не поддерживается'); + const r = p(line); + if (!r.name) r.name = r.type + '-' + r.server; + return r; + } + + // Разбирает текст: ссылки по одной на строку или base64-подписку целиком. + function parseText(text) { + let t = (text || '').trim(); + if (t && !/:\/\//.test(t) && looksBase64(t)) { + try { t = b64decode(t); } catch (e) { /* не base64 — разберём как есть */ } + } + const ok = []; + const errors = []; + t.split(/\r?\n/).map((s) => s.trim()).filter((s) => s && !s.startsWith('#')).forEach((line, i) => { + try { + ok.push(parseLink(line)); + } catch (e) { + errors.push({ line: i + 1, text: line.length > 60 ? line.slice(0, 57) + '…' : line, error: e.message }); + } + }); + // Уникальные имена — клиенты требуют. + const seen = {}; + ok.forEach((p) => { + const base = p.name; + let n = base, k = 2; + while (seen[n]) n = base + ' ' + k++; + seen[n] = true; + p.name = n; + }); + return { proxies: ok, errors }; + } + + const api = { parseLink, parseText, b64decode }; + if (typeof module === 'object' && module.exports) module.exports = api; + root.PM = Object.assign(root.PM || {}, api); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/tools/lib/mihomo.js b/tools/lib/mihomo.js new file mode 100644 index 0000000..e734bf2 --- /dev/null +++ b/tools/lib/mihomo.js @@ -0,0 +1,234 @@ +// Сборка config.yaml для Mihomo (XKeen) из разобранных ссылок. +(function (root) { + 'use strict'; + + const META = 'https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/'; + const REFILTER = 'https://github.com/legiz-ru/mihomo-rule-sets/raw/main/re-filter/'; + + // Сервисы: какие наборы правил отправлять через прокси. + const SERVICES = [ + { id: 'blocked', group: 'Заблокированное', label: 'Заблокированное в России', hint: 'список re:filter — основные заблокированные сайты', on: true, + sets: [['refilter@domain', REFILTER + 'domain-rule.mrs', 'domain'], ['refilter@ipcidr', REFILTER + 'ip-rule.mrs', 'ipcidr']] }, + { id: 'youtube', label: 'YouTube', on: true, sets: [['youtube@domain', META + 'geosite/youtube.mrs', 'domain']] }, + { id: 'telegram', label: 'Telegram', on: true, + sets: [['telegram@domain', META + 'geosite/telegram.mrs', 'domain'], ['telegram@ipcidr', META + 'geoip/telegram.mrs', 'ipcidr']] }, + { id: 'meta', group: 'Meta', label: 'Instagram, Facebook, WhatsApp', on: true, + sets: [['meta@domain', META + 'geosite/meta.mrs', 'domain'], ['facebook@ipcidr', META + 'geoip/facebook.mrs', 'ipcidr']] }, + { id: 'twitter', group: 'X', label: 'X (Twitter)', on: true, + sets: [['twitter@domain', META + 'geosite/twitter.mrs', 'domain'], ['twitter@ipcidr', META + 'geoip/twitter.mrs', 'ipcidr']] }, + { id: 'discord', label: 'Discord', on: true, sets: [['discord@domain', META + 'geosite/discord.mrs', 'domain']] }, + { id: 'ai', group: 'ИИ', label: 'ChatGPT, Claude и другие ИИ', on: true, sets: [['ai@domain', META + 'geosite/category-ai-!cn.mrs', 'domain']] }, + { id: 'tiktok', label: 'TikTok', on: false, sets: [['tiktok@domain', META + 'geosite/tiktok.mrs', 'domain']] }, + { id: 'spotify', label: 'Spotify', on: false, sets: [['spotify@domain', META + 'geosite/spotify.mrs', 'domain']] }, + { id: 'netflix', label: 'Netflix', on: false, + sets: [['netflix@domain', META + 'geosite/netflix.mrs', 'domain'], ['netflix@ipcidr', META + 'geoip/netflix.mrs', 'ipcidr']] }, + { id: 'github', label: 'GitHub', on: false, sets: [['github@domain', META + 'geosite/github.mrs', 'domain']] }, + { id: 'twitch', label: 'Twitch', on: false, sets: [['twitch@domain', META + 'geosite/twitch.mrs', 'domain']] }, + ]; + + // ---------- YAML ---------- + + const PLAIN = /^[A-Za-z0-9_][A-Za-z0-9_.\/@:+-]*$/; + const RESERVED = /^(true|false|yes|no|on|off|null|~|y|n|[-+]?[0-9][0-9_.eE+-]*|0x[0-9a-f]+)$/i; + + function scalar(v) { + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + const s = String(v); + return PLAIN.test(s) && !RESERVED.test(s) && !s.includes(': ') ? s : JSON.stringify(s); + } + + function flow(v) { + if (Array.isArray(v)) return '[' + v.map(flow).join(', ') + ']'; + if (v && typeof v === 'object') return '{' + Object.keys(v).map((k) => scalar(k) + ': ' + flow(v[k])).join(', ') + '}'; + return scalar(v); + } + + function yaml(v, ind) { + ind = ind || ''; + if (Array.isArray(v)) { + if (!v.length) return ' []\n'; + return '\n' + v.map((x) => { + if (x && typeof x === 'object' && !Array.isArray(x)) { + const body = yaml(x, ind + ' ').replace(/^\n/, ''); + return ind + '- ' + body.slice(ind.length + 2); + } + return ind + '- ' + scalar(x) + '\n'; + }).join(''); + } + if (v && typeof v === 'object') { + return '\n' + Object.keys(v).map((k) => { + const x = v[k]; + if (x && typeof x === 'object') { + if (x.__flow) { const c = Object.assign({}, x); delete c.__flow; return ind + scalar(k) + ': ' + flow(c) + '\n'; } + return ind + scalar(k) + ':' + yaml(x, ind + ' '); + } + return ind + scalar(k) + ': ' + scalar(x) + '\n'; + }).join(''); + } + return ' ' + scalar(v) + '\n'; + } + + function clean(o) { + Object.keys(o).forEach((k) => { + const v = o[k]; + if (v === undefined || v === '' || v === false && k !== 'tls' && k !== 'udp' || (Array.isArray(v) && !v.length)) delete o[k]; + }); + return o; + } + + // ---------- прокси ---------- + + function transportOpts(p, o) { + const t = p.transport; + if (t.network === 'tcp') { + if (t.headerType === 'http') { + o.network = 'http'; + o['http-opts'] = clean({ path: [t.path], headers: t.host ? { Host: [t.host] } : undefined }); + } else o.network = 'tcp'; + } else if (t.network === 'ws' || t.network === 'httpupgrade') { + o.network = 'ws'; + o['ws-opts'] = clean({ path: t.path, headers: t.host ? { Host: t.host } : undefined, 'v2ray-http-upgrade': t.network === 'httpupgrade' || undefined }); + } else if (t.network === 'grpc') { + o.network = 'grpc'; + o['grpc-opts'] = { 'grpc-service-name': t.serviceName }; + } else if (t.network === 'xhttp') { + o.network = 'xhttp'; + o['xhttp-opts'] = clean({ path: t.path, host: t.host, mode: t.mode }); + } + } + + function tlsOpts(p, o, sniKey) { + const s = p.tls; + if (s.security === 'none') { o.tls = false; return; } + o.tls = true; + o[sniKey] = s.sni; + o['client-fingerprint'] = s.fp || undefined; + o.alpn = s.alpn; + o['skip-cert-verify'] = s.insecure || undefined; + if (s.security === 'reality') o['reality-opts'] = clean({ 'public-key': s.pbk, 'short-id': s.sid }); + } + + function proxy(p) { + const o = { name: p.name, type: p.type, server: p.server, port: p.port, udp: true }; + switch (p.type) { + case 'vless': + o.uuid = p.uuid; o.flow = p.flow || undefined; + transportOpts(p, o); tlsOpts(p, o, 'servername'); + break; + case 'vmess': + o.uuid = p.uuid; o.alterId = p.alterId; o.cipher = p.cipher; + transportOpts(p, o); tlsOpts(p, o, 'servername'); + break; + case 'trojan': + o.password = p.password; + transportOpts(p, o); tlsOpts(p, o, 'sni'); + if (o.network === 'tcp') delete o.network; + delete o.tls; + break; + case 'ss': + o.cipher = p.method; o.password = p.password; + break; + case 'hysteria2': + o.password = p.password; o.sni = p.sni; o.alpn = p.alpn; + o['skip-cert-verify'] = (p.insecure && !p.pinSHA256) || undefined; + o.fingerprint = p.pinSHA256 || undefined; + if (p.obfs) { o.obfs = p.obfs; o['obfs-password'] = p.obfsPassword; } + break; + } + return clean(o); + } + + function randomSecret() { + const a = new Uint8Array(12); + (root.crypto || require('crypto').webcrypto).getRandomValues(a); + return Array.from(a, (b) => b.toString(16).padStart(2, '0')).join(''); + } + + 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)); + } + + // opts: services[], subscription, perService, finalProxy, blockAds, blockQuic, proxyDomains, directDomains + function build(proxies, opts) { + opts = opts || {}; + const services = SERVICES.filter((s) => (opts.services || SERVICES.filter((x) => x.on).map((x) => x.id)).includes(s.id)); + if (!proxies.length && !opts.subscription) throw new Error('Добавьте хотя бы одну ссылку или подписку.'); + + const cfg = { + 'log-level': 'warning', + 'allow-lan': true, + 'redir-port': 5000, + 'tproxy-port': 5001, + 'routing-mark': 255, + 'find-process-mode': 'off', + 'unified-delay': true, + 'external-controller': '0.0.0.0:9090', + secret: opts.secret || randomSecret(), + 'external-ui': 'zashboard', + 'external-ui-url': 'https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip', + sniffer: { + enable: true, + sniff: { HTTP: { __flow: 1, ports: [80, 8080] }, TLS: { __flow: 1, ports: [443, 8443] }, QUIC: { __flow: 1, ports: [443, 8443] } }, + }, + }; + if (services.some((s) => s.id === 'telegram')) cfg.sniffer['skip-dst-address'] = ['rule-set:telegram@ipcidr']; + + if (proxies.length) cfg.proxies = proxies.map(proxy); + if (opts.subscription) { + cfg['proxy-providers'] = { + subscription: { + type: 'http', url: opts.subscription, interval: 3600, path: './providers/subscription.yaml', + 'health-check': { enable: true, url: 'https://www.gstatic.com/generate_204', interval: 300 }, + }, + }; + } + + const PROXY = 'Прокси'; + const groups = [ + { name: PROXY, type: 'select', proxies: ['Авто'], 'include-all': true }, + { name: 'Авто', type: 'url-test', url: 'https://www.gstatic.com/generate_204', interval: 300, tolerance: 50, 'include-all': true }, + ]; + const target = {}; + services.forEach((s) => { + if (opts.perService) { + // В правилах запятая — разделитель, поэтому имя группы без запятых. + const g = (s.group || s.label).replace(/,/g, ''); + groups.push({ name: g, type: 'select', proxies: [PROXY, 'DIRECT'], 'include-all': true }); + target[s.id] = g; + } else target[s.id] = PROXY; + }); + cfg['proxy-groups'] = groups; + + const providers = {}; + const addSet = (name, url, behavior) => { + providers[name] = { __flow: 1, type: 'http', format: 'mrs', behavior, url, path: './rules/' + name.replace('@', '-') + '.mrs', interval: 86400 }; + }; + services.forEach((s) => s.sets.forEach((x) => addSet(x[0], x[1], x[2]))); + if (opts.blockAds) addSet('ads@domain', META + 'geosite/category-ads-all.mrs', 'domain'); + const pd = domains(opts.proxyDomains), dd = domains(opts.directDomains); + if (pd.length) providers['my-proxy@inline'] = { type: 'inline', behavior: 'domain', payload: pd.map((d) => '+.' + d) }; + if (dd.length) providers['my-direct@inline'] = { type: 'inline', behavior: 'domain', payload: dd.map((d) => '+.' + d) }; + if (Object.keys(providers).length) cfg['rule-providers'] = providers; + + const rules = []; + if (dd.length) rules.push('RULE-SET,my-direct@inline,DIRECT'); + if (opts.blockAds) rules.push('RULE-SET,ads@domain,REJECT'); + if (opts.blockQuic) rules.push('AND,((NETWORK,UDP),(DST-PORT,443)),REJECT'); + if (pd.length) rules.push('RULE-SET,my-proxy@inline,' + PROXY); + services.forEach((s) => s.sets.forEach((x) => { + rules.push('RULE-SET,' + x[0] + ',' + target[s.id] + (x[2] === 'ipcidr' ? ',no-resolve' : '')); + })); + rules.push('MATCH,' + (opts.finalProxy ? PROXY : 'DIRECT')); + cfg.rules = rules; + + const head = '# Сгенерировано: https://pinkmanxxx.github.io/pinkman_manuals/tools/mihomo/\n' + + '# Файл для XKeen: /opt/etc/mihomo/config.yaml, затем xkeen -restart\n' + + '# Панель управления: http://IP-роутера:9090/ui (секрет — поле secret ниже)\n'; + return { yaml: head + yaml(cfg).replace(/^\n/, ''), config: cfg, count: proxies.length }; + } + + const api = { buildMihomo: build, MIHOMO_SERVICES: SERVICES, toYaml: (v) => yaml(v).replace(/^\n/, '') }; + if (typeof module === 'object' && module.exports) module.exports = api; + root.PM = Object.assign(root.PM || {}, api); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/tools/lib/ui.css b/tools/lib/ui.css new file mode 100644 index 0000000..b23cb33 --- /dev/null +++ b/tools/lib/ui.css @@ -0,0 +1,80 @@ +:root { + --bg: #0d1117; --panel: #161b22; --panel2: #1c2330; --line: #30363d; --text: #e6edf3; --muted: #8b949e; + --acc: #3fb950; --acc-bg: #3fb95022; --acc-ink: #0d1117; --warn: #d29922; --err: #f85149; --code: #7ee787; + color-scheme: dark; +} +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --bg: #f6f8fa; --panel: #ffffff; --panel2: #f0f3f6; --line: #d0d7de; --text: #1f2328; --muted: #59636e; + --acc: #1a7f37; --acc-bg: #1a7f3718; --acc-ink: #ffffff; --warn: #9a6700; --err: #cf222e; --code: #116329; + color-scheme: light; + } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.55 Inter, -apple-system, "Segoe UI", Roboto, sans-serif; } +a { color: var(--acc); } +code, pre, textarea.mono { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; } + +header { border-bottom: 1px solid var(--line); background: var(--panel); } +.bar { max-width: 1040px; margin: 0 auto; padding: 12px 16px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; } +.brand { font-weight: 800; color: var(--text); text-decoration: none; } +.brand span { color: var(--acc); } +nav { display: flex; gap: 4px; margin-left: auto; } +nav a { padding: 6px 12px; border-radius: 8px; color: var(--muted); text-decoration: none; font-weight: 600; font-size: 14px; } +nav a.on { background: var(--acc-bg); color: var(--acc); } + +main { max-width: 1040px; margin: 0 auto; padding: 24px 16px 64px; } +h1 { font-size: 26px; margin: 8px 0 4px; } +.lead { color: var(--muted); margin: 0 0 20px; max-width: 720px; } +.privacy { display: inline-flex; gap: 8px; align-items: center; font-size: 13px; color: var(--muted); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; margin-bottom: 24px; } +.privacy b { color: var(--acc); } + +section.step { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; margin-bottom: 16px; } +.step h2 { font-size: 16px; margin: 0 0 12px; display: flex; gap: 10px; align-items: center; } +.num { width: 24px; height: 24px; border-radius: 50%; background: var(--acc); color: var(--acc-ink); display: inline-grid; place-items: center; font-size: 13px; font-weight: 800; flex: none; } +.hint { color: var(--muted); font-size: 13px; margin: 6px 0 0; } + +textarea, input[type=text], input[type=url] { + width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font-size: 14px; +} +textarea { resize: vertical; min-height: 110px; } +textarea:focus, input:focus { outline: 2px solid var(--acc); outline-offset: -1px; border-color: transparent; } +label.field { display: block; font-size: 13px; font-weight: 600; margin: 14px 0 6px; } + +.parsed { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.chip { display: inline-flex; gap: 8px; align-items: center; background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 5px 10px; font-size: 13px; max-width: 100%; } +.chip .p { font-weight: 700; color: var(--acc); text-transform: uppercase; font-size: 11px; letter-spacing: .04em; } +.chip .s { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chip.bad { border-color: var(--err); } +.chip.bad .p { color: var(--err); } +.chip.skip { border-color: var(--warn); } +.chip.skip .p { color: var(--warn); } + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 8px; } +.opt { display: flex; gap: 10px; align-items: flex-start; padding: 9px 11px; border: 1px solid var(--line); border-radius: 10px; cursor: pointer; background: var(--bg); } +.opt:hover { border-color: var(--muted); } +.opt input { margin: 3px 0 0; accent-color: var(--acc); width: 16px; height: 16px; flex: none; } +.opt small { display: block; color: var(--muted); font-size: 12px; } +.opt.off { opacity: .5; cursor: not-allowed; } +.sub { font-size: 13px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin: 18px 0 8px; } +.sub:first-of-type { margin-top: 0; } +.two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +@media (max-width: 640px) { .two { grid-template-columns: 1fr; } } + +.tabs { display: flex; gap: 4px; flex-wrap: wrap; border-bottom: 1px solid var(--line); margin-bottom: 12px; } +.tabs button { background: none; border: 0; color: var(--muted); padding: 8px 12px; font: inherit; font-weight: 600; font-size: 14px; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; } +.tabs button.on { color: var(--text); border-bottom-color: var(--acc); } +.out { position: relative; } +pre.code { margin: 0; background: var(--bg); border: 1px solid var(--line); border-radius: 10px; padding: 14px; overflow: auto; max-height: 460px; color: var(--code); white-space: pre; } +.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } +button.btn { background: var(--acc); color: var(--acc-ink); border: 0; border-radius: 9px; padding: 9px 16px; font: inherit; font-weight: 700; font-size: 14px; cursor: pointer; } +button.btn.ghost { background: var(--panel2); color: var(--text); border: 1px solid var(--line); } +button.btn:disabled { opacity: .45; cursor: not-allowed; } +.msg { font-size: 13px; margin-top: 10px; } +.msg.err { color: var(--err); } +.msg.warn { color: var(--warn); } +.empty { color: var(--muted); padding: 28px; text-align: center; border: 1px dashed var(--line); border-radius: 10px; } +ol.how { margin: 12px 0 0; padding-left: 20px; color: var(--muted); font-size: 14px; } +ol.how li { margin: 4px 0; } +ol.how code { color: var(--text); background: var(--panel2); padding: 1px 5px; border-radius: 5px; } +footer { max-width: 1040px; margin: 0 auto; padding: 0 16px 40px; color: var(--muted); font-size: 13px; } diff --git a/tools/lib/ui.js b/tools/lib/ui.js new file mode 100644 index 0000000..d6d9828 --- /dev/null +++ b/tools/lib/ui.js @@ -0,0 +1,71 @@ +// Общие элементы интерфейса генераторов. +(function (root) { + 'use strict'; + const $ = (s, el) => (el || document).querySelector(s); + const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + + const store = { + get(k) { try { return localStorage.getItem('pm:' + k); } catch (e) { return null; } }, + set(k, v) { try { localStorage.setItem('pm:' + k, v); } catch (e) { /* приватный режим */ } }, + }; + + function renderParsed(el, parsed, skipped) { + const sk = {}; + (skipped || []).forEach((s) => { sk[s.name] = s.error; }); + const chips = parsed.proxies.map((p) => { + const why = sk[p.name]; + return '' + + '' + esc(p.type === 'hysteria2' ? 'hy2' : p.type) + '' + esc(p.name) + + '' + esc(why ? 'пропущен: ' + why : p.server + ':' + p.port) + ''; + }); + parsed.errors.forEach((e) => { + chips.push('ошибкастрока ' + e.line + + '' + esc(e.error) + ''); + }); + el.innerHTML = chips.join(''); + } + + function tabs(el, onChange) { + el.addEventListener('click', (e) => { + const b = e.target.closest('button[data-tab]'); + if (!b) return; + el.querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b)); + onChange(b.dataset.tab); + }); + } + + async function copy(text, btn) { + try { + await navigator.clipboard.writeText(text); + } catch (e) { + const t = document.createElement('textarea'); + t.value = text; document.body.appendChild(t); t.select(); document.execCommand('copy'); t.remove(); + } + const old = btn.textContent; + btn.textContent = 'Скопировано ✓'; + setTimeout(() => { btn.textContent = old; }, 1600); + } + + function download(name, text) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + } + + // Команда для SSH: записывает файлы и перезапускает XKeen. + function routerCommand(files, dir, switchCmd) { + const parts = ['# Вставьте целиком в SSH-консоль роутера (Entware)', '# ' + switchCmd + ' ← раскомментируйте, если сейчас работает другое ядро', 'mkdir -p ' + dir]; + Object.keys(files).forEach((name) => { + let body = files[name]; + let tag = 'PMEOF'; + while (body.includes(tag)) tag += 'X'; + parts.push("cat > " + dir + '/' + name + " <<'" + tag + "'\n" + body.replace(/\n?$/, '\n') + tag); + }); + parts.push('xkeen -restart'); + return parts.join('\n') + '\n'; + } + + root.UI = { $, esc, store, renderParsed, tabs, copy, download, routerCommand }; +})(window); diff --git a/tools/lib/xray.js b/tools/lib/xray.js new file mode 100644 index 0000000..2c37d4a --- /dev/null +++ b/tools/lib/xray.js @@ -0,0 +1,178 @@ +// Сборка конфигов 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); diff --git a/tools/mihomo/index.html b/tools/mihomo/index.html new file mode 100644 index 0000000..f579cf8 --- /dev/null +++ b/tools/mihomo/index.html @@ -0,0 +1,131 @@ + + + + + +Генератор Mihomo для XKeen + + + + + +
+ Pinkman Manuals + +
+ +
+

Конфиг Mihomo для XKeen

+

Ссылки на серверы или подписка — и готовый config.yaml с правилами для нужных сервисов, + автовыбором быстрого сервера и веб-панелью. Плюс команда, которая сама положит его на роутер.

+
🔒 Всё считается в вашем браузере — ссылки никуда не отправляются
+ +
+

1Серверы

+ +
+ + +

Подписку роутер скачает сам и будет обновлять каждый час. Поддерживаются vless (REALITY, TLS, ws, gRPC, xhttp), + vmess, trojan, shadowsocks и Hysteria2.

+
+ +
+

2Что пускать через прокси

+
Сервисы
+
+
Дополнительно
+
+ + + + +
+
+
+
+
+
+
+
+ +
+

3Готовый конфиг

+
Добавьте ссылку или подписку в шаг 1 — здесь появится конфиг.
+
+
+ + + + + + + + diff --git a/tools/test/links.test.js b/tools/test/links.test.js new file mode 100644 index 0000000..96f9098 --- /dev/null +++ b/tools/test/links.test.js @@ -0,0 +1,106 @@ +// Тесты разбора ссылок и сборки конфигов: node --test tools/test/ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { parseLink, parseText } = require('../lib/links.js'); +const { buildXray } = require('../lib/xray.js'); +const { buildMihomo } = require('../lib/mihomo.js'); + +const REALITY = 'vless://5f2c1e7a-0b3d-4c6e-9a8f-1234567890ab@203.0.113.10:443?type=tcp&security=reality&pbk=Fj98Liz-8fJCAaK_hcsGDJRSd9ztkw0TDR2v4kqK7TA&fp=chrome&sni=www.microsoft.com&sid=2831cdcf4a278ac2&spx=%2F&flow=xtls-rprx-vision#%D0%93%D0%B5%D1%80%D0%BC%D0%B0%D0%BD%D0%B8%D1%8F'; +const WS_TLS = 'vless://11111111-2222-3333-4444-555555555555@cdn.example.com:443?type=ws&security=tls&path=%2Fws%3Fed%3D2048&host=cdn.example.com&sni=cdn.example.com&alpn=h2%2Chttp%2F1.1#ws'; +const XHTTP = 'vless://11111111-2222-3333-4444-555555555555@x.example.com:443?type=xhttp&security=reality&pbk=abc&sid=01&sni=www.apple.com&path=%2Fx&mode=packet-up#xhttp'; +const GRPC = 'trojan://p%40ss@t.example.com:443?type=grpc&serviceName=gun&security=tls&sni=t.example.com#grpc'; +const VMESS = 'vmess://' + Buffer.from(JSON.stringify({ v: '2', ps: 'vm', add: 'v.example.com', port: '8443', id: '11111111-2222-3333-4444-555555555555', aid: '0', scy: 'auto', net: 'ws', host: 'v.example.com', path: '/vm', tls: 'tls', sni: 'v.example.com' })).toString('base64'); +const SS_B64 = 'ss://' + Buffer.from('chacha20-ietf-poly1305:secret').toString('base64url') + '@198.51.100.7:8388#ss'; +const SS_2022 = 'ss://2022-blake3-aes-128-gcm:YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D@198.51.100.8:443#ss22'; +const SS_OLD = 'ss://' + Buffer.from('aes-256-gcm:pw@198.51.100.9:1234').toString('base64') + '#old'; +const HY2 = 'hy2://admin:9c1e5b@hy.example.com:443/?sni=hy.example.com&insecure=1&pinSHA256=AB:CD:EF&obfs=salamander&obfs-password=x#hy'; + +test('REALITY', () => { + const p = parseLink(REALITY); + assert.equal(p.type, 'vless'); + assert.equal(p.name, 'Германия'); + assert.equal(p.tls.security, 'reality'); + assert.equal(p.tls.pbk, 'Fj98Liz-8fJCAaK_hcsGDJRSd9ztkw0TDR2v4kqK7TA'); + assert.equal(p.tls.spx, '/'); + assert.equal(p.flow, 'xtls-rprx-vision'); +}); + +test('WS + TLS с путём и ALPN', () => { + const p = parseLink(WS_TLS); + assert.equal(p.transport.path, '/ws?ed=2048'); + assert.deepEqual(p.tls.alpn, ['h2', 'http/1.1']); +}); + +test('xhttp, gRPC trojan, vmess', () => { + assert.equal(parseLink(XHTTP).transport.mode, 'packet-up'); + const g = parseLink(GRPC); + assert.equal(g.password, 'p@ss'); + assert.equal(g.transport.serviceName, 'gun'); + const v = parseLink(VMESS); + assert.equal(v.port, 8443); + assert.equal(v.transport.path, '/vm'); +}); + +test('Shadowsocks во всех трёх форматах', () => { + assert.equal(parseLink(SS_B64).method, 'chacha20-ietf-poly1305'); + assert.equal(parseLink(SS_2022).password, 'YctPZ6U7xPPcU+gp3u+0tx/tRizJN9K8y+uKlW2qjlI='); + const o = parseLink(SS_OLD); + assert.equal(o.server, '198.51.100.9'); + assert.equal(o.port, 1234); +}); + +test('Hysteria2: логин:пароль и отпечаток', () => { + const h = parseLink(HY2); + assert.equal(h.password, 'admin:9c1e5b'); + assert.equal(h.pinSHA256, 'abcdef'); + assert.equal(h.obfs, 'salamander'); +}); + +test('подписка в base64 и ошибки по строкам', () => { + const sub = Buffer.from([REALITY, 'garbage', 'tuic://x@y:1', HY2].join('\n')).toString('base64'); + const r = parseText(sub); + assert.equal(r.proxies.length, 2); + assert.equal(r.errors.length, 2); + assert.match(r.errors[1].error, /tuic/); +}); + +test('одинаковые имена становятся уникальными', () => { + const r = parseText(REALITY + '\n' + REALITY); + assert.deepEqual(r.proxies.map((p) => p.name), ['Германия', 'Германия 2']); +}); + +test('битые ссылки дают понятную ошибку', () => { + assert.throws(() => parseLink('vless://@h:443'), /UUID/); + assert.throws(() => parseLink('vless://id@h:443?security=reality'), /pbk/); + assert.throws(() => parseLink('hy2://pw@h:443-500'), /диапазон/); + assert.throws(() => parseLink('vless://id@h:99999'), /порт|Invalid/); +}); + +test('Xray: теги, балансировщик и маршрутизация', () => { + const one = buildXray(parseText(REALITY).proxies, {}); + assert.equal(one.files['04_outbounds.json'].outbounds[0].tag, 'vless-reality'); + assert.equal(one.files['04_outbounds.json'].outbounds[0].streamSettings.realitySettings.publicKey, 'Fj98Liz-8fJCAaK_hcsGDJRSd9ztkw0TDR2v4kqK7TA'); + 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.ok(multi.files['05_routing.json'].routing.balancers); +}); + +test('Xray: сервисы без нужной геобазы не попадают в правила', () => { + const r = buildXray(parseText(REALITY).proxies, { bases: ['zkeen'], services: ['twitter', 'youtube'] }); + const s = JSON.stringify(r.files['05_routing.json']); + assert.ok(!s.includes('twitter')); + assert.ok(s.includes('ext:zkeen.dat:youtube')); +}); + +test('Mihomo: порты XKeen и прокси', () => { + const r = buildMihomo(parseText([REALITY, HY2, SS_2022].join('\n')).proxies, { secret: 'x' }); + assert.equal(r.config['redir-port'], 5000); + assert.equal(r.config['tproxy-port'], 5001); + assert.equal(r.config['routing-mark'], 255); + assert.equal(r.config.proxies.length, 3); + assert.equal(r.config.proxies[1].fingerprint, 'abcdef'); + assert.match(r.yaml, /name: "Германия"/); +}); diff --git a/tools/xray/index.html b/tools/xray/index.html new file mode 100644 index 0000000..19c0110 --- /dev/null +++ b/tools/xray/index.html @@ -0,0 +1,166 @@ + + + + + +Генератор Xray для XKeen + + + + + +
+ Pinkman Manuals + +
+ +
+

Конфиг Xray для XKeen

+

Вставьте ссылку на свой сервер — получите готовые 04_outbounds.json и 05_routing.json + и одну команду, которая сама положит их на роутер.

+
🔒 Всё считается в вашем браузере — ссылки никуда не отправляются
+ +
+

1Ссылки на серверы

+ +
+

Поддерживаются vless (REALITY, TLS, ws, gRPC, xhttp, httpupgrade), vmess, trojan и shadowsocks. + Несколько серверов — Xray сам выберет самый быстрый. Hysteria2 работает через Mihomo.

+
+ +
+

2Что пускать через прокси

+
Геобазы на роутере
+
+

Какие базы вы выбрали при установке XKeen. Не уверены — оставьте ZKeen: её XKeen ставит по умолчанию.

+
Сервисы
+
+
Дополнительно
+
+ + + +
+
+
+
+
+
+
+ + +

XKeen по умолчанию ожидает vless-reality — менять стоит, только если вы правили правила вручную.

+
+ +
+

3Готовый конфиг

+
Вставьте ссылку в шаг 1 — здесь появятся файлы.
+
+
+ + + + + + + +