Files
alpine-router/public/proxy.js
2026-04-13 18:56:13 +03:00

2249 lines
91 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.
'use strict';
const PS = {
status: null,
config: null,
};
// ─── API helpers ───
async function api(method, path, body) {
const opts = {
method,
headers: body ? (body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) : {},
};
if (body && !(body instanceof FormData)) {
opts.body = JSON.stringify(body);
} else if (body instanceof FormData) {
opts.body = body;
}
const res = await fetch(path, opts);
const json = await res.json();
if (!res.ok || !json.success) {
throw new Error(json.error || `HTTP ${res.status}`);
}
return json.data;
}
const get = (path) => api('GET', path);
const post = (path, body) => api('POST', path, body);
const put = (path, body) => api('PUT', path, body);
function showToast(msg, type = 'info') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `toast ${type}`;
t.classList.remove('hidden');
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.add('hidden'), 3500);
}
function esc(s) {
if (s == null) return '';
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
// ─── Segmented buttons ───
function setSegBtn(id, mode) {
document.querySelectorAll(`#${id} .seg-btn`).forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
}
function getSegBtn(id) {
const active = document.querySelector(`#${id} .seg-btn.active`);
return active ? active.dataset.mode : '';
}
// Generic seg btn click handler
document.querySelectorAll('.segmented').forEach(seg => {
seg.addEventListener('click', e => {
const btn = e.target.closest('.seg-btn');
if (!btn || !seg.id) return;
seg.querySelectorAll('.seg-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// ─── Config accessors ───
function getProxies() { return PS.config && Array.isArray(PS.config.proxies) ? PS.config.proxies : []; }
function getGroups() { return PS.config && Array.isArray(PS.config['proxy-groups']) ? PS.config['proxy-groups'] : []; }
function getRules() { return PS.config && Array.isArray(PS.config.rules) ? PS.config.rules : []; }
function getProxyProviders() {
const pp = PS.config && PS.config['proxy-providers'];
if (!pp || typeof pp !== 'object') return [];
return Object.entries(pp).map(([name, val]) => ({ name, ...val }));
}
function getRuleProviders() {
const rp = PS.config && PS.config['rule-providers'];
if (!rp || typeof rp !== 'object') return [];
return Object.entries(rp).map(([name, val]) => ({ name, ...val }));
}
// ─── Type labels ───
const PROTO_LABELS = {
ss: 'Shadowsocks', ssr: 'ShadowsocksR', vmess: 'VMess', vless: 'VLESS',
trojan: 'Trojan', hysteria: 'Hysteria', hysteria2: 'Hysteria2', tuic: 'TUIC',
wireguard: 'WireGuard', snell: 'Snell', ssh: 'SSH', anytls: 'AnyTLS',
mieru: 'Mieru', http: 'HTTP', socks5: 'SOCKS5', direct: 'DIRECT',
};
function typeLabel(t) { return PROTO_LABELS[t] || t; }
const GROUP_LABELS = { select: 'Выбор', 'url-test': 'Автотест', fallback: 'Резерв', 'load-balance': 'Балансировка', relay: 'Relay' };
function groupTypeLabel(t) { return GROUP_LABELS[t] || t; }
// ─── Load / Save ───
async function loadStatus() {
try {
PS.status = await get('/api/mihomo/status');
renderStatus();
} catch (e) {
console.error('load status', e);
}
}
async function loadConfig() {
try {
PS.config = await get('/api/mihomo/config');
renderAll();
} catch (e) {
console.error('load config', e);
PS.config = {};
renderAll();
showToast('Ошибка загрузки конфига: ' + e.message, 'error');
}
document.getElementById('loading').classList.add('hidden');
}
async function saveFullConfig(restart) {
try {
await put('/api/mihomo/config', PS.config);
showToast('Конфиг сохранён', 'success');
refreshYAMLPreview();
if (restart) {
try {
await post('/api/mihomo/restart', null);
showToast('Mihomo перезапущен', 'success');
} catch (e) {
showToast('Перезапуск не удался: ' + e.message, 'error');
}
await loadStatus();
}
} catch (e) {
showToast('Ошибка сохранения: ' + e.message, 'error');
}
}
async function refreshYAMLPreview() {
try {
const res = await fetch('/api/mihomo/config.yaml');
if (res.ok) {
const el = document.getElementById('yamlPreview');
if (el) el.value = await res.text();
}
} catch (e) { /* ignore */ }
}
// ─── Status rendering ───
function renderStatus() {
const s = PS.status;
if (!s) return;
const text = document.getElementById('statusText');
const headerBadge = document.getElementById('statusBadge');
const toggle = document.getElementById('serviceActive');
const toggleText = document.getElementById('serviceToggleText');
const restartBtn = document.getElementById('restartBtn');
const coreInfo = document.getElementById('coreInfo');
if (s.running) {
text.className = 'svc-badge running'; text.textContent = 'Запущен (PID ' + (s.pid || '?') + ')';
headerBadge.className = 'svc-badge running'; headerBadge.textContent = 'Запущен';
toggle.checked = true; toggleText.textContent = 'Запущен'; restartBtn.disabled = false;
} else {
text.className = 'svc-badge stopped'; text.textContent = 'Остановлен';
headerBadge.className = 'svc-badge stopped'; headerBadge.textContent = 'Остановлен';
toggle.checked = false; toggleText.textContent = 'Остановлен'; restartBtn.disabled = true;
}
const corePath = document.getElementById('corePath');
const coreExists = document.getElementById('coreExists');
const corePid = document.getElementById('corePid');
if (corePath) corePath.textContent = s.core_path || '—';
if (coreExists) coreExists.textContent = s.core_exists ? 'Да' : 'Нет';
if (corePid) corePid.textContent = s.running && s.pid ? s.pid : '—';
if (!s.core_exists) {
coreInfo.classList.remove('hidden');
} else {
coreInfo.classList.add('hidden');
}
document.getElementById('statusBar').classList.remove('hidden');
document.getElementById('loading').classList.add('hidden');
}
// ─── Render all ───
function renderAll() {
renderProxies();
renderGroups();
renderRules();
renderProxyProviders();
renderRuleProviders();
fillSettings();
refreshYAMLPreview();
}
// ─── Render proxies ───
function renderProxies() {
const list = document.getElementById('proxyList');
const empty = document.getElementById('proxyEmpty');
const proxies = getProxies();
list.innerHTML = '';
if (proxies.length === 0) { empty.classList.remove('hidden'); return; }
empty.classList.add('hidden');
proxies.forEach(p => {
const card = document.createElement('div');
card.className = 'proxy-card';
const server = p.type === 'direct' ? '' :
p.type === 'wireguard' ? (p.ip || '—') :
`<div class="info-row"><span class="info-label">Сервер</span><span class="info-val mono">${esc(p.server || '—')}</span></div>
<div class="info-row"><span class="info-label">Порт</span><span class="info-val mono">${p.port || '—'}</span></div>`;
const tags = [
p.udp ? '<span class="tag-active">UDP</span>' : '',
p.tls ? '<span class="tag-active">TLS</span>' : '',
p.network ? `<span class="tag-active">${esc(p.network)}</span>` : '',
].filter(Boolean).join('');
card.innerHTML = `
<div class="proxy-card-header">
<div style="display:flex;align-items:center;gap:8px">
<span class="proxy-name">${esc(p.name)}</span>
<span class="tag-active">${esc(typeLabel(p.type))}</span>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" data-edit-proxy="${esc(p.name)}">Изменить</button>
<button class="btn btn-danger btn-sm" data-delete-proxy="${esc(p.name)}">Удалить</button>
</div>
</div>
<div class="proxy-card-info">${server}${tags ? `<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px">${tags}</div>` : ''}</div>`;
list.appendChild(card);
});
}
// ─── Render groups ───
function renderGroups() {
const list = document.getElementById('groupList');
const empty = document.getElementById('groupEmpty');
const groups = getGroups();
list.innerHTML = '';
if (groups.length === 0) { empty.classList.remove('hidden'); return; }
empty.classList.add('hidden');
groups.forEach(g => {
const card = document.createElement('div');
card.className = 'proxy-card';
const pl = (g.proxies || []).slice(0, 5).map(p => esc(p)).join(', ');
const more = (g.proxies || []).length > 5 ? ` +${(g.proxies || []).length - 5}` : '';
card.innerHTML = `
<div class="proxy-card-header">
<div style="display:flex;align-items:center;gap:8px">
<span class="proxy-name">${esc(g.name)}</span>
<span class="tag-gw">${esc(groupTypeLabel(g.type))}</span>
${g['include-all'] ? '<span class="tag-active">Все прокси</span>' : ''}
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" data-edit-group="${esc(g.name)}">Изменить</button>
<button class="btn btn-danger btn-sm" data-delete-group="${esc(g.name)}">Удалить</button>
</div>
</div>
<div class="proxy-card-info">
<div class="info-row"><span class="info-label">Узлы</span><span class="info-val">${pl || '—'}${more}</span></div>
${g.url ? `<div class="info-row"><span class="info-label">URL</span><span class="info-val mono" style="font-size:.75rem">${esc(g.url)}</span></div>` : ''}
${g.interval ? `<div class="info-row"><span class="info-label">Интервал</span><span class="info-val">${g.interval}с</span></div>` : ''}
</div>`;
list.appendChild(card);
});
}
// ─── Render rules ───
function renderRules() {
const list = document.getElementById('rulesList');
const rules = getRules();
list.innerHTML = '';
if (rules.length === 0) { list.innerHTML = '<div class="empty-state">Нет правил маршрутизации</div>'; return; }
rules.forEach((rule, i) => {
const parts = rule.split(',');
const type = parts[0] || '';
const value = parts[1] || '';
const target = parts[2] || '';
const flags = parts.slice(3).join(',');
const el = document.createElement('div');
el.className = 'rule-item';
el.innerHTML = `
<div class="rule-info">
<span class="rule-type">${esc(type)}</span>
<span class="rule-value">${esc(value || (type === 'MATCH' ? '*' : ''))}</span>
<span class="rule-target">${esc(target)}</span>
${flags ? `<span class="rule-flags">${esc(flags)}</span>` : ''}
</div>
<div style="display:flex;gap:4px">
<button class="btn btn-danger btn-sm" data-delete-rule="${i}">✕</button>
</div>`;
list.appendChild(el);
});
}
// ─── Render proxy providers ───
function renderProxyProviders() {
const list = document.getElementById('proxyProviderList');
const empty = document.getElementById('proxyProviderEmpty');
const providers = getProxyProviders();
list.innerHTML = '';
if (providers.length === 0) { empty.style.display = ''; return; }
empty.style.display = 'none';
providers.forEach(p => {
const card = document.createElement('div');
card.className = 'proxy-card';
card.innerHTML = `
<div class="proxy-card-header">
<div style="display:flex;align-items:center;gap:8px">
<span class="proxy-name">${esc(p.name)}</span>
<span class="tag-active">${esc(p.type || 'http')}</span>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" data-edit-pp="${esc(p.name)}">Изменить</button>
<button class="btn btn-danger btn-sm" data-delete-pp="${esc(p.name)}">Удалить</button>
</div>
</div>
<div class="proxy-card-info">
${p.url ? `<div class="info-row"><span class="info-label">URL</span><span class="info-val mono" style="font-size:.75rem">${esc(p.url)}</span></div>` : ''}
${p.interval ? `<div class="info-row"><span class="info-label">Интервал</span><span class="info-val">${p.interval}с</span></div>` : ''}
${p.filter ? `<div class="info-row"><span class="info-label">Filter</span><span class="info-val mono">${esc(p.filter)}</span></div>` : ''}
</div>`;
list.appendChild(card);
});
}
// ─── Render rule providers ───
function renderRuleProviders() {
const list = document.getElementById('ruleProviderList');
const empty = document.getElementById('ruleProviderEmpty');
const providers = getRuleProviders();
list.innerHTML = '';
if (providers.length === 0) { empty.style.display = ''; return; }
empty.style.display = 'none';
providers.forEach(p => {
const card = document.createElement('div');
card.className = 'proxy-card';
card.innerHTML = `
<div class="proxy-card-header">
<div style="display:flex;align-items:center;gap:8px">
<span class="proxy-name">${esc(p.name)}</span>
<span class="tag-active">${esc(p.type || 'http')}</span>
<span class="tag-gw">${esc(p.behavior || 'classical')}</span>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" data-edit-rp="${esc(p.name)}">Изменить</button>
<button class="btn btn-danger btn-sm" data-delete-rp="${esc(p.name)}">Удалить</button>
</div>
</div>
<div class="proxy-card-info">
${p.url ? `<div class="info-row"><span class="info-label">URL</span><span class="info-val mono" style="font-size:.75rem">${esc(p.url)}</span></div>` : ''}
${p.format ? `<div class="info-row"><span class="info-label">Format</span><span class="info-val">${esc(p.format)}</span></div>` : ''}
</div>`;
list.appendChild(card);
});
}
// ─── Fill settings ───
function fillSettings() {
if (!PS.config) return;
const c = PS.config;
const dns = c.dns || {};
const geo = c['geox-url'] || {};
const profile = c.profile || {};
// Basic
setSegBtn('modeSwitch', c.mode || 'rule');
setSegBtn('logLevelSwitch', c['log-level'] || 'info');
setVal('allowLan', c['allow-lan'] !== false, true);
setVal('bindAddress', c['bind-address'] || '*');
setVal('ipv6', c.ipv6 !== false, true);
setVal('tcpConcurrent', c['tcp-concurrent'] !== false, true);
setVal('unifiedDelay', !!c['unified-delay'], true);
setSegBtn('findProcessSwitch', c['find-process-mode'] || 'off');
setVal('globalUA', c['global-ua'] || '');
setVal('keepAliveInterval', c['keep-alive-interval'] || '');
setVal('keepAliveIdle', c['keep-alive-idle'] || '');
// Ports
setVal('mixedPort', c['mixed-port'] || 7890);
setVal('httpPort', c.port || '');
setVal('socksPort', c['socks-port'] || '');
setVal('redirPort', c['redir-port'] || '');
// TProxy
setVal('tproxyEnabled', !!c['tproxy-port'], true);
setVal('tproxyPort', c['tproxy-port'] || 7894);
// DNS
setVal('dnsEnabled', dns.enable !== false, true);
setVal('dnsListen', dns.listen || '0.0.0.0:1053');
setSegBtn('dnsModeSwitch', dns['enhanced-mode'] || 'redir-host');
setVal('dnsNameserver', (dns.nameserver || []).join('\n'));
setVal('dnsFallback', (dns.fallback || []).join('\n'));
setVal('dnsDefaultNS', (dns['default-nameserver'] || []).join('\n'));
setVal('dnsProxyNS', (dns['proxy-server-nameserver'] || []).join('\n'));
setVal('dnsFakeIPRange', dns['fake-ip-range'] || '198.18.0.1/16');
setVal('dnsFakeIPFilter', (dns['fake-ip-filter'] || []).join('\n'));
const nsp = dns['nameserver-policy'];
setVal('dnsNameserverPolicy', nsp && typeof nsp === 'object' ?
Object.entries(nsp).map(([k, v]) => `${k}: ${v}`).join('\n') : '');
setVal('dnsUseHosts', dns['use-hosts'] !== false, true);
setVal('dnsUseSystemHosts', dns['use-system-hosts'] !== false, true);
// GEO
setVal('geodataMode', !!c['geodata-mode'], true);
setSegBtn('geodataLoaderSwitch', c['geodata-loader'] || 'memconservative');
setVal('geoAutoUpdate', !!c['geo-auto-update'], true);
setVal('geoUpdateInterval', c['geo-update-interval'] || 24);
setVal('geoxGeoIP', geo.geoip || '');
setVal('geoxGeoSite', geo.geosite || '');
setVal('geoxMMDB', geo.mmdb || '');
setVal('geoxASN', geo.asn || '');
// External controller
setVal('externalController', c['external-controller'] || '0.0.0.0:9090');
setVal('secret', c.secret || '');
setVal('externalUI', c['external-ui'] || '');
setVal('externalUIName', c['external-ui-name'] || '');
setVal('externalUIURL', c['external-ui-url'] || '');
// Profile
setVal('profileStoreSelected', profile['store-selected'] !== false, true);
setVal('profileStoreFakeIP', !!profile['store-fake-ip'], true);
}
function setVal(id, val, isCheck) {
const el = document.getElementById(id);
if (!el) return;
if (isCheck) el.checked = !!val;
else el.value = val == null ? '' : val;
}
function gVal(id) {
const el = document.getElementById(id);
return el ? el.value : '';
}
function gCheck(id) {
const el = document.getElementById(id);
return el ? el.checked : false;
}
function gNum(id, def) {
const v = parseInt(gVal(id));
return isNaN(v) ? (def || 0) : v;
}
function gLines(id) {
return gVal(id).split('\n').map(s => s.trim()).filter(Boolean);
}
// ─── Apply settings to config ───
function applySettingsToConfig() {
if (!PS.config) PS.config = {};
const c = PS.config;
c.mode = getSegBtn('modeSwitch');
c['log-level'] = getSegBtn('logLevelSwitch');
c['allow-lan'] = gCheck('allowLan');
c['bind-address'] = gVal('bindAddress') || '*';
c.ipv6 = gCheck('ipv6');
c['tcp-concurrent'] = gCheck('tcpConcurrent');
c['unified-delay'] = gCheck('unifiedDelay');
c['find-process-mode'] = getSegBtn('findProcessSwitch');
const ua = gVal('globalUA');
if (ua) c['global-ua'] = ua; else delete c['global-ua'];
const kai = gNum('keepAliveInterval', 0);
if (kai) c['keep-alive-interval'] = kai; else delete c['keep-alive-interval'];
const kaIdle = gNum('keepAliveIdle', 0);
if (kaIdle) c['keep-alive-idle'] = kaIdle; else delete c['keep-alive-idle'];
// Ports
c['mixed-port'] = gNum('mixedPort', 7890);
const hp = gNum('httpPort', 0);
if (hp) c.port = hp; else delete c.port;
const sp = gNum('socksPort', 0);
if (sp) c['socks-port'] = sp; else delete c['socks-port'];
const rp = gNum('redirPort', 0);
if (rp) c['redir-port'] = rp; else delete c['redir-port'];
// TProxy
if (gCheck('tproxyEnabled')) {
c['tproxy-port'] = gNum('tproxyPort', 7894);
} else {
delete c['tproxy-port'];
}
// DNS
const nspRaw = gVal('dnsNameserverPolicy').trim();
let nsp = null;
if (nspRaw) {
nsp = {};
nspRaw.split('\n').forEach(line => {
const idx = line.indexOf(':');
if (idx > 0) { nsp[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); }
});
}
c.dns = {
enable: gCheck('dnsEnabled'),
ipv6: gCheck('ipv6'),
listen: gVal('dnsListen'),
'enhanced-mode': getSegBtn('dnsModeSwitch'),
'fake-ip-range': gVal('dnsFakeIPRange') || '198.18.0.1/16',
'fake-ip-filter': gLines('dnsFakeIPFilter'),
'default-nameserver': gLines('dnsDefaultNS'),
nameserver: gLines('dnsNameserver'),
fallback: gLines('dnsFallback'),
'use-hosts': gCheck('dnsUseHosts'),
'use-system-hosts': gCheck('dnsUseSystemHosts'),
};
const proxyNS = gLines('dnsProxyNS');
if (proxyNS.length) c.dns['proxy-server-nameserver'] = proxyNS;
if (nsp) c.dns['nameserver-policy'] = nsp;
// GEO
c['geodata-mode'] = gCheck('geodataMode');
c['geodata-loader'] = getSegBtn('geodataLoaderSwitch');
c['geo-auto-update'] = gCheck('geoAutoUpdate');
c['geo-update-interval'] = gNum('geoUpdateInterval', 24);
const geox = {};
['geoip', 'geosite', 'mmdb', 'asn'].forEach(k => {
const el = document.getElementById(`geox${k.charAt(0).toUpperCase() + k.slice(1)}`);
if (el && el.value.trim()) geox[k] = el.value.trim();
});
if (Object.keys(geox).length) c['geox-url'] = geox; else delete c['geox-url'];
// External controller
c['external-controller'] = gVal('externalController');
c.secret = gVal('secret') || '';
const extUI = gVal('externalUI');
if (extUI) c['external-ui'] = extUI; else delete c['external-ui'];
const extUIName = gVal('externalUIName');
if (extUIName) c['external-ui-name'] = extUIName; else delete c['external-ui-name'];
const extUIURL = gVal('externalUIURL');
if (extUIURL) c['external-ui-url'] = extUIURL; else delete c['external-ui-url'];
// Profile
c.profile = {
'store-selected': gCheck('profileStoreSelected'),
'store-fake-ip': gCheck('profileStoreFakeIP'),
};
}
// ─── Tab switching ───
document.querySelectorAll('.ptab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.ptab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.ptab-content').forEach(c => c.classList.add('hidden'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.remove('hidden');
if (btn.dataset.tab === 'dashboard') {
updateDashVisibility();
if (PS.status && PS.status.running) startDashPolling();
} else {
stopDashPolling();
}
});
});
// ─── Core control ───
document.getElementById('serviceActive').addEventListener('change', async (e) => {
const action = e.target.checked ? 'start' : 'stop';
try {
await post('/api/mihomo/' + action, null);
showToast('Mihomo ' + (action === 'start' ? 'запущен' : 'остановлен'), 'success');
await loadStatus();
updateDashVisibility();
if (action === 'start') {
const dashTab = document.querySelector('.ptab[data-tab="dashboard"]');
if (dashTab && dashTab.classList.contains('active')) startDashPolling();
} else {
stopDashPolling();
}
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
await loadStatus();
updateDashVisibility();
}
});
document.getElementById('restartBtn').addEventListener('click', async () => {
try {
await post('/api/mihomo/restart', null);
showToast('Mihomo перезапущен', 'success');
await loadStatus();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
// ─── Settings ───
document.getElementById('settingsForm').addEventListener('submit', e => {
e.preventDefault();
applySettingsToConfig();
saveFullConfig(false);
});
document.getElementById('saveAndRestartBtn').addEventListener('click', () => {
applySettingsToConfig();
saveFullConfig(true);
});
// ════════════════════════════════════════════════════════════
// PROXY MODAL
// ════════════════════════════════════════════════════════════
// Protocols needing transport
const TRANSPORT_PROTOS = ['vmess', 'vless', 'trojan'];
// Protocols needing TLS section (always shown)
const TLS_PROTOS = ['ss', 'vmess', 'vless', 'trojan', 'hysteria', 'hysteria2', 'tuic', 'snell', 'anytls', 'http', 'socks5'];
// TLS always on (no toggle)
const TLS_ALWAYS = ['trojan', 'hysteria', 'hysteria2', 'tuic'];
// Protocols with client-fingerprint
const CLIENT_FP_PROTOS = ['vmess', 'vless', 'trojan', 'anytls'];
// Protocols with Reality support (shows Reality section inside TLS)
const REALITY_PROTOS = ['vless', 'vmess'];
// All proto-specific section IDs
const PROTO_SECTIONS = ['pf-ss', 'pf-ssr', 'pf-vmess', 'pf-vless', 'pf-trojan',
'pf-hysteria', 'pf-hysteria2', 'pf-tuic', 'pf-wireguard', 'pf-snell',
'pf-ssh', 'pf-anytls', 'pf-mieru', 'pf-http-auth'];
function showEl(id) { const e = document.getElementById(id); if (e) e.classList.remove('hidden'); }
function hideEl(id) { const e = document.getElementById(id); if (e) e.classList.add('hidden'); }
function updateProxyFields() {
const type = document.getElementById('proxyType').value;
// Hide all proto sections
PROTO_SECTIONS.forEach(hideEl);
// Server/port visibility
if (type === 'direct') {
hideEl('pf-server-section');
} else {
showEl('pf-server-section');
}
// UDP row visibility
const noUDP = ['wireguard']; // WireGuard has its own udp field
// actually WireGuard does have udp field in common but let's keep it
// Show the appropriate proto section
switch (type) {
case 'ss': showEl('pf-ss'); break;
case 'ssr': showEl('pf-ssr'); break;
case 'vmess': showEl('pf-vmess'); break;
case 'vless': showEl('pf-vless'); break;
case 'trojan': showEl('pf-trojan'); break;
case 'hysteria': showEl('pf-hysteria'); break;
case 'hysteria2': showEl('pf-hysteria2'); break;
case 'tuic': showEl('pf-tuic'); break;
case 'wireguard': showEl('pf-wireguard'); break;
case 'snell': showEl('pf-snell'); break;
case 'ssh': showEl('pf-ssh'); break;
case 'anytls': showEl('pf-anytls'); break;
case 'mieru': showEl('pf-mieru'); break;
case 'http':
case 'socks5':
showEl('pf-http-auth');
// headers only for http
const hrow = document.getElementById('pf-http-headers-row');
if (hrow) hrow.classList.toggle('hidden', type !== 'http');
break;
}
// Transport section
if (TRANSPORT_PROTOS.includes(type)) {
showEl('pf-transport-section');
updateTransportOpts(); // show/hide transport sub-opts
// XHTTP only for VLESS
const xopt = document.getElementById('pf-xhttp-opts');
if (xopt) {
const net = document.getElementById('proxyNetwork').value;
if (type === 'vless' && net === 'xhttp') xopt.classList.remove('hidden');
else xopt.classList.add('hidden');
}
} else {
hideEl('pf-transport-section');
}
// TLS section
if (TLS_PROTOS.includes(type)) {
showEl('pf-tls-section');
const toggleRow = document.getElementById('pf-tls-toggle-row');
if (TLS_ALWAYS.includes(type)) {
// Always on — hide toggle, show fields
if (toggleRow) toggleRow.classList.add('hidden');
showEl('pf-tls-fields');
} else {
if (toggleRow) toggleRow.classList.remove('hidden');
const tlsOn = document.getElementById('proxyTLS') && document.getElementById('proxyTLS').checked;
if (tlsOn) showEl('pf-tls-fields'); else hideEl('pf-tls-fields');
}
// Client fingerprint
const cfpRow = document.getElementById('pf-client-fp-row');
if (cfpRow) cfpRow.classList.toggle('hidden', !CLIENT_FP_PROTOS.includes(type));
// Reality section
const realSec = document.getElementById('pf-reality-section');
if (realSec) realSec.classList.toggle('hidden', !REALITY_PROTOS.includes(type));
} else {
hideEl('pf-tls-section');
}
}
function updateTransportOpts() {
const net = document.getElementById('proxyNetwork').value;
const type = document.getElementById('proxyType').value;
hideEl('pf-ws-opts'); hideEl('pf-h2-opts'); hideEl('pf-grpc-opts');
hideEl('pf-http-transport-opts'); hideEl('pf-xhttp-opts');
switch (net) {
case 'ws': showEl('pf-ws-opts'); break;
case 'h2': showEl('pf-h2-opts'); break;
case 'grpc': showEl('pf-grpc-opts'); break;
case 'http': showEl('pf-http-transport-opts'); break;
case 'xhttp': if (type === 'vless') showEl('pf-xhttp-opts'); break;
}
}
document.getElementById('proxyType').addEventListener('change', updateProxyFields);
document.getElementById('proxyNetwork').addEventListener('change', updateTransportOpts);
document.getElementById('proxyTLS').addEventListener('change', () => {
const on = document.getElementById('proxyTLS').checked;
if (on) showEl('pf-tls-fields'); else hideEl('pf-tls-fields');
});
document.getElementById('ssPlugin').addEventListener('change', () => {
const plugin = document.getElementById('ssPlugin').value;
if (!plugin) {
hideEl('pf-ss-obfs'); hideEl('pf-ss-plugin-opts');
} else if (plugin === 'obfs') {
showEl('pf-ss-obfs'); hideEl('pf-ss-plugin-opts');
} else {
hideEl('pf-ss-obfs'); showEl('pf-ss-plugin-opts');
// Set placeholder for plugin opts
const ta = document.getElementById('ssPluginOpts');
if (plugin === 'v2ray-plugin' || plugin === 'gost-plugin') {
ta.placeholder = 'mode: websocket\ntls: true\nhost: example.com\npath: /';
} else if (plugin === 'shadow-tls') {
ta.placeholder = 'host: cloud.tencent.com\npassword: shadow_tls_password\nversion: 3';
} else if (plugin === 'restls') {
ta.placeholder = 'host: www.microsoft.com\npassword: your_password\nversion-hint: tls13';
} else if (plugin === 'kcptun') {
ta.placeholder = 'key: secret\ncrypt: aes\nmode: fast';
}
}
});
document.getElementById('snellObfsMode').addEventListener('change', () => {
const mode = document.getElementById('snellObfsMode').value;
const row = document.getElementById('pf-snell-obfs-host');
if (row) row.classList.toggle('hidden', !mode);
});
// TUIC version switch — visibility handled after generic seg handler
document.getElementById('tuicVersionSwitch').addEventListener('click', e => {
const btn = e.target.closest('.seg-btn');
if (!btn) return;
// Let the generic handler set active class, then update visibility
setTimeout(() => {
const v = getSegBtn('tuicVersionSwitch') || '5';
const tokenRow = document.getElementById('pf-tuic-token');
const v5auth = document.getElementById('pf-tuic-v5-auth');
if (v === '4') { if (tokenRow) tokenRow.classList.remove('hidden'); if (v5auth) v5auth.classList.add('hidden'); }
else { if (tokenRow) tokenRow.classList.add('hidden'); if (v5auth) v5auth.classList.remove('hidden'); }
}, 0);
});
// ─── Open proxy modal ───
let editProxyName = null;
function openProxyModal(proxy) {
editProxyName = proxy ? proxy.name : null;
document.getElementById('proxyModalTitle').textContent = proxy ? 'Редактировать прокси' : 'Добавить прокси';
document.getElementById('proxyEditName').value = proxy ? proxy.name : '';
const p = proxy || {};
const type = p.type || 'ss';
document.getElementById('proxyType').value = type;
document.getElementById('proxyName').value = p.name || '';
document.getElementById('proxyServer').value = p.server || '';
document.getElementById('proxyPort').value = p.port || '';
// Common advanced
document.getElementById('proxyUDP').checked = p.udp !== false;
document.getElementById('proxyIPVersion').value = p['ip-version'] || '';
document.getElementById('proxyInterface').value = p['interface-name'] || '';
document.getElementById('proxyRoutingMark').value = p['routing-mark'] || '';
document.getElementById('proxyTFO').checked = !!p.tfo;
document.getElementById('proxyMPTCP').checked = !!p.mptcp;
document.getElementById('proxyDialerProxy').value = p['dialer-proxy'] || '';
// TLS
document.getElementById('proxyTLS').checked = !!p.tls;
document.getElementById('proxySNI').value = p.servername || p.sni || '';
document.getElementById('proxyFingerprint').value = p.fingerprint || '';
document.getElementById('proxyALPN').value = Array.isArray(p.alpn) ? p.alpn.join('\n') : (p.alpn || '');
document.getElementById('proxySkipCertVerify').checked = !!p['skip-cert-verify'];
document.getElementById('proxyClientFP').value = p['client-fingerprint'] || '';
// Reality
const ro = p['reality-opts'] || {};
document.getElementById('realityPublicKey').value = ro['public-key'] || '';
document.getElementById('realityShortID').value = ro['short-id'] || '';
document.getElementById('realityX25519mlkem').checked = !!ro['support-x25519mlkem768'];
// ECH
const ech = p['ech-opts'] || {};
document.getElementById('echEnable').checked = !!ech.enable;
document.getElementById('echConfig').value = ech.config || '';
document.getElementById('echQuerySNI').value = ech['query-server-name'] || '';
// Transport
document.getElementById('proxyNetwork').value = p.network || '';
const ws = p['ws-opts'] || {};
document.getElementById('wsPath').value = ws.path || '';
document.getElementById('wsHost').value = (ws.headers && ws.headers.Host) || '';
document.getElementById('wsMaxEarlyData').value = ws['max-early-data'] || '';
document.getElementById('wsEarlyDataHeader').value = ws['early-data-header-name'] || '';
document.getElementById('wsV2rayUpgrade').checked = !!ws['v2ray-http-upgrade'];
document.getElementById('wsV2rayUpgradeFO').checked = !!ws['v2ray-http-upgrade-fast-open'];
const h2 = p['h2-opts'] || {};
document.getElementById('h2Host').value = Array.isArray(h2.host) ? h2.host.join('\n') : (h2.host || '');
document.getElementById('h2Path').value = h2.path || '';
const grpc = p['grpc-opts'] || {};
document.getElementById('grpcServiceName').value = grpc['grpc-service-name'] || '';
document.getElementById('grpcUserAgent').value = grpc['grpc-user-agent'] || '';
const http = p['http-opts'] || {};
document.getElementById('httpTransportMethod').value = http.method || 'GET';
document.getElementById('httpTransportPath').value = Array.isArray(http.path) ? http.path.join('\n') : (http.path || '');
const xhttp = p['xhttp-opts'] || {};
document.getElementById('xhttpPath').value = xhttp.path || '';
document.getElementById('xhttpHost').value = xhttp.host || '';
document.getElementById('xhttpMode').value = xhttp.mode || '';
document.getElementById('xhttpNoGRPC').checked = !!xhttp['no-grpc-header'];
document.getElementById('xhttpPaddingBytes').value = xhttp['x-padding-bytes'] || '';
// ── Protocol fields ──
// SS
document.getElementById('ssCipher').value = p.cipher || 'auto';
document.getElementById('ssPassword').value = p.password || '';
document.getElementById('ssUOT').checked = !!p['udp-over-tcp'];
setSegBtn('ssUOTVersionSwitch', String(p['udp-over-tcp-version'] || '1'));
document.getElementById('ssPlugin').value = p.plugin || '';
if (p.plugin === 'obfs') {
document.getElementById('ssObfsMode').value = (p['plugin-opts'] || {}).mode || 'tls';
document.getElementById('ssObfsHost').value = (p['plugin-opts'] || {}).host || '';
} else if (p.plugin && p['plugin-opts']) {
// Serialize plugin-opts to YAML-like text
document.getElementById('ssPluginOpts').value = objToYAML(p['plugin-opts']);
}
// SSR
document.getElementById('ssrCipher').value = p.cipher || 'none';
document.getElementById('ssrPassword').value = p.password || '';
document.getElementById('ssrProtocol').value = p.protocol || 'origin';
document.getElementById('ssrProtocolParam').value = p['protocol-param'] || '';
document.getElementById('ssrObfs').value = p.obfs || 'plain';
document.getElementById('ssrObfsParam').value = p['obfs-param'] || '';
// VMess
document.getElementById('vmessUUID').value = p.uuid || '';
document.getElementById('vmessAlterID').value = p.alterId != null ? p.alterId : 0;
document.getElementById('vmessCipher').value = p.cipher || 'auto';
// VLESS
document.getElementById('vlessUUID').value = p.uuid || '';
document.getElementById('vlessFlow').value = p.flow || '';
document.getElementById('vlessEncryption').value = p.encryption || 'none';
// Trojan
document.getElementById('trojanPassword').value = p.password || '';
// Hysteria v1
document.getElementById('hyAuthStr').value = p['auth-str'] || '';
document.getElementById('hyProtocol').value = p.protocol || 'udp';
document.getElementById('hyUp').value = p.up || '';
document.getElementById('hyDown').value = p.down || '';
document.getElementById('hyObfs').value = p.obfs || '';
document.getElementById('hyPorts').value = p.ports || '';
document.getElementById('hyFastOpen').checked = !!p['fast-open'];
// Hysteria2
document.getElementById('hy2Password').value = p.password || '';
document.getElementById('hy2Up').value = p.up || '';
document.getElementById('hy2Down').value = p.down || '';
document.getElementById('hy2ObfsType').value = p.obfs || '';
document.getElementById('hy2ObfsPassword').value = p['obfs-password'] || '';
document.getElementById('hy2Ports').value = p.ports || '';
document.getElementById('hy2HopInterval').value = p['hop-interval'] || '';
// TUIC
const tuicV = p.token ? '4' : '5';
setSegBtn('tuicVersionSwitch', tuicV);
document.getElementById('pf-tuic-token').classList.toggle('hidden', tuicV !== '4');
document.getElementById('pf-tuic-v5-auth').classList.toggle('hidden', tuicV !== '5');
document.getElementById('tuicToken').value = p.token || '';
document.getElementById('tuicUUID').value = p.uuid || '';
document.getElementById('tuicPassword').value = p.password || '';
document.getElementById('tuicIP').value = p.ip || '';
document.getElementById('tuicHeartbeat').value = p['heartbeat-interval'] || '';
document.getElementById('tuicUDPMode').value = p['udp-relay-mode'] || 'native';
document.getElementById('tuicCongestion').value = p['congestion-controller'] || '';
document.getElementById('tuicMaxUDP').value = p['max-udp-relay-packet-size'] || '';
document.getElementById('tuicReqTimeout').value = p['request-timeout'] || '';
document.getElementById('tuicMaxStreams').value = p['max-open-streams'] || '';
document.getElementById('tuicDisableSNI').checked = !!p['disable-sni'];
document.getElementById('tuicReduceRTT').checked = !!p['reduce-rtt'];
document.getElementById('tuicFastOpen').checked = !!p['fast-open'];
// WireGuard
document.getElementById('wgPrivateKey').value = p['private-key'] || '';
document.getElementById('wgIP').value = p.ip || '';
document.getElementById('wgIPv6').value = p.ipv6 || '';
// In simple mode, use top-level server/port/public-key
document.getElementById('wgPublicKey').value = p['public-key'] || '';
document.getElementById('wgAllowedIPs').value = Array.isArray(p['allowed-ips']) ? p['allowed-ips'].join(', ') : (p['allowed-ips'] || '0.0.0.0/0');
document.getElementById('wgPreSharedKey').value = p['pre-shared-key'] || '';
document.getElementById('wgReserved').value = Array.isArray(p.reserved) ? p.reserved.join(',') : (p.reserved || '');
document.getElementById('wgMTU').value = p.mtu || '';
document.getElementById('wgKeepalive').value = p['persistent-keepalive'] || '';
document.getElementById('wgRemoteDNS').checked = !!p['remote-dns-resolve'];
document.getElementById('wgDNS').value = Array.isArray(p.dns) ? p.dns.join(', ') : (p.dns || '');
document.getElementById('wgDialerProxy').value = p['dialer-proxy'] || '';
// Snell
document.getElementById('snellPSK').value = p.psk || '';
document.getElementById('snellVersion').value = p.version || '3';
const snellObfsMode = (p['obfs-opts'] || {}).mode || '';
document.getElementById('snellObfsMode').value = snellObfsMode;
document.getElementById('snellObfsHost').value = (p['obfs-opts'] || {}).host || '';
const snellHostRow = document.getElementById('pf-snell-obfs-host');
if (snellHostRow) snellHostRow.classList.toggle('hidden', !snellObfsMode);
// SSH
document.getElementById('sshUsername').value = p.username || '';
document.getElementById('sshPassword').value = p.password || '';
document.getElementById('sshPrivateKey').value = p['private-key'] || '';
document.getElementById('sshPrivateKeyPass').value = p['private-key-passphrase'] || '';
document.getElementById('sshHostKey').value = Array.isArray(p['host-key']) ? p['host-key'].join('\n') : '';
document.getElementById('sshHostKeyAlgos').value = Array.isArray(p['host-key-algorithms']) ? p['host-key-algorithms'].join('\n') : '';
// AnyTLS
document.getElementById('anytlsPassword').value = p.password || '';
// Mieru
document.getElementById('mieruUsername').value = p.username || '';
document.getElementById('mieruPassword').value = p.password || '';
if (p.transport) document.getElementById('mieruTransport').value = p.transport;
// HTTP/SOCKS5
document.getElementById('httpUsername').value = p.username || '';
document.getElementById('httpPassword').value = p.password || '';
if (p.headers && typeof p.headers === 'object') {
document.getElementById('httpHeaders').value = Object.entries(p.headers)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`).join('\n');
} else {
document.getElementById('httpHeaders').value = '';
}
updateProxyFields();
// Trigger plugin-related visibility
document.getElementById('ssPlugin').dispatchEvent(new Event('change'));
document.getElementById('proxyModal').classList.remove('hidden');
}
function objToYAML(obj, indent) {
if (!obj || typeof obj !== 'object') return String(obj || '');
indent = indent || '';
return Object.entries(obj).map(([k, v]) => {
if (v && typeof v === 'object') {
return `${indent}${k}:\n${objToYAML(v, indent + ' ')}`;
}
return `${indent}${k}: ${v}`;
}).join('\n');
}
function parseYAMLToObj(text) {
const result = {};
if (!text) return result;
text.split('\n').forEach(line => {
const idx = line.indexOf(':');
if (idx > 0) {
const k = line.slice(0, idx).trim();
const v = line.slice(idx + 1).trim();
if (k) result[k] = v;
}
});
return result;
}
function closeProxyModal() {
document.getElementById('proxyModal').classList.add('hidden');
}
document.getElementById('addProxyBtn').addEventListener('click', () => openProxyModal(null));
document.getElementById('closeProxyModal').addEventListener('click', closeProxyModal);
document.getElementById('cancelProxyBtn').addEventListener('click', closeProxyModal);
document.getElementById('proxyModalBackdrop').addEventListener('click', closeProxyModal);
document.getElementById('saveProxyBtn').addEventListener('click', () => {
const type = document.getElementById('proxyType').value;
const name = document.getElementById('proxyName').value.trim();
if (!name) { showToast('Имя прокси обязательно', 'error'); return; }
const proxy = { name, type };
// Server/port
if (type !== 'direct') {
proxy.server = gVal('proxyServer').trim();
const port = parseInt(gVal('proxyPort'));
if (port) proxy.port = port;
}
// Common advanced
proxy.udp = gCheck('proxyUDP');
const ipVer = gVal('proxyIPVersion');
if (ipVer) proxy['ip-version'] = ipVer;
const iface = gVal('proxyInterface').trim();
if (iface) proxy['interface-name'] = iface;
const mark = parseInt(gVal('proxyRoutingMark'));
if (mark) proxy['routing-mark'] = mark;
if (gCheck('proxyTFO')) proxy.tfo = true;
if (gCheck('proxyMPTCP')) proxy.mptcp = true;
const dialerProxy = gVal('proxyDialerProxy').trim();
if (dialerProxy) proxy['dialer-proxy'] = dialerProxy;
// TLS
const hasTLS = TLS_PROTOS.includes(type);
const tlsAlways = TLS_ALWAYS.includes(type);
const tlsEnabled = tlsAlways || (hasTLS && gCheck('proxyTLS'));
if (hasTLS) {
if (!tlsAlways) proxy.tls = gCheck('proxyTLS');
if (tlsEnabled) {
const sni = gVal('proxySNI').trim();
if (sni) {
if (type === 'vmess' || type === 'vless') proxy.servername = sni;
else proxy.sni = sni;
}
const fp = gVal('proxyFingerprint').trim();
if (fp) proxy.fingerprint = fp;
const alpn = gLines('proxyALPN');
if (alpn.length) proxy.alpn = alpn;
if (gCheck('proxySkipCertVerify')) proxy['skip-cert-verify'] = true;
if (CLIENT_FP_PROTOS.includes(type)) {
const cfp = gVal('proxyClientFP');
if (cfp) proxy['client-fingerprint'] = cfp;
}
// Reality
if (REALITY_PROTOS.includes(type)) {
const rPubKey = gVal('realityPublicKey').trim();
const rShortID = gVal('realityShortID').trim();
if (rPubKey || rShortID) {
proxy['reality-opts'] = {};
if (rPubKey) proxy['reality-opts']['public-key'] = rPubKey;
if (rShortID) proxy['reality-opts']['short-id'] = rShortID;
if (gCheck('realityX25519mlkem')) proxy['reality-opts']['support-x25519mlkem768'] = true;
}
}
// ECH
if (gCheck('echEnable')) {
proxy['ech-opts'] = { enable: true };
const echCfg = gVal('echConfig').trim();
if (echCfg) proxy['ech-opts'].config = echCfg;
const echQ = gVal('echQuerySNI').trim();
if (echQ) proxy['ech-opts']['query-server-name'] = echQ;
}
}
}
// Transport
if (TRANSPORT_PROTOS.includes(type)) {
const net = gVal('proxyNetwork');
if (net) {
proxy.network = net;
switch (net) {
case 'ws': {
const wsOpts = {};
const wsPath = gVal('wsPath').trim();
if (wsPath) wsOpts.path = wsPath;
const wsHost = gVal('wsHost').trim();
if (wsHost) wsOpts.headers = { Host: wsHost };
const wsMaxED = parseInt(gVal('wsMaxEarlyData'));
if (wsMaxED) wsOpts['max-early-data'] = wsMaxED;
const wsEDH = gVal('wsEarlyDataHeader').trim();
if (wsEDH) wsOpts['early-data-header-name'] = wsEDH;
if (gCheck('wsV2rayUpgrade')) wsOpts['v2ray-http-upgrade'] = true;
if (gCheck('wsV2rayUpgradeFO')) wsOpts['v2ray-http-upgrade-fast-open'] = true;
if (Object.keys(wsOpts).length) proxy['ws-opts'] = wsOpts;
break;
}
case 'h2': {
const h2Opts = {};
const h2host = gLines('h2Host');
if (h2host.length) h2Opts.host = h2host;
const h2path = gVal('h2Path').trim();
if (h2path) h2Opts.path = h2path;
if (Object.keys(h2Opts).length) proxy['h2-opts'] = h2Opts;
break;
}
case 'grpc': {
const grpcOpts = {};
const gsn = gVal('grpcServiceName').trim();
if (gsn) grpcOpts['grpc-service-name'] = gsn;
const gua = gVal('grpcUserAgent').trim();
if (gua) grpcOpts['grpc-user-agent'] = gua;
if (Object.keys(grpcOpts).length) proxy['grpc-opts'] = grpcOpts;
break;
}
case 'http': {
const httpOpts = {};
const method = gVal('httpTransportMethod');
if (method) httpOpts.method = method;
const paths = gLines('httpTransportPath');
if (paths.length) httpOpts.path = paths;
if (Object.keys(httpOpts).length) proxy['http-opts'] = httpOpts;
break;
}
case 'xhttp': {
if (type === 'vless') {
const xopts = {};
const xp = gVal('xhttpPath').trim();
if (xp) xopts.path = xp;
const xh = gVal('xhttpHost').trim();
if (xh) xopts.host = xh;
const xm = gVal('xhttpMode');
if (xm) xopts.mode = xm;
if (gCheck('xhttpNoGRPC')) xopts['no-grpc-header'] = true;
const xpad = gVal('xhttpPaddingBytes').trim();
if (xpad) xopts['x-padding-bytes'] = xpad;
if (Object.keys(xopts).length) proxy['xhttp-opts'] = xopts;
}
break;
}
}
}
}
// ── Protocol-specific ──
switch (type) {
case 'ss': {
proxy.cipher = gVal('ssCipher');
proxy.password = gVal('ssPassword');
if (gCheck('ssUOT')) {
proxy['udp-over-tcp'] = true;
const uotVer = parseInt(getSegBtn('ssUOTVersionSwitch') || '1');
if (uotVer === 2) proxy['udp-over-tcp-version'] = 2;
}
const plugin = gVal('ssPlugin');
if (plugin) {
proxy.plugin = plugin;
if (plugin === 'obfs') {
proxy['plugin-opts'] = { mode: gVal('ssObfsMode') || 'tls' };
const oh = gVal('ssObfsHost').trim();
if (oh) proxy['plugin-opts'].host = oh;
} else {
const optsText = gVal('ssPluginOpts').trim();
if (optsText) proxy['plugin-opts'] = parseYAMLToObj(optsText);
}
}
break;
}
case 'ssr': {
proxy.cipher = gVal('ssrCipher');
proxy.password = gVal('ssrPassword');
proxy.protocol = gVal('ssrProtocol');
const pp = gVal('ssrProtocolParam').trim();
if (pp) proxy['protocol-param'] = pp;
proxy.obfs = gVal('ssrObfs');
const op = gVal('ssrObfsParam').trim();
if (op) proxy['obfs-param'] = op;
break;
}
case 'vmess': {
proxy.uuid = gVal('vmessUUID').trim();
proxy.alterId = gNum('vmessAlterID', 0);
proxy.cipher = gVal('vmessCipher');
break;
}
case 'vless': {
proxy.uuid = gVal('vlessUUID').trim();
const flow = gVal('vlessFlow');
if (flow) proxy.flow = flow;
proxy.encryption = gVal('vlessEncryption') || 'none';
break;
}
case 'trojan': {
proxy.password = gVal('trojanPassword');
break;
}
case 'hysteria': {
proxy['auth-str'] = gVal('hyAuthStr');
proxy.protocol = gVal('hyProtocol') || 'udp';
const up = gVal('hyUp').trim();
if (up) proxy.up = up;
const down = gVal('hyDown').trim();
if (down) proxy.down = down;
const obfs = gVal('hyObfs').trim();
if (obfs) proxy.obfs = obfs;
const ports = gVal('hyPorts').trim();
if (ports) proxy.ports = ports;
if (gCheck('hyFastOpen')) proxy['fast-open'] = true;
break;
}
case 'hysteria2': {
proxy.password = gVal('hy2Password');
const up2 = gVal('hy2Up').trim();
if (up2) proxy.up = up2;
const down2 = gVal('hy2Down').trim();
if (down2) proxy.down = down2;
const obfsType = gVal('hy2ObfsType');
if (obfsType) {
proxy.obfs = obfsType;
const obfsPass = gVal('hy2ObfsPassword').trim();
if (obfsPass) proxy['obfs-password'] = obfsPass;
}
const ports2 = gVal('hy2Ports').trim();
if (ports2) proxy.ports = ports2;
const hopInt = parseInt(gVal('hy2HopInterval'));
if (hopInt) proxy['hop-interval'] = hopInt;
break;
}
case 'tuic': {
const tuicV = getSegBtn('tuicVersionSwitch') || '5';
if (tuicV === '4') {
proxy.token = gVal('tuicToken').trim();
} else {
proxy.uuid = gVal('tuicUUID').trim();
proxy.password = gVal('tuicPassword');
}
const tip = gVal('tuicIP').trim();
if (tip) proxy.ip = tip;
const hb = parseInt(gVal('tuicHeartbeat'));
if (hb) proxy['heartbeat-interval'] = hb;
proxy['udp-relay-mode'] = gVal('tuicUDPMode') || 'native';
const cong = gVal('tuicCongestion');
if (cong) proxy['congestion-controller'] = cong;
const maxUDP = parseInt(gVal('tuicMaxUDP'));
if (maxUDP) proxy['max-udp-relay-packet-size'] = maxUDP;
const reqTOut = parseInt(gVal('tuicReqTimeout'));
if (reqTOut) proxy['request-timeout'] = reqTOut;
const maxStr = parseInt(gVal('tuicMaxStreams'));
if (maxStr) proxy['max-open-streams'] = maxStr;
if (gCheck('tuicDisableSNI')) proxy['disable-sni'] = true;
if (gCheck('tuicReduceRTT')) proxy['reduce-rtt'] = true;
if (gCheck('tuicFastOpen')) proxy['fast-open'] = true;
break;
}
case 'wireguard': {
proxy['private-key'] = gVal('wgPrivateKey').trim();
const wip = gVal('wgIP').trim();
if (wip) proxy.ip = wip;
const wipv6 = gVal('wgIPv6').trim();
if (wipv6) proxy.ipv6 = wipv6;
proxy['public-key'] = gVal('wgPublicKey').trim();
const allowedIPs = gVal('wgAllowedIPs').split(',').map(s => s.trim()).filter(Boolean);
proxy['allowed-ips'] = allowedIPs.length ? allowedIPs : ['0.0.0.0/0'];
const psk = gVal('wgPreSharedKey').trim();
if (psk) proxy['pre-shared-key'] = psk;
const wgRes = gVal('wgReserved').trim();
if (wgRes) proxy.reserved = wgRes;
const wgMTU = parseInt(gVal('wgMTU'));
if (wgMTU) proxy.mtu = wgMTU;
const wgKA = parseInt(gVal('wgKeepalive'));
if (wgKA) proxy['persistent-keepalive'] = wgKA;
if (gCheck('wgRemoteDNS')) {
proxy['remote-dns-resolve'] = true;
const wgDNS = gVal('wgDNS').split(',').map(s => s.trim()).filter(Boolean);
if (wgDNS.length) proxy.dns = wgDNS;
}
const wgDP = gVal('wgDialerProxy').trim();
if (wgDP) proxy['dialer-proxy'] = wgDP;
break;
}
case 'snell': {
proxy.psk = gVal('snellPSK');
proxy.version = parseInt(gVal('snellVersion')) || 3;
const snellMode = gVal('snellObfsMode');
if (snellMode) {
proxy['obfs-opts'] = { mode: snellMode };
const sh = gVal('snellObfsHost').trim();
if (sh) proxy['obfs-opts'].host = sh;
}
break;
}
case 'ssh': {
proxy.username = gVal('sshUsername').trim();
const sshPass = gVal('sshPassword');
if (sshPass) proxy.password = sshPass;
const sshPK = gVal('sshPrivateKey').trim();
if (sshPK) proxy['private-key'] = sshPK;
const sshPKP = gVal('sshPrivateKeyPass').trim();
if (sshPKP) proxy['private-key-passphrase'] = sshPKP;
const sshHK = gLines('sshHostKey');
if (sshHK.length) proxy['host-key'] = sshHK;
const sshHKA = gLines('sshHostKeyAlgos');
if (sshHKA.length) proxy['host-key-algorithms'] = sshHKA;
break;
}
case 'anytls': {
proxy.password = gVal('anytlsPassword');
break;
}
case 'mieru': {
proxy.username = gVal('mieruUsername').trim();
proxy.password = gVal('mieruPassword');
proxy.transport = gVal('mieruTransport');
break;
}
case 'http':
case 'socks5': {
const uname = gVal('httpUsername').trim();
if (uname) proxy.username = uname;
const upass = gVal('httpPassword');
if (upass) proxy.password = upass;
if (type === 'http') {
const hdrText = gVal('httpHeaders').trim();
if (hdrText) {
const hdrs = {};
hdrText.split('\n').forEach(line => {
const idx = line.indexOf(':');
if (idx > 0) {
hdrs[line.slice(0, idx).trim()] = [line.slice(idx + 1).trim()];
}
});
if (Object.keys(hdrs).length) proxy.headers = hdrs;
}
}
break;
}
case 'direct':
// nothing extra needed
break;
}
// Save to config
const proxies = getProxies();
if (editProxyName) {
const idx = proxies.findIndex(p => p.name === editProxyName);
if (idx >= 0) {
const oldName = proxies[idx].name;
proxies[idx] = proxy;
if (proxy.name !== oldName) {
getGroups().forEach(g => {
if (g.proxies) g.proxies = g.proxies.map(pn => pn === oldName ? proxy.name : pn);
});
}
}
} else {
if (proxies.some(p => p.name === proxy.name)) {
showToast('Прокси с таким именем уже существует', 'error');
return;
}
proxies.push(proxy);
}
PS.config.proxies = proxies;
closeProxyModal();
renderAll();
saveFullConfig(false);
});
// Proxy list delegated events
document.getElementById('proxyList').addEventListener('click', e => {
const editBtn = e.target.closest('[data-edit-proxy]');
const delBtn = e.target.closest('[data-delete-proxy]');
if (editBtn) {
const proxy = getProxies().find(p => p.name === editBtn.dataset.editProxy);
if (proxy) openProxyModal(proxy);
} else if (delBtn) {
const name = delBtn.dataset.deleteProxy;
if (confirm(`Удалить прокси "${name}"?`)) {
PS.config.proxies = getProxies().filter(p => p.name !== name);
getGroups().forEach(g => { if (g.proxies) g.proxies = g.proxies.filter(pn => pn !== name); });
renderAll(); saveFullConfig(false);
}
}
});
// ════════════════════════════════════════════════════════════
// GROUP MODAL
// ════════════════════════════════════════════════════════════
function updateGroupFields() {
const type = document.getElementById('groupType').value;
const urlField = document.getElementById('groupURLField');
const lbField = document.getElementById('groupLBStrategyDiv');
const needsURL = ['url-test', 'fallback', 'load-balance'].includes(type);
urlField.classList.toggle('hidden', !needsURL);
lbField.classList.toggle('hidden', type !== 'load-balance');
}
document.getElementById('groupType').addEventListener('change', updateGroupFields);
let editGroupName = null;
function openGroupModal(group) {
editGroupName = group ? group.name : null;
document.getElementById('groupEditName').value = group ? group.name : '';
document.getElementById('groupModalTitle').textContent = group ? 'Редактировать группу' : 'Добавить группу';
document.getElementById('groupName').value = group ? group.name : '';
document.getElementById('groupType').value = group ? group.type : 'select';
document.getElementById('groupURL').value = group ? (group.url || 'https://www.gstatic.com/generate_204') : 'https://www.gstatic.com/generate_204';
document.getElementById('groupInterval').value = group ? (group.interval || 300) : 300;
document.getElementById('groupTimeout').value = group ? (group.timeout || 5000) : 5000;
document.getElementById('groupTolerance').value = group ? (group.tolerance || 50) : 50;
document.getElementById('groupMaxFailed').value = group ? (group['max-failed-times'] || 5) : 5;
document.getElementById('groupLazy').checked = group ? (group.lazy !== false) : true;
document.getElementById('groupIncludeAll').checked = group ? (group['include-all'] || false) : false;
document.getElementById('groupFilter').value = group ? (group.filter || '') : '';
document.getElementById('groupExcludeFilter').value = group ? (group['exclude-filter'] || '') : '';
document.getElementById('groupExcludeType').value = group ? (group['exclude-type'] || '') : '';
document.getElementById('groupExpectedStatus').value = group ? (group['expected-status'] || '') : '';
document.getElementById('groupLBStrategy').value = group ? (group.strategy || 'round-robin') : 'round-robin';
document.getElementById('groupDisableUDP').checked = group ? (group['disable-udp'] || false) : false;
const checkboxes = document.getElementById('groupProxyCheckboxes');
checkboxes.innerHTML = '';
const selected = group ? (group.proxies || []) : [];
const allItems = ['DIRECT', 'REJECT', ...getProxies().map(p => p.name), ...getGroups().filter(g => !group || g.name !== group.name).map(g => g.name)];
allItems.forEach(name => {
const label = document.createElement('label');
label.className = 'checkbox-label';
label.style.cssText = 'font-size:.85rem;padding:4px 0;display:flex;align-items:center;gap:6px';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.value = name;
if (selected.includes(name)) cb.checked = true;
label.appendChild(cb);
label.appendChild(document.createTextNode(name));
checkboxes.appendChild(label);
});
updateGroupFields();
document.getElementById('groupModal').classList.remove('hidden');
}
function closeGroupModal() { document.getElementById('groupModal').classList.add('hidden'); }
document.getElementById('addGroupBtn').addEventListener('click', () => openGroupModal(null));
document.getElementById('closeGroupModal').addEventListener('click', closeGroupModal);
document.getElementById('cancelGroupBtn').addEventListener('click', closeGroupModal);
document.getElementById('groupModalBackdrop').addEventListener('click', closeGroupModal);
document.getElementById('saveGroupBtn').addEventListener('click', () => {
const selectedProxies = [];
document.querySelectorAll('#groupProxyCheckboxes input[type="checkbox"]:checked').forEach(cb => selectedProxies.push(cb.value));
const type = document.getElementById('groupType').value;
const hasURL = ['url-test', 'fallback', 'load-balance'].includes(type);
const group = {
name: document.getElementById('groupName').value.trim(),
type,
proxies: selectedProxies,
'include-all': gCheck('groupIncludeAll'),
'disable-udp': gCheck('groupDisableUDP'),
};
if (hasURL) {
group.url = gVal('groupURL').trim();
group.interval = gNum('groupInterval', 300);
group.timeout = gNum('groupTimeout', 5000);
group.lazy = gCheck('groupLazy');
const maxFailed = gNum('groupMaxFailed', 0);
if (maxFailed) group['max-failed-times'] = maxFailed;
}
if (type === 'url-test') {
const tol = gNum('groupTolerance', 0);
if (tol) group.tolerance = tol;
}
if (type === 'load-balance') {
group.strategy = gVal('groupLBStrategy') || 'round-robin';
}
const filter = gVal('groupFilter').trim();
if (filter) group.filter = filter;
const exFilter = gVal('groupExcludeFilter').trim();
if (exFilter) group['exclude-filter'] = exFilter;
const exType = gVal('groupExcludeType').trim();
if (exType) group['exclude-type'] = exType;
const expStatus = gVal('groupExpectedStatus').trim();
if (expStatus) group['expected-status'] = expStatus;
if (!group.name) { showToast('Имя группы обязательно', 'error'); return; }
const groups = getGroups();
if (editGroupName) {
const idx = groups.findIndex(g => g.name === editGroupName);
if (idx >= 0) groups[idx] = group;
} else {
if (groups.some(g => g.name === group.name)) { showToast('Группа с таким именем уже существует', 'error'); return; }
groups.push(group);
}
PS.config['proxy-groups'] = groups;
closeGroupModal(); renderAll(); saveFullConfig(false);
});
document.getElementById('groupList').addEventListener('click', e => {
const editBtn = e.target.closest('[data-edit-group]');
const delBtn = e.target.closest('[data-delete-group]');
if (editBtn) {
const group = getGroups().find(g => g.name === editBtn.dataset.editGroup);
if (group) openGroupModal(group);
} else if (delBtn) {
const name = delBtn.dataset.deleteGroup;
if (confirm(`Удалить группу "${name}"?`)) {
PS.config['proxy-groups'] = getGroups().filter(g => g.name !== name);
PS.config.rules = getRules().map(r => r.endsWith(',' + name) ? r.replace(',' + name, ',DIRECT') : r);
renderAll(); saveFullConfig(false);
}
}
});
// ════════════════════════════════════════════════════════════
// RULE MODAL
// ════════════════════════════════════════════════════════════
const NO_RESOLVE_TYPES = ['IP-CIDR', 'IP-CIDR6', 'IP-SUFFIX', 'IP-ASN', 'GEOIP', 'SRC-IP-CIDR', 'SRC-IP-SUFFIX', 'SRC-IP-ASN', 'SRC-GEOIP'];
function updateRuleFields() {
const type = document.getElementById('ruleType').value;
const valueDiv = document.getElementById('ruleValueField');
const noResolveDiv = document.getElementById('ruleNoResolveDiv');
valueDiv.classList.toggle('hidden', type === 'MATCH');
noResolveDiv.classList.toggle('hidden', !NO_RESOLVE_TYPES.includes(type));
const sel = document.getElementById('ruleProxy');
const cur = sel.value;
sel.innerHTML = '<option value="DIRECT">DIRECT</option><option value="REJECT">REJECT</option><option value="REJECT-DROP">REJECT-DROP</option>';
getGroups().forEach(g => {
const opt = document.createElement('option');
opt.value = g.name; opt.textContent = g.name;
sel.appendChild(opt);
});
getProxies().filter(p => p.type !== 'direct').forEach(p => {
const opt = document.createElement('option');
opt.value = p.name; opt.textContent = p.name;
sel.appendChild(opt);
});
if ([...sel.options].some(o => o.value === cur)) sel.value = cur;
}
document.getElementById('ruleType').addEventListener('change', updateRuleFields);
function openRuleModal() {
document.getElementById('ruleType').value = 'DOMAIN-SUFFIX';
document.getElementById('ruleValue').value = '';
document.getElementById('ruleNoResolve').checked = true;
updateRuleFields();
document.getElementById('ruleModal').classList.remove('hidden');
}
function closeRuleModal() { document.getElementById('ruleModal').classList.add('hidden'); }
document.getElementById('addRuleBtn').addEventListener('click', openRuleModal);
document.getElementById('addBlockBtn').addEventListener('click', () => {
openRuleModal();
document.getElementById('ruleType').value = 'DOMAIN-KEYWORD';
document.getElementById('ruleProxy').value = 'REJECT';
updateRuleFields();
});
document.getElementById('closeRuleModal').addEventListener('click', closeRuleModal);
document.getElementById('cancelRuleBtn').addEventListener('click', closeRuleModal);
document.getElementById('ruleModalBackdrop').addEventListener('click', closeRuleModal);
document.getElementById('saveRuleBtn').addEventListener('click', () => {
const type = document.getElementById('ruleType').value;
const value = gVal('ruleValue').trim();
const proxy = gVal('ruleProxy');
const noResolve = gCheck('ruleNoResolve');
if (type !== 'MATCH' && !value) { showToast('Введите значение правила', 'error'); return; }
let rule;
if (type === 'MATCH') {
rule = `MATCH,${proxy}`;
} else {
rule = `${type},${value},${proxy}`;
if (NO_RESOLVE_TYPES.includes(type) && noResolve) rule += ',no-resolve';
}
const rules = getRules();
rules.push(rule);
PS.config.rules = rules;
closeRuleModal(); renderAll(); saveFullConfig(false);
});
document.getElementById('rulesList').addEventListener('click', e => {
const delBtn = e.target.closest('[data-delete-rule]');
if (delBtn) {
const idx = parseInt(delBtn.dataset.deleteRule);
if (!isNaN(idx)) {
const rules = getRules();
rules.splice(idx, 1);
PS.config.rules = rules;
renderAll(); saveFullConfig(false);
}
}
});
// ════════════════════════════════════════════════════════════
// PROXY PROVIDERS
// ════════════════════════════════════════════════════════════
let editPPName = null;
function openPPModal(provider) {
editPPName = provider ? provider.name : null;
document.getElementById('ppModalTitle').textContent = provider ? 'Редактировать провайдер' : 'Добавить провайдер прокси';
document.getElementById('ppEditName').value = provider ? provider.name : '';
document.getElementById('ppName').value = provider ? provider.name : '';
document.getElementById('ppType').value = provider ? (provider.type || 'http') : 'http';
document.getElementById('ppURL').value = provider ? (provider.url || '') : '';
document.getElementById('ppPath').value = provider ? (provider.path || '') : '';
document.getElementById('ppInterval').value = provider ? (provider.interval || 3600) : 3600;
document.getElementById('ppProxy').value = provider ? (provider.proxy || '') : '';
const hc = (provider && provider['health-check']) || {};
document.getElementById('ppHCEnable').checked = hc.enable !== false;
document.getElementById('ppHCURL').value = hc.url || 'https://www.gstatic.com/generate_204';
document.getElementById('ppHCInterval').value = hc.interval || 300;
document.getElementById('ppHCTimeout').value = hc.timeout || 5000;
document.getElementById('ppHCLazy').checked = hc.lazy !== false;
document.getElementById('ppFilter').value = provider ? (provider.filter || '') : '';
document.getElementById('ppExcludeFilter').value = provider ? (provider['exclude-filter'] || '') : '';
document.getElementById('ppExcludeType').value = provider ? (provider['exclude-type'] || '') : '';
const ov = (provider && provider.override) || {};
document.getElementById('ppOverridePrefix').value = ov['additional-prefix'] || '';
document.getElementById('ppOverrideSuffix').value = ov['additional-suffix'] || '';
document.getElementById('ppOverrideSkipCert').checked = !!ov['skip-cert-verify'];
document.getElementById('ppOverrideUDP').checked = !!ov.udp;
document.getElementById('ppOverrideIPVersion').value = ov['ip-version'] || '';
updatePPTypeFields();
document.getElementById('proxyProviderModal').classList.remove('hidden');
}
function updatePPTypeFields() {
const type = document.getElementById('ppType').value;
const urlRow = document.getElementById('pp-url-row');
const intRow = document.getElementById('pp-interval-row');
if (urlRow) urlRow.classList.toggle('hidden', type === 'file' || type === 'inline');
if (intRow) intRow.classList.toggle('hidden', type === 'inline');
}
document.getElementById('ppType').addEventListener('change', updatePPTypeFields);
function closePPModal() { document.getElementById('proxyProviderModal').classList.add('hidden'); }
document.getElementById('addProxyProviderBtn').addEventListener('click', () => openPPModal(null));
document.getElementById('closePPModal').addEventListener('click', closePPModal);
document.getElementById('cancelPPBtn').addEventListener('click', closePPModal);
document.getElementById('ppModalBackdrop').addEventListener('click', closePPModal);
document.getElementById('savePPBtn').addEventListener('click', () => {
const name = gVal('ppName').trim();
if (!name) { showToast('Имя провайдера обязательно', 'error'); return; }
const provider = {
type: gVal('ppType'),
};
const url = gVal('ppURL').trim();
if (url) provider.url = url;
const path = gVal('ppPath').trim();
if (path) provider.path = path;
const interval = gNum('ppInterval', 0);
if (interval) provider.interval = interval;
const proxy = gVal('ppProxy').trim();
if (proxy) provider.proxy = proxy;
if (gCheck('ppHCEnable')) {
provider['health-check'] = {
enable: true,
url: gVal('ppHCURL') || 'https://www.gstatic.com/generate_204',
interval: gNum('ppHCInterval', 300),
timeout: gNum('ppHCTimeout', 5000),
lazy: gCheck('ppHCLazy'),
};
}
const filter = gVal('ppFilter').trim();
if (filter) provider.filter = filter;
const exFilter = gVal('ppExcludeFilter').trim();
if (exFilter) provider['exclude-filter'] = exFilter;
const exType = gVal('ppExcludeType').trim();
if (exType) provider['exclude-type'] = exType;
const override = {};
const prefix = gVal('ppOverridePrefix').trim();
if (prefix) override['additional-prefix'] = prefix;
const suffix = gVal('ppOverrideSuffix').trim();
if (suffix) override['additional-suffix'] = suffix;
if (gCheck('ppOverrideSkipCert')) override['skip-cert-verify'] = true;
if (gCheck('ppOverrideUDP')) override.udp = true;
const ipVer = gVal('ppOverrideIPVersion');
if (ipVer) override['ip-version'] = ipVer;
if (Object.keys(override).length) provider.override = override;
if (!PS.config['proxy-providers']) PS.config['proxy-providers'] = {};
if (editPPName && editPPName !== name) {
delete PS.config['proxy-providers'][editPPName];
}
PS.config['proxy-providers'][name] = provider;
closePPModal(); renderAll(); saveFullConfig(false);
});
document.getElementById('proxyProviderList').addEventListener('click', e => {
const editBtn = e.target.closest('[data-edit-pp]');
const delBtn = e.target.closest('[data-delete-pp]');
if (editBtn) {
const name = editBtn.dataset.editPp;
const pp = PS.config['proxy-providers'] && PS.config['proxy-providers'][name];
if (pp) openPPModal({ name, ...pp });
} else if (delBtn) {
const name = delBtn.dataset.deletePp;
if (confirm(`Удалить провайдер "${name}"?`)) {
if (PS.config['proxy-providers']) delete PS.config['proxy-providers'][name];
renderAll(); saveFullConfig(false);
}
}
});
// ════════════════════════════════════════════════════════════
// RULE PROVIDERS
// ════════════════════════════════════════════════════════════
let editRPName = null;
function openRPModal(provider) {
editRPName = provider ? provider.name : null;
document.getElementById('rpModalTitle').textContent = provider ? 'Редактировать провайдер правил' : 'Добавить провайдер правил';
document.getElementById('rpEditName').value = provider ? provider.name : '';
document.getElementById('rpName').value = provider ? provider.name : '';
document.getElementById('rpType').value = provider ? (provider.type || 'http') : 'http';
document.getElementById('rpBehavior').value = provider ? (provider.behavior || 'domain') : 'domain';
document.getElementById('rpFormat').value = provider ? (provider.format || 'yaml') : 'yaml';
document.getElementById('rpURL').value = provider ? (provider.url || '') : '';
document.getElementById('rpPath').value = provider ? (provider.path || '') : '';
document.getElementById('rpInterval').value = provider ? (provider.interval || 600) : 600;
document.getElementById('rpProxy').value = provider ? (provider.proxy || '') : '';
updateRPTypeFields();
document.getElementById('ruleProviderModal').classList.remove('hidden');
}
function updateRPTypeFields() {
const type = document.getElementById('rpType').value;
const urlRow = document.getElementById('rp-url-row');
const intRow = document.getElementById('rp-interval-row');
if (urlRow) urlRow.classList.toggle('hidden', type !== 'http');
if (intRow) intRow.classList.toggle('hidden', type === 'inline');
}
document.getElementById('rpType').addEventListener('change', updateRPTypeFields);
function closeRPModal() { document.getElementById('ruleProviderModal').classList.add('hidden'); }
document.getElementById('addRuleProviderBtn').addEventListener('click', () => openRPModal(null));
document.getElementById('closeRPModal').addEventListener('click', closeRPModal);
document.getElementById('cancelRPBtn').addEventListener('click', closeRPModal);
document.getElementById('rpModalBackdrop').addEventListener('click', closeRPModal);
document.getElementById('saveRPBtn').addEventListener('click', () => {
const name = gVal('rpName').trim();
if (!name) { showToast('Имя провайдера обязательно', 'error'); return; }
const provider = {
type: gVal('rpType'),
behavior: gVal('rpBehavior') || 'domain',
format: gVal('rpFormat') || 'yaml',
};
const url = gVal('rpURL').trim();
if (url) provider.url = url;
const path = gVal('rpPath').trim();
if (path) provider.path = path;
const interval = gNum('rpInterval', 0);
if (interval) provider.interval = interval;
const proxy = gVal('rpProxy').trim();
if (proxy) provider.proxy = proxy;
if (!PS.config['rule-providers']) PS.config['rule-providers'] = {};
if (editRPName && editRPName !== name) {
delete PS.config['rule-providers'][editRPName];
}
PS.config['rule-providers'][name] = provider;
closeRPModal(); renderAll(); saveFullConfig(false);
});
document.getElementById('ruleProviderList').addEventListener('click', e => {
const editBtn = e.target.closest('[data-edit-rp]');
const delBtn = e.target.closest('[data-delete-rp]');
if (editBtn) {
const name = editBtn.dataset.editRp;
const rp = PS.config['rule-providers'] && PS.config['rule-providers'][name];
if (rp) openRPModal({ name, ...rp });
} else if (delBtn) {
const name = delBtn.dataset.deleteRp;
if (confirm(`Удалить провайдер "${name}"?`)) {
if (PS.config['rule-providers']) delete PS.config['rule-providers'][name];
renderAll(); saveFullConfig(false);
}
}
});
// ════════════════════════════════════════════════════════════
// CORE UPLOAD
// ════════════════════════════════════════════════════════════
document.getElementById('uploadCoreForm').addEventListener('submit', async e => {
e.preventDefault();
const fileInput = document.getElementById('coreFile');
if (!fileInput.files[0]) { showToast('Выберите файл', 'error'); return; }
const fd = new FormData();
fd.append('core', fileInput.files[0]);
try {
const result = await fetch('/api/mihomo/upload-core', { method: 'POST', body: fd });
const json = await result.json();
if (!json.success) throw new Error(json.error || 'Upload failed');
showToast('Ядро загружено: ' + (json.data.arch || json.data.path), 'success');
await loadStatus();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
// ════════════════════════════════════════════════════════════
// YAML EDITOR
// ════════════════════════════════════════════════════════════
document.getElementById('yamlLoadBtn').addEventListener('click', async () => {
try {
const res = await fetch('/api/mihomo/config.yaml');
if (res.status === 404) { document.getElementById('yamlEditor').value = '# Config not found.'; return; }
document.getElementById('yamlEditor').value = await res.text();
showToast('Конфиг загружен', 'info');
} catch (e) { showToast('Ошибка: ' + e.message, 'error'); }
});
document.getElementById('yamlSaveBtn').addEventListener('click', async () => {
const content = document.getElementById('yamlEditor').value;
try {
const res = await fetch('/api/mihomo/config.yaml', {
method: 'PUT', headers: { 'Content-Type': 'text/yaml' }, body: content,
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Save failed');
showToast('Конфиг сохранён', 'success');
await loadConfig();
} catch (e) { showToast('Ошибка: ' + e.message, 'error'); }
});
// ════════════════════════════════════════════════════════════
// LOGS
// ════════════════════════════════════════════════════════════
let logPollTimer = null;
let lastLogCount = 0;
async function fetchLogs() {
try {
const lines = await get('/api/mihomo/logs');
const el = document.getElementById('logOutput');
if (lines.length !== lastLogCount) {
lastLogCount = lines.length;
el.textContent = lines.join('\n');
el.scrollTop = el.scrollHeight;
}
} catch (e) { /* ignore */ }
}
function startLogPoll() {
if (logPollTimer) return;
fetchLogs();
logPollTimer = setInterval(fetchLogs, 500);
}
function stopLogPoll() {
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
}
document.querySelectorAll('.ptab').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.tab === 'logs') { lastLogCount = 0; startLogPoll(); }
else stopLogPoll();
});
});
document.getElementById('clearLogsBtn').addEventListener('click', () => {
document.getElementById('logOutput').textContent = '';
lastLogCount = 0;
});
// ════════════════════════════════════════════════════════════
// DASHBOARD (LIVE MIHOMO API)
// ════════════════════════════════════════════════════════════
const DA = {
ws: null,
trafficUp: 0,
trafficDown: 0,
totalUp: 0,
totalDown: 0,
memory: 0,
connections: [],
proxies: {},
groups: {},
version: '',
mode: '',
running: false,
};
function mihomoAPI(path, opts) {
return fetch('/api/mihomo/api/' + path.replace(/^\/+/, ''), opts).then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
function mihomoPut(path, body) {
return mihomoAPI(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function mihomoDel(path) {
return mihomoAPI(path, { method: 'DELETE' });
}
function formatBytes(b) {
if (b < 1024) return b.toFixed(0) + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
return (b / 1073741824).toFixed(2) + ' GB';
}
function formatBytesPerSec(b) {
return formatBytes(b) + '/s';
}
function closeDashWS() {
if (DA.ws) {
try { DA.ws.close(); } catch(e) {}
DA.ws = null;
}
}
function openDashWS() {
closeDashWS();
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + '//' + location.host + '/api/mihomo/ws/traffic';
try {
const ws = new WebSocket(url);
ws.onmessage = (e) => {
try {
const d = JSON.parse(e.data);
DA.trafficUp = d.up || 0;
DA.trafficDown = d.down || 0;
} catch(err) {}
};
ws.onerror = () => {};
ws.onclose = () => { DA.ws = null; };
DA.ws = ws;
} catch(e) {}
}
let dashTrafficInterval = null;
let dashConnsInterval = null;
async function loadDashInitial() {
try {
const vdata = await mihomoAPI('version');
DA.version = vdata.version || '?';
} catch(e) { DA.version = '?'; }
try {
const cfg = await mihomoAPI('configs');
DA.mode = cfg.mode || 'rule';
renderDashMode();
} catch(e) {}
await loadDashProxies();
await loadDashConnections();
renderDash();
}
async function loadDashProxies() {
try {
const data = await mihomoAPI('proxies');
const all = data.proxies || data || {};
DA.proxies = {};
DA.groups = {};
for (const [name, p] of Object.entries(all)) {
if ((p.all && p.all.length > 0) || p.type === 'Selector' || p.type === 'URLTest' || p.type === 'Fallback' || p.type === 'LoadBalance' || p.type === 'Relay') {
DA.groups[name] = p;
} else {
DA.proxies[name] = p;
}
}
} catch(e) { console.error('dash proxies load', e); }
}
async function loadDashConnections() {
try {
const data = await mihomoAPI('connections');
DA.connections = Object.values(data.connections || {});
renderDashConnections();
} catch(e) { DA.connections = []; }
}
async function dashPingProxy(name) {
try {
await mihomoAPI('proxies/' + encodeURIComponent(name) + '/delay?timeout=5000&url=https://www.gstatic.com/generate_204');
await loadDashProxies();
renderDashGroups();
} catch(e) {
showToast('Пинг ' + name + ': ошибка', 'error');
}
}
async function dashPingAll() {
showToast('Пинг всех прокси...', 'info');
const names = Object.keys(DA.proxies);
let done = 0;
for (const name of names) {
mihomoAPI('proxies/' + encodeURIComponent(name) + '/delay?timeout=5000&url=https://www.gstatic.com/generate_204')
.then(() => { done++; })
.catch(() => { done++; })
.finally(() => {
if (done === names.length) {
loadDashProxies().then(() => renderDashGroups());
showToast('Пинг завершён', 'success');
}
});
}
}
async function dashSwitchProxy(groupName, proxyName) {
try {
await mihomoPut('proxies/' + encodeURIComponent(groupName), { name: proxyName });
await loadDashProxies();
renderDashGroups();
showToast(groupName + ' → ' + proxyName, 'success');
} catch(e) {
showToast('Ошибка переключения: ' + e.message, 'error');
}
}
async function dashSetMode(mode) {
try {
await mihomoPut('configs', { mode });
DA.mode = mode;
renderDashMode();
showToast('Режим: ' + mode, 'success');
} catch(e) {
showToast('Ошибка смены режима: ' + e.message, 'error');
}
}
async function dashCloseConnection(id) {
try {
await mihomoDel('connections/' + encodeURIComponent(id));
await loadDashConnections();
} catch(e) {}
}
async function dashCloseAllConnections() {
try {
await mihomoDel('connections');
DA.connections = [];
renderDashConnections();
showToast('Все соединения закрыты', 'success');
} catch(e) {}
}
function renderDashMode() {
const seg = document.getElementById('dashModeSwitch');
if (!seg) return;
seg.querySelectorAll('.seg-btn').forEach(b => {
b.classList.toggle('active', b.dataset.mode === DA.mode);
});
}
document.addEventListener('click', (e) => {
const btn = e.target.closest('#dashModeSwitch .seg-btn');
if (btn) dashSetMode(btn.dataset.mode);
});
function renderDash() {
document.getElementById('dashUp').textContent = formatBytesPerSec(DA.trafficUp);
document.getElementById('dashDown').textContent = formatBytesPerSec(DA.trafficDown);
document.getElementById('dashUpTotal').textContent = formatBytes(DA.totalUp);
document.getElementById('dashDownTotal').textContent = formatBytes(DA.totalDown);
document.getElementById('dashMem').textContent = formatBytes(DA.memory);
document.getElementById('dashVersion').textContent = DA.version;
renderDashGroups();
renderDashConnections();
}
function renderDashGroups() {
const list = document.getElementById('dashGroupList');
if (!list) return;
list.innerHTML = '';
const groupEntries = Object.entries(DA.groups);
if (groupEntries.length === 0) {
list.innerHTML = '<div class="empty-state">Нет групп прокси. Добавьте группы во вкладке «Группы».</div>';
return;
}
groupEntries.sort((a, b) => {
const order = ['Selector', 'URLTest', 'Fallback', 'LoadBalance', 'Relay'];
return (order.indexOf(a[1].type) || 99) - (order.indexOf(b[1].type) || 99);
});
for (const [name, g] of groupEntries) {
const card = document.createElement('div');
card.className = 'dash-group-card';
const members = (g.all || []).map(m => {
const p = DA.proxies[m] || DA.groups[m] || {};
const delay = p.history && p.history.length ? p.history[p.history.length - 1].delay : undefined;
const dead = delay === undefined || delay === 0;
let delayText = '—';
let delayClass = 'dash-delay-unknown';
if (!dead) {
delayText = delay + 'ms';
delayClass = delay < 200 ? 'dash-delay-good' : delay < 500 ? 'dash-delay-medium' : 'dash-delay-slow';
}
const isActive = g.now === m;
const type = p.type || '';
return `<div class="dash-proxy-item${isActive ? ' dash-proxy-active' : ''}" data-group="${esc(name)}" data-proxy="${esc(m)}">
<span class="dash-proxy-name">${esc(m)}</span>
<span class="dash-proxy-type">${esc(type)}</span>
<span class="dash-delay ${delayClass}">${delayText}</span>
</div>`;
}).join('');
card.innerHTML = `
<div class="dash-group-header">
<div class="dash-group-title">
<span class="dash-group-name">${esc(name)}</span>
<span class="tag-gw">${esc(g.type || '')}</span>
</div>
<button class="btn btn-ghost btn-sm dash-ping-group-btn" data-group="${esc(name)}">Пинг</button>
</div>
<div class="dash-proxy-list">${members || '<div class="empty-state" style="padding:8px">Нет нод</div>'}</div>`;
list.appendChild(card);
}
}
document.addEventListener('click', (e) => {
const proxyItem = e.target.closest('.dash-proxy-item');
if (proxyItem) {
const groupName = proxyItem.dataset.group;
const proxyName = proxyItem.dataset.proxy;
dashSwitchProxy(groupName, proxyName);
}
const pingBtn = e.target.closest('.dash-ping-group-btn');
if (pingBtn) {
const groupName = pingBtn.dataset.group;
const group = DA.groups[groupName];
if (group && group.all) {
for (const name of group.all) {
mihomoAPI('proxies/' + encodeURIComponent(name) + '/delay?timeout=5000&url=https://www.gstatic.com/generate_204').catch(() => {});
}
showToast('Пинг группы ' + groupName + '...', 'info');
setTimeout(() => loadDashProxies().then(() => renderDashGroups()), 3000);
}
}
if (e.target.closest('#dashPingAllBtn')) {
dashPingAll();
}
if (e.target.closest('#dashCloseAllConnsBtn')) {
dashCloseAllConnections();
}
const closeConnBtn = e.target.closest('.dash-conn-close');
if (closeConnBtn) {
dashCloseConnection(closeConnBtn.dataset.id);
}
});
function renderDashConnections() {
const tbody = document.getElementById('dashConnBody');
const countEl = document.getElementById('dashConnCount');
if (!tbody) return;
if (countEl) countEl.textContent = DA.connections.length;
tbody.innerHTML = '';
const conns = DA.connections.slice(0, 100);
for (const c of conns) {
const meta = c.metadata || {};
const host = (meta.host || meta.destinationIP || '') + (meta.destinationPort ? ':' + meta.destinationPort : '');
const chain = (c.chains || []).reverse().join(' → ') || '—';
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="dash-conn-host" title="${esc(host)}">${esc(host.length > 40 ? host.slice(0, 40) + '...' : host)}</td>
<td>${esc(meta.network || '')}</td>
<td class="dash-conn-chain" title="${esc(chain)}">${esc(chain.length > 30 ? chain.slice(0, 30) + '...' : chain)}</td>
<td class="dash-conn-traffic">${formatBytes(c.upload || 0)}</td>
<td class="dash-conn-traffic">${formatBytes(c.download || 0)}</td>
<td><button class="btn-icon dash-conn-close" data-id="${esc(c.id || '')}" title="Закрыть">✕</button></td>`;
tbody.appendChild(tr);
}
if (conns.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state" style="padding:12px">Нет активных соединений</td></tr>';
}
}
function startDashPolling() {
stopDashPolling();
loadDashInitial();
openDashWS();
dashTrafficInterval = setInterval(() => {
DA.totalUp += DA.trafficUp;
DA.totalDown += DA.trafficDown;
document.getElementById('dashUp').textContent = formatBytesPerSec(DA.trafficUp);
document.getElementById('dashDown').textContent = formatBytesPerSec(DA.trafficDown);
document.getElementById('dashUpTotal').textContent = formatBytes(DA.totalUp);
document.getElementById('dashDownTotal').textContent = formatBytes(DA.totalDown);
document.getElementById('dashMem').textContent = formatBytes(DA.memory);
}, 1000);
dashConnsInterval = setInterval(loadDashConnections, 3000);
}
function stopDashPolling() {
if (dashTrafficInterval) { clearInterval(dashTrafficInterval); dashTrafficInterval = null; }
if (dashConnsInterval) { clearInterval(dashConnsInterval); dashConnsInterval = null; }
closeDashWS();
}
function updateDashVisibility() {
const online = document.getElementById('dashOnline');
const offline = document.getElementById('dashOffline');
if (!online || !offline) return;
if (PS.status && PS.status.running) {
online.classList.remove('hidden');
offline.classList.add('hidden');
} else {
online.classList.add('hidden');
offline.classList.remove('hidden');
stopDashPolling();
}
}
// ════════════════════════════════════════════════════════════
// INIT
// ════════════════════════════════════════════════════════════
(async () => {
try { await loadStatus(); } catch (e) { console.error('status', e); }
try { await loadConfig(); } catch (e) { console.error('config', e); }
updateDashVisibility();
if (PS.status && PS.status.running) startDashPolling();
})();