mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-08-03 07:59:09 +08:00
2962 lines
112 KiB
JavaScript
2962 lines
112 KiB
JavaScript
/*
|
||
* SPDX-License-Identifier: GPL-2.0-only
|
||
*
|
||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||
*/
|
||
|
||
'use strict';
|
||
'require form';
|
||
'require fs';
|
||
'require rpc';
|
||
'require uci';
|
||
'require ui';
|
||
'require view';
|
||
|
||
'require homeproxy as hp';
|
||
'require tools.widgets as widgets';
|
||
|
||
function allowInsecureConfirm(ev, _section_id, value) {
|
||
if (value === '1' && !confirm(_('Are you sure to allow insecure?')))
|
||
ev.target.firstElementChild.checked = null;
|
||
}
|
||
|
||
async function parseVpnLink(uri) {
|
||
/* AmneziaVPN vpn:// share format: base64url(qCompress(JSON))
|
||
* qCompress = 4-byte big-endian uncompressed length + zlib stream */
|
||
const b64 = uri.slice(6).replace(/-/g, '+').replace(/_/g, '/');
|
||
const binary = atob(b64);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++)
|
||
bytes[i] = binary.charCodeAt(i);
|
||
|
||
const ds = new DecompressionStream('deflate');
|
||
const writer = ds.writable.getWriter();
|
||
const reader = ds.readable.getReader();
|
||
writer.write(bytes.slice(4)); // skip 4-byte qCompress length header
|
||
writer.close();
|
||
|
||
const chunks = [];
|
||
let res;
|
||
while (!(res = await reader.read()).done)
|
||
chunks.push(res.value);
|
||
|
||
const decoded = JSON.parse(new TextDecoder().decode(
|
||
new Uint8Array(chunks.reduce((a, c) => [...a, ...c], []))
|
||
));
|
||
|
||
const container = decoded.containers?.find(c => c.container === decoded.defaultContainer)
|
||
|| decoded.containers?.[0];
|
||
if (!container) return null;
|
||
|
||
const label = decoded.description || decoded.hostName || null;
|
||
|
||
switch (container.container) {
|
||
case 'amnezia-awg':
|
||
case 'amnezia-awg2': {
|
||
const awg = container.awg;
|
||
if (!awg) return null;
|
||
/* detailed fields live inside last_config as a JSON string */
|
||
let cfg = awg;
|
||
if (awg.last_config) {
|
||
try { cfg = Object.assign({}, awg, JSON.parse(awg.last_config)); } catch(e) {}
|
||
}
|
||
const clientIp = cfg.client_ip || '';
|
||
const localAddr = clientIp.includes('/') ? clientIp : (clientIp ? clientIp + '/32' : null);
|
||
return {
|
||
label: (label || (cfg.hostName + ':' + cfg.port) || 'AmneziaWG') + '-awg',
|
||
type: 'amneziawg',
|
||
address: cfg.hostName || decoded.hostName,
|
||
port: String(cfg.port || awg.port),
|
||
wireguard_local_address: localAddr ? [localAddr] : null,
|
||
wireguard_private_key: cfg.client_priv_key,
|
||
wireguard_peer_public_key: cfg.server_pub_key,
|
||
wireguard_pre_shared_key: cfg.psk_key || null,
|
||
wireguard_mtu: cfg.mtu || null,
|
||
wireguard_persistent_keepalive_interval: cfg.persistent_keep_alive || null,
|
||
amnezia_jc: awg.Jc,
|
||
amnezia_jmin: awg.Jmin,
|
||
amnezia_jmax: awg.Jmax,
|
||
amnezia_s1: awg.S1,
|
||
amnezia_s2: awg.S2,
|
||
amnezia_s3: awg.S3 || null,
|
||
amnezia_s4: awg.S4 || null,
|
||
amnezia_h1: awg.H1,
|
||
amnezia_h2: awg.H2,
|
||
amnezia_h3: awg.H3,
|
||
amnezia_h4: awg.H4,
|
||
amnezia_i1: awg.I1 || null,
|
||
amnezia_i2: awg.I2 || null,
|
||
amnezia_i3: awg.I3 || null,
|
||
amnezia_i4: awg.I4 || null,
|
||
amnezia_i5: awg.I5 || null,
|
||
};
|
||
}
|
||
case 'amnezia-xray': {
|
||
const xray = container.xray;
|
||
if (!xray?.last_config) return null;
|
||
let xrayCfg;
|
||
try { xrayCfg = JSON.parse(xray.last_config); } catch(e) { return null; }
|
||
|
||
const outbound = xrayCfg.outbounds?.find(
|
||
o => !['freedom', 'blackhole', 'dns'].includes(o.protocol)
|
||
);
|
||
if (!outbound) return null;
|
||
|
||
const stream = outbound.streamSettings || {};
|
||
/* Xray network → homeproxy transport */
|
||
const netMap = { ws: 'ws', grpc: 'grpc', h2: 'http', http: 'http',
|
||
httpupgrade: 'httpupgrade', xhttp: 'xhttp', splithttp: 'xhttp' };
|
||
const transport = netMap[stream.network] || null;
|
||
const security = stream.security || 'none';
|
||
|
||
let config = {
|
||
label: (label || (decoded.hostName + ':' + xray.port) || outbound.protocol) + '-XRay',
|
||
transport: transport,
|
||
};
|
||
|
||
/* TLS / Reality */
|
||
if (security === 'reality') {
|
||
const r = stream.realitySettings || {};
|
||
Object.assign(config, {
|
||
tls: '1',
|
||
tls_reality: '1',
|
||
tls_sni: r.serverName || null,
|
||
tls_utls: r.fingerprint || null,
|
||
tls_reality_public_key: r.publicKey || null,
|
||
tls_reality_short_id: r.shortId || null,
|
||
});
|
||
} else if (security === 'tls') {
|
||
const t = stream.tlsSettings || {};
|
||
Object.assign(config, {
|
||
tls: '1',
|
||
tls_sni: t.serverName || null,
|
||
tls_insecure: t.allowInsecure ? '1' : '0',
|
||
tls_utls: t.fingerprint || null,
|
||
});
|
||
}
|
||
|
||
/* Transport details */
|
||
if (transport === 'ws') {
|
||
const ws = stream.wsSettings || {};
|
||
config.ws_path = ws.path || null;
|
||
config.transport_host = ws.headers?.Host || null;
|
||
} else if (transport === 'grpc') {
|
||
config.grpc_servicename = (stream.grpcSettings || {}).serviceName || null;
|
||
} else if (transport === 'xhttp') {
|
||
const xhttpCfg = stream.xhttpSettings || stream.splithttpSettings || {};
|
||
config.http_path = xhttpCfg.path || null;
|
||
config.http_host = xhttpCfg.host || null;
|
||
config.xhttp_mode = xhttpCfg.mode || null;
|
||
config.xhttp_padding_bytes = xhttpCfg.xPaddingBytes || xhttpCfg.x_padding_bytes || null;
|
||
config.xhttp_sc_max_each_post_bytes = xhttpCfg.scMaxEachPostBytes || xhttpCfg.sc_max_each_post_bytes || null;
|
||
config.xhttp_sc_min_posts_interval_ms = xhttpCfg.scMinPostsIntervalMs || xhttpCfg.sc_min_posts_interval_ms || null;
|
||
}
|
||
|
||
/* Protocol-specific fields */
|
||
switch (outbound.protocol) {
|
||
case 'vless': {
|
||
const vnext = outbound.settings?.vnext?.[0];
|
||
if (!vnext) return null;
|
||
const user = vnext.users?.[0] || {};
|
||
return Object.assign(config, {
|
||
type: 'vless',
|
||
address: vnext.address,
|
||
port: String(vnext.port),
|
||
uuid: user.id,
|
||
vless_flow: (security === 'reality' || security === 'tls') ? (user.flow || null) : null,
|
||
});
|
||
}
|
||
case 'vmess': {
|
||
const vnext = outbound.settings?.vnext?.[0];
|
||
if (!vnext) return null;
|
||
const user = vnext.users?.[0] || {};
|
||
return Object.assign(config, {
|
||
type: 'vmess',
|
||
address: vnext.address,
|
||
port: String(vnext.port),
|
||
uuid: user.id,
|
||
vmess_alter_id: String(user.alterId || 0),
|
||
});
|
||
}
|
||
case 'trojan': {
|
||
const server = outbound.settings?.servers?.[0];
|
||
if (!server) return null;
|
||
return Object.assign(config, {
|
||
type: 'trojan',
|
||
address: server.address,
|
||
port: String(server.port),
|
||
password: server.password,
|
||
});
|
||
}
|
||
case 'shadowsocks': {
|
||
const server = outbound.settings?.servers?.[0];
|
||
if (!server) return null;
|
||
return Object.assign(config, {
|
||
type: 'shadowsocks',
|
||
address: server.address,
|
||
port: String(server.port),
|
||
password: server.password,
|
||
shadowsocks_encrypt_method: server.method,
|
||
});
|
||
}
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
default:
|
||
/* amnezia-openvpn, amnezia-ipsec, unknown — skip silently */
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function parseWireGuardConf(text) {
|
||
const lines = text.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
||
let section = null;
|
||
const iface = {}, peer = {};
|
||
|
||
for (const line of lines) {
|
||
if (line === '[Interface]') { section = 'interface'; continue; }
|
||
if (line === '[Peer]') { section = 'peer'; continue; }
|
||
const eq = line.indexOf('=');
|
||
if (eq < 0) continue;
|
||
const key = line.slice(0, eq).trim();
|
||
const val = line.slice(eq + 1).trim();
|
||
if (section === 'interface') iface[key] = val;
|
||
else if (section === 'peer') peer[key] = val;
|
||
}
|
||
|
||
if (!iface.PrivateKey || !peer.PublicKey || !peer.Endpoint) return null;
|
||
|
||
const lastColon = peer.Endpoint.lastIndexOf(':');
|
||
const host = peer.Endpoint.slice(0, lastColon);
|
||
const port = peer.Endpoint.slice(lastColon + 1);
|
||
|
||
const isAWG = !!(iface.Jc || iface.Jmin || iface.Jmax || iface.H1);
|
||
|
||
const node = {
|
||
label: isAWG ? 'AmneziaWG' : 'WireGuard',
|
||
type: isAWG ? 'amneziawg' : 'wireguard',
|
||
address: host.replace(/^\[|\]$/g, ''),
|
||
port: port,
|
||
wireguard_private_key: iface.PrivateKey,
|
||
wireguard_peer_public_key: peer.PublicKey,
|
||
wireguard_pre_shared_key: peer.PresharedKey || null,
|
||
wireguard_local_address: iface.Address ? iface.Address.split(',').map(a => a.trim()) : null,
|
||
wireguard_mtu: iface.MTU || null,
|
||
};
|
||
|
||
if (isAWG) {
|
||
node.amnezia_jc = iface.Jc || null;
|
||
node.amnezia_jmin = iface.Jmin || null;
|
||
node.amnezia_jmax = iface.Jmax || null;
|
||
node.amnezia_s1 = iface.S1 || null;
|
||
node.amnezia_s2 = iface.S2 || null;
|
||
node.amnezia_s3 = iface.S3 || null;
|
||
node.amnezia_s4 = iface.S4 || null;
|
||
node.amnezia_h1 = iface.H1 || null;
|
||
node.amnezia_h2 = iface.H2 || null;
|
||
node.amnezia_h3 = iface.H3 || null;
|
||
node.amnezia_h4 = iface.H4 || null;
|
||
node.amnezia_i1 = iface.I1 || null;
|
||
node.amnezia_i2 = iface.I2 || null;
|
||
node.amnezia_i3 = iface.I3 || null;
|
||
node.amnezia_i4 = iface.I4 || null;
|
||
node.amnezia_i5 = iface.I5 || null;
|
||
}
|
||
|
||
return node;
|
||
}
|
||
|
||
function parseShareLink(uri, features) {
|
||
let config, url, params;
|
||
|
||
uri = uri.split('://');
|
||
if (uri[0] && uri[1]) {
|
||
switch (uri[0]) {
|
||
case 'anytls':
|
||
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
|
||
url = new URL('http://' + uri[1]);
|
||
params = url.searchParams;
|
||
|
||
/* Check if password exists */
|
||
if (!url.username)
|
||
return null;
|
||
|
||
config = {
|
||
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null,
|
||
type: 'anytls',
|
||
address: url.hostname,
|
||
port: url.port || '80',
|
||
password: url.username ? decodeURIComponent(url.username) : null,
|
||
tls: '1',
|
||
tls_sni: params.get('sni'),
|
||
tls_insecure: (params.get('insecure') === '1') ? '1' : '0'
|
||
};
|
||
|
||
break;
|
||
case 'http':
|
||
case 'https':
|
||
url = new URL('http://' + uri[1]);
|
||
|
||
config = {
|
||
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null,
|
||
type: 'http',
|
||
address: url.hostname,
|
||
port: url.port || '80',
|
||
username: url.username ? decodeURIComponent(url.username) : null,
|
||
password: url.password ? decodeURIComponent(url.password) : null,
|
||
tls: (uri[0] === 'https') ? '1' : '0'
|
||
};
|
||
|
||
break;
|
||
case 'hysteria':
|
||
/* https://github.com/HyNetwork/hysteria/wiki/URI-Scheme */
|
||
url = new URL('http://' + uri[1]);
|
||
params = url.searchParams;
|
||
|
||
/* WeChat-Video / FakeTCP are unsupported by sing-box currently */
|
||
if (!features.with_quic || (params.get('protocol') && params.get('protocol') !== 'udp'))
|
||
return null;
|
||
|
||
config = {
|
||
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null,
|
||
type: 'hysteria',
|
||
address: url.hostname,
|
||
port: url.port || '80',
|
||
hysteria_protocol: params.get('protocol') || 'udp',
|
||
hysteria_auth_type: params.get('auth') ? 'string' : null,
|
||
hysteria_auth_payload: params.get('auth'),
|
||
hysteria_obfs_password: params.get('obfsParam'),
|
||
hysteria_down_mbps: params.get('downmbps'),
|
||
hysteria_up_mbps: params.get('upmbps'),
|
||
tls: '1',
|
||
tls_sni: params.get('peer'),
|
||
tls_alpn: params.get('alpn'),
|
||
tls_insecure: (params.get('insecure') === '1') ? '1' : '0'
|
||
};
|
||
|
||
break;
|
||
case 'hysteria2':
|
||
case 'hy2':
|
||
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
|
||
url = new URL('http://' + uri[1]);
|
||
params = url.searchParams;
|
||
|
||
if (!features.with_quic)
|
||
return null;
|
||
|
||
config = {
|
||
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null,
|
||
type: 'hysteria2',
|
||
address: url.hostname,
|
||
port: url.port || '80',
|
||
password: url.username ? (
|
||
decodeURIComponent(url.username + (url.password ? (':' + url.password) : ''))
|
||
) : null,
|
||
hysteria_obfs_type: params.get('obfs'),
|
||
hysteria_obfs_password: params.get('obfs-password'),
|
||
tls: '1',
|
||
tls_sni: params.get('sni'),
|
||
tls_insecure: (params.get('insecure') === '1' || params.get('allow_insecure') === '1') ? '1' : '0'
|
||
};
|
||
|
||
break;
|
||
case 'mieru':
|
||
/* https://github.com/enfein/mieru */
|
||
url = new URL('http://' + uri[1]);
|
||
params = url.searchParams;
|
||
|
||
config = {
|
||
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null,
|
||
type: 'mieru',
|
||
address: url.hostname,
|
||
port: '0',
|
||
username: url.username ? decodeURIComponent(url.username) : null,
|
||
password: url.password ? decodeURIComponent(url.password) : null,
|
||
mieru_protocol: params.get('protocol') || null,
|
||
mieru_port_range: params.get('port') || null,
|
||
mieru_multiplexing: params.get('multiplexing') || null,
|
||
mieru_handshake_mode: params.get('handshake-mode') || null
|
||
};
|
||
|
||
break;
|
||
case 'naive':
|
||
case 'naive+http':
|
||
case 'naive+https': {
|
||
if (!features.with_naive_outbound)
|
||
return null;
|
||
|
||
url = new URL('http://' + uri[1]);
|
||
params = url.searchParams;
|
||
|
||
let naiveExtraHeaders = null;
|
||
if (params.get('header')) {
|
||
const hdrParts = params.get('header').split(':');
|
||
if (hdrParts.length >= 2) {
|
||
let hdrs = {};
|
||
hdrs[hdrParts[0].trim()] = hdrParts.slice(1).join(':').trim();
|
||
naiveExtraHeaders = JSON.stringify(hdrs);
|
||
}
|
||
}
|
||
|
||
config = {
|
||
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null,
|
||
type: 'naive',
|
||
address: url.hostname,
|
||
port: url.port || '443',
|
||
username: url.username ? decodeURIComponent(url.username) : null,
|
||
password: url.password ? decodeURIComponent(url.password) : null,
|
||
tls: (uri[0] === 'naive+https' || params.get('security') === 'tls') ? '1' : '0',
|
||
tls_sni: params.get('sni') || url.hostname,
|
||
naive_udp_over_tcp: (params.get('uot') === '1') ? '1' : null,
|
||
naive_quic: (params.get('quic') === '1') ? '1' : null,
|
||
naive_extra_headers: naiveExtraHeaders
|
||
};
|
||
|
||
break;
|
||
}
|
||
case 'socks':
|
||
case 'socks4':
|
||
case 'socks4a':
|
||
case 'socsk5':
|
||
case 'socks5h':
|
||
url = new URL('http://' + uri[1]);
|
||
|
||
config = {
|
||
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null,
|
||
type: 'socks',
|
||
address: url.hostname,
|
||
port: url.port || '80',
|
||
username: url.username ? decodeURIComponent(url.username) : null,
|
||
password: url.password ? decodeURIComponent(url.password) : null,
|
||
socks_version: (uri[0].includes('4')) ? '4' : '5'
|
||
};
|
||
|
||
break;
|
||
case 'ss':
|
||
try {
|
||
/* "Lovely" Shadowrocket format */
|
||
try {
|
||
let suri = uri[1].split('#'), slabel = '';
|
||
if (suri.length <= 2) {
|
||
if (suri.length === 2)
|
||
slabel = '#' + suri[1];
|
||
uri[1] = hp.decodeBase64Str(suri[0]) + slabel;
|
||
}
|
||
} catch(e) { }
|
||
|
||
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
|
||
url = new URL('http://' + uri[1]);
|
||
|
||
let userinfo;
|
||
if (url.username && url.password) {
|
||
/* User info encoded with URIComponent */
|
||
userinfo = [url.username, decodeURIComponent(url.password)];
|
||
} else if (url.username) {
|
||
/* User info encoded with base64 */
|
||
userinfo = hp.decodeBase64Str(decodeURIComponent(url.username)).split(':');
|
||
if (userinfo.length > 1)
|
||
userinfo = [userinfo[0], userinfo.slice(1).join(':')]
|
||
}
|
||
|
||
if (!hp.shadowsocks_encrypt_methods.includes(userinfo[0]))
|
||
return null;
|
||
|
||
let plugin, plugin_opts;
|
||
if (url.search && url.searchParams.get('plugin')) {
|
||
let plugin_info = url.searchParams.get('plugin').split(';');
|
||
plugin = plugin_info[0];
|
||
plugin_opts = (plugin_info.length > 1) ? plugin_info.slice(1).join(';') : null;
|
||
}
|
||
|
||
config = {
|
||
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null,
|
||
type: 'shadowsocks',
|
||
address: url.hostname,
|
||
port: url.port || '80',
|
||
shadowsocks_encrypt_method: userinfo[0],
|
||
password: userinfo[1],
|
||
shadowsocks_plugin: plugin,
|
||
shadowsocks_plugin_opts: plugin_opts
|
||
};
|
||
} catch(e) {
|
||
/* Legacy format https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */
|
||
uri = uri[1].split('@');
|
||
if (uri.length < 2)
|
||
return null;
|
||
else if (uri.length > 2)
|
||
uri = [ uri.slice(0, -1).join('@'), uri.slice(-1).toString() ];
|
||
|
||
config = {
|
||
type: 'shadowsocks',
|
||
address: uri[1].split(':')[0],
|
||
port: uri[1].split(':')[1],
|
||
shadowsocks_encrypt_method: uri[0].split(':')[0],
|
||
password: uri[0].split(':').slice(1).join(':')
|
||
};
|
||
}
|
||
|
||
break;
|
||
case 'ssh': {
|
||
/* Manual parse to avoid URL corruption on long base64 query values */
|
||
let sshStr = uri[1];
|
||
let sshLabel = null;
|
||
|
||
const sshHashIdx = sshStr.indexOf('#');
|
||
if (sshHashIdx >= 0) {
|
||
sshLabel = decodeURIComponent(sshStr.slice(sshHashIdx + 1));
|
||
sshStr = sshStr.slice(0, sshHashIdx);
|
||
}
|
||
|
||
let sshParams = {};
|
||
const sshQ = sshStr.indexOf('?');
|
||
if (sshQ >= 0) {
|
||
sshParams = Object.fromEntries(new URLSearchParams(sshStr.slice(sshQ + 1)));
|
||
sshStr = sshStr.slice(0, sshQ);
|
||
}
|
||
sshStr = sshStr.replace(/\/+$/, '');
|
||
|
||
const sshAt = sshStr.indexOf('@');
|
||
let sshUser = null, sshPass = null, sshHost = null, sshPort = null;
|
||
if (sshAt >= 0) {
|
||
const sshUserinfo = sshStr.slice(0, sshAt);
|
||
const sshHostport = sshStr.slice(sshAt + 1);
|
||
const sshColon = sshUserinfo.indexOf(':');
|
||
if (sshColon >= 0) {
|
||
sshUser = decodeURIComponent(sshUserinfo.slice(0, sshColon));
|
||
sshPass = decodeURIComponent(sshUserinfo.slice(sshColon + 1));
|
||
} else {
|
||
sshUser = decodeURIComponent(sshUserinfo);
|
||
}
|
||
const sshHp = sshHostport.split(':');
|
||
sshPort = sshHp.pop();
|
||
sshHost = sshHp.join(':') || null;
|
||
}
|
||
|
||
let sshHostKey = null;
|
||
const rawHk = sshParams['hk'] || sshParams['host_key'] || sshParams['hostKey'];
|
||
if (rawHk) {
|
||
const hkLines = rawHk.split(',').filter(l => l.trim().length > 0);
|
||
sshHostKey = hkLines.length ? hkLines : null;
|
||
}
|
||
|
||
config = {
|
||
label: sshLabel,
|
||
type: 'ssh',
|
||
address: sshHost,
|
||
port: sshPort,
|
||
username: sshUser,
|
||
password: sshPass || null,
|
||
ssh_priv_key: sshParams['pk'] || sshParams['private_key'] || sshParams['privateKey'] || null,
|
||
ssh_priv_key_pp: sshParams['passphrase'] || null,
|
||
ssh_host_key: sshHostKey
|
||
};
|
||
|
||
break;
|
||
}
|
||
case 'trojan':
|
||
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
|
||
url = new URL('http://' + uri[1]);
|
||
params = url.searchParams;
|
||
|
||
/* Check if password exists */
|
||
if (!url.username)
|
||
return null;
|
||
|
||
config = {
|
||
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null,
|
||
type: 'trojan',
|
||
address: url.hostname,
|
||
port: url.port || '80',
|
||
password: decodeURIComponent(url.username),
|
||
transport: params.get('type') !== 'tcp' ? params.get('type') : null,
|
||
tls: '1',
|
||
tls_sni: params.get('sni')
|
||
};
|
||
switch (params.get('type')) {
|
||
case 'grpc':
|
||
config.grpc_servicename = params.get('serviceName');
|
||
break;
|
||
case 'ws':
|
||
config.ws_host = params.get('host') ? decodeURIComponent(params.get('host')) : null;
|
||
config.ws_path = params.get('path') ? decodeURIComponent(params.get('path')) : null;
|
||
if (config.ws_path && config.ws_path.includes('?ed=')) {
|
||
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
||
config.websocket_early_data = config.ws_path.split('?ed=')[1];
|
||
config.ws_path = config.ws_path.split('?ed=')[0];
|
||
}
|
||
break;
|
||
case 'xhttp':
|
||
config.http_path = params.get('path') ? decodeURIComponent(params.get('path')) : null;
|
||
config.http_host = params.get('host') ? decodeURIComponent(params.get('host')) : null;
|
||
config.xhttp_mode = params.get('mode') || null;
|
||
config.xhttp_padding_bytes = params.get('xPaddingBytes') || params.get('x_padding_bytes') || null;
|
||
config.xhttp_sc_max_each_post_bytes = params.get('scMaxEachPostBytes') || params.get('sc_max_each_post_bytes') || null;
|
||
config.xhttp_sc_min_posts_interval_ms = params.get('scMinPostsIntervalMs') || params.get('sc_min_posts_interval_ms') || null;
|
||
break;
|
||
}
|
||
|
||
if (params.get('hiddify') === '1') {
|
||
if (params.get('fragment')) {
|
||
const fparts = params.get('fragment').split(',');
|
||
if (fparts.length >= 2) {
|
||
config.tls_fragment = '1';
|
||
config.tls_fragment_size = fparts[0];
|
||
config.tls_fragment_sleep = fparts[1];
|
||
config.tls_fragment_type = fparts[2] || null;
|
||
}
|
||
}
|
||
if (params.get('allowInsecure') === 'true' || params.get('insecure') === 'true')
|
||
config.tls_insecure = '1';
|
||
}
|
||
|
||
break;
|
||
case 'tuic':
|
||
/* https://github.com/daeuniverse/dae/discussions/182 */
|
||
url = new URL('http://' + uri[1]);
|
||
params = url.searchParams;
|
||
|
||
if (!features.with_quic)
|
||
return null;
|
||
|
||
/* Check if uuid exists */
|
||
if (!url.username)
|
||
return null;
|
||
|
||
config = {
|
||
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null,
|
||
type: 'tuic',
|
||
address: url.hostname,
|
||
port: url.port || '80',
|
||
uuid: url.username,
|
||
password: url.password ? decodeURIComponent(url.password) : null,
|
||
tuic_congestion_control: params.get('congestion_control'),
|
||
tuic_udp_relay_mode: params.get('udp_relay_mode'),
|
||
tuic_enable_zero_rtt: params.get('zero_rtt_handshake') || null,
|
||
tuic_heartbeat: params.get('heartbeat') || null,
|
||
tls: '1',
|
||
tls_sni: params.get('sni'),
|
||
tls_alpn: params.get('alpn') ? decodeURIComponent(params.get('alpn')).split(',') : null,
|
||
tls_insecure: (params.get('allow_insecure') === '1' || params.get('insecure') === '1') ? '1' : '0'
|
||
};
|
||
|
||
break;
|
||
case 'vless':
|
||
/* https://github.com/XTLS/Xray-core/discussions/716 */
|
||
url = new URL('http://' + uri[1]);
|
||
params = url.searchParams;
|
||
|
||
/* Unsupported protocol */
|
||
if (params.get('type') === 'kcp')
|
||
return null;
|
||
else if (params.get('type') === 'quic' && ((params.get('quicSecurity') && params.get('quicSecurity') !== 'none') || !features.with_quic))
|
||
return null;
|
||
/* Check if uuid and type exist */
|
||
if (!url.username || !params.get('type'))
|
||
return null;
|
||
|
||
config = {
|
||
label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null,
|
||
type: 'vless',
|
||
address: url.hostname,
|
||
port: url.port || '80',
|
||
uuid: url.username,
|
||
transport: params.get('type') !== 'tcp' ? params.get('type') : null,
|
||
tls: ['tls', 'xtls', 'reality'].includes(params.get('security')) ? '1' : '0',
|
||
tls_sni: params.get('sni'),
|
||
tls_alpn: params.get('alpn') ? decodeURIComponent(params.get('alpn')).split(',') : null,
|
||
tls_reality: (params.get('security') === 'reality') ? '1' : '0',
|
||
tls_reality_public_key: params.get('pbk') ? decodeURIComponent(params.get('pbk')) : null,
|
||
tls_reality_short_id: params.get('sid'),
|
||
tls_utls: features.with_utls ? params.get('fp') : null,
|
||
vless_flow: ['tls', 'reality'].includes(params.get('security')) ? params.get('flow') : null
|
||
};
|
||
switch (params.get('type')) {
|
||
case 'grpc':
|
||
config.grpc_servicename = params.get('serviceName');
|
||
break;
|
||
case 'http':
|
||
case 'tcp':
|
||
if (config.transport === 'http' || params.get('headerType') === 'http') {
|
||
config.http_host = params.get('host') ? decodeURIComponent(params.get('host')).split(',') : null;
|
||
config.http_path = params.get('path') ? decodeURIComponent(params.get('path')) : null;
|
||
}
|
||
break;
|
||
case 'httpupgrade':
|
||
config.httpupgrade_host = params.get('host') ? decodeURIComponent(params.get('host')) : null;
|
||
config.http_path = params.get('path') ? decodeURIComponent(params.get('path')) : null;
|
||
break;
|
||
case 'ws':
|
||
config.ws_host = params.get('host') ? decodeURIComponent(params.get('host')) : null;
|
||
config.ws_path = params.get('path') ? decodeURIComponent(params.get('path')) : null;
|
||
if (config.ws_path && config.ws_path.includes('?ed=')) {
|
||
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
||
config.websocket_early_data = config.ws_path.split('?ed=')[1];
|
||
config.ws_path = config.ws_path.split('?ed=')[0];
|
||
}
|
||
break;
|
||
case 'xhttp':
|
||
config.http_path = params.get('path') ? decodeURIComponent(params.get('path')) : null;
|
||
config.http_host = params.get('host') ? decodeURIComponent(params.get('host')) : null;
|
||
config.xhttp_mode = params.get('mode') || null;
|
||
config.xhttp_padding_bytes = params.get('xPaddingBytes') || params.get('x_padding_bytes') || null;
|
||
config.xhttp_sc_max_each_post_bytes = params.get('scMaxEachPostBytes') || params.get('sc_max_each_post_bytes') || null;
|
||
config.xhttp_sc_min_posts_interval_ms = params.get('scMinPostsIntervalMs') || params.get('sc_min_posts_interval_ms') || null;
|
||
break;
|
||
}
|
||
|
||
if (params.get('hiddify') === '1') {
|
||
if (params.get('fragment')) {
|
||
const fparts = params.get('fragment').split(',');
|
||
if (fparts.length >= 2) {
|
||
config.tls_fragment = '1';
|
||
config.tls_fragment_size = fparts[0];
|
||
config.tls_fragment_sleep = fparts[1];
|
||
config.tls_fragment_type = fparts[2] || null;
|
||
}
|
||
}
|
||
if (params.get('allowInsecure') === 'true' || params.get('insecure') === 'true')
|
||
config.tls_insecure = '1';
|
||
if (params.get('extra')) {
|
||
try {
|
||
const extra = JSON.parse(params.get('extra'));
|
||
if (extra.headers && Object.keys(extra.headers).length > 0)
|
||
config.xhttp_headers = JSON.stringify(extra.headers);
|
||
if (extra.downloadSettings) {
|
||
const dl = extra.downloadSettings;
|
||
config.xhttp_download_server = dl.address || null;
|
||
config.xhttp_download_port = dl.port ? String(dl.port) : null;
|
||
if (dl.xhttpSettings) {
|
||
config.xhttp_download_path = dl.xhttpSettings.path || null;
|
||
config.xhttp_download_host = dl.xhttpSettings.host || null;
|
||
config.xhttp_download_mode = dl.xhttpSettings.mode || null;
|
||
}
|
||
config.xhttp_download_security = dl.security || null;
|
||
if (dl.security === 'reality' && dl.realitySettings) {
|
||
config.xhttp_download_sni = dl.realitySettings.serverName || null;
|
||
config.xhttp_download_fp = dl.realitySettings.fingerprint || null;
|
||
config.xhttp_download_pbk = dl.realitySettings.publicKey || null;
|
||
config.xhttp_download_sid = dl.realitySettings.shortId || null;
|
||
} else if (dl.security === 'tls' && dl.tlsSettings) {
|
||
config.xhttp_download_sni = dl.tlsSettings.serverName || null;
|
||
config.xhttp_download_alpn = dl.tlsSettings.alpn || null;
|
||
}
|
||
}
|
||
} catch(e) { }
|
||
}
|
||
}
|
||
|
||
break;
|
||
case 'vmess':
|
||
/* "Lovely" shadowrocket format */
|
||
if (uri.includes('&'))
|
||
return null;
|
||
|
||
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
|
||
uri = JSON.parse(hp.decodeBase64Str(uri[1]));
|
||
|
||
if (uri.v != '2')
|
||
return null;
|
||
/* Unsupported protocols */
|
||
else if (uri.net === 'kcp')
|
||
return null;
|
||
else if (uri.net === 'quic' && ((uri.type && uri.type !== 'none') || !features.with_quic))
|
||
return null;
|
||
/* https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6
|
||
* else if (uri.aid && parseInt(uri.aid) !== 0)
|
||
* return null;
|
||
*/
|
||
|
||
config = {
|
||
label: uri.ps,
|
||
type: 'vmess',
|
||
address: uri.add,
|
||
port: uri.port,
|
||
uuid: uri.id,
|
||
vmess_alterid: uri.aid,
|
||
vmess_encrypt: uri.scy || 'auto',
|
||
transport: (uri.net !== 'tcp') ? uri.net : null,
|
||
tls: uri.tls === 'tls' ? '1' : '0',
|
||
tls_sni: uri.sni || uri.host,
|
||
tls_alpn: uri.alpn ? uri.alpn.split(',') : null,
|
||
tls_utls: features.with_utls ? uri.fp : null
|
||
};
|
||
switch (uri.net) {
|
||
case 'grpc':
|
||
config.grpc_servicename = uri.path;
|
||
break;
|
||
case 'h2':
|
||
case 'tcp':
|
||
if (uri.net === 'h2' || uri.type === 'http') {
|
||
config.transport = 'http';
|
||
config.http_host = uri.host ? uri.host.split(',') : null;
|
||
config.http_path = uri.path;
|
||
}
|
||
break;
|
||
case 'httpupgrade':
|
||
config.httpupgrade_host = uri.host;
|
||
config.http_path = uri.path;
|
||
break;
|
||
case 'ws':
|
||
config.ws_host = uri.host;
|
||
config.ws_path = uri.path;
|
||
if (config.ws_path && config.ws_path.includes('?ed=')) {
|
||
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
||
config.websocket_early_data = config.ws_path.split('?ed=')[1];
|
||
config.ws_path = config.ws_path.split('?ed=')[0];
|
||
}
|
||
break;
|
||
}
|
||
|
||
break;
|
||
case 'wg':
|
||
case 'wireguard': {
|
||
if (!features.with_wireguard || !features.with_gvisor)
|
||
return null;
|
||
|
||
/* Manual parse: private key in userinfo may contain chars that confuse URL parsing */
|
||
let wgStr = uri[1];
|
||
let wgLabel = null;
|
||
|
||
const wgHash = wgStr.indexOf('#');
|
||
if (wgHash >= 0) {
|
||
wgLabel = decodeURIComponent(wgStr.slice(wgHash + 1));
|
||
wgStr = wgStr.slice(0, wgHash);
|
||
}
|
||
|
||
let wgParams = {};
|
||
const wgQ = wgStr.indexOf('?');
|
||
if (wgQ >= 0) {
|
||
wgParams = Object.fromEntries(new URLSearchParams(wgStr.slice(wgQ + 1)));
|
||
wgStr = wgStr.slice(0, wgQ);
|
||
}
|
||
wgStr = wgStr.replace(/\/+$/, '');
|
||
|
||
const wgAt = wgStr.indexOf('@');
|
||
let wgPrivKey = null, wgHost = null, wgPort = null;
|
||
if (wgAt >= 0) {
|
||
wgPrivKey = decodeURIComponent(wgStr.slice(0, wgAt));
|
||
const wgHp = wgStr.slice(wgAt + 1).split(':');
|
||
wgPort = wgHp.pop();
|
||
wgHost = wgHp.join(':') || null;
|
||
} else {
|
||
const wgHp = wgStr.split(':');
|
||
wgPort = wgHp.pop();
|
||
wgHost = wgHp.join(':') || null;
|
||
wgPrivKey = wgParams['privateKey'] || wgParams['privatekey'] || null;
|
||
}
|
||
|
||
const wgLocalAddr = wgParams['address'] || wgParams['ip'] || null;
|
||
config = {
|
||
label: wgLabel,
|
||
type: 'wireguard',
|
||
address: wgHost,
|
||
port: wgPort,
|
||
wireguard_private_key: wgPrivKey,
|
||
wireguard_peer_public_key: wgParams['publicKey'] || wgParams['publickey'] || null,
|
||
wireguard_pre_shared_key: wgParams['presharedKey'] || wgParams['presharedkey'] || null,
|
||
wireguard_local_address: wgLocalAddr ? wgLocalAddr.split(',') : null,
|
||
wireguard_mtu: wgParams['mtu'] || null,
|
||
wireguard_reserved: wgParams['reserved'] ? wgParams['reserved'].split(',') : null
|
||
};
|
||
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (config) {
|
||
if (!config.address || !config.port)
|
||
return null;
|
||
else if (!config.label)
|
||
config.label = config.address + ':' + config.port;
|
||
|
||
config.address = config.address.replace(/\[|\]/g, '');
|
||
}
|
||
|
||
return config;
|
||
}
|
||
|
||
function renderNodeSettings(section, data, features, main_node, routing_mode) {
|
||
let s = section, o;
|
||
s.rowcolors = true;
|
||
s.sortable = true;
|
||
s.nodescriptions = true;
|
||
s.modaltitle = L.bind(hp.loadModalTitle, this, _('Node'), _('Add a node'), data[0]);
|
||
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]);
|
||
|
||
|
||
o = s.option(form.Value, 'label', _('Label'));
|
||
o.load = L.bind(hp.loadDefaultLabel, this, data[0]);
|
||
o.validate = L.bind(hp.validateUniqueValue, this, data[0], 'node', 'label');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'type', _('Type'));
|
||
o.value('direct', _('Direct'));
|
||
o.value('anytls', _('AnyTLS'));
|
||
o.value('http', _('HTTP'));
|
||
if (features.with_quic) {
|
||
o.value('hysteria', _('Hysteria'));
|
||
o.value('hysteria2', _('Hysteria2'));
|
||
o.value('mieru', _('Mieru'));
|
||
}
|
||
o.value('shadowsocks', _('Shadowsocks'));
|
||
o.value('shadowtls', _('ShadowTLS'));
|
||
if (features.with_naive_outbound)
|
||
o.value('naive', _('NaïveProxy'));
|
||
o.value('socks', _('Socks'));
|
||
o.value('ssh', _('SSH'));
|
||
o.value('trojan', _('Trojan'));
|
||
if (features.with_quic)
|
||
o.value('tuic', _('Tuic'));
|
||
if (features.with_wireguard && features.with_gvisor)
|
||
o.value('wireguard', _('WireGuard'));
|
||
o.value('amneziawg', _('AmneziaWG'));
|
||
o.value('vless', _('VLESS'));
|
||
o.value('vmess', _('VMess'));
|
||
o.rmempty = false;
|
||
|
||
o = s.option(form.Value, 'address', _('Address'));
|
||
o.datatype = 'host';
|
||
o.depends({'type': 'direct', '!reverse': true});
|
||
o.rmempty = false;
|
||
|
||
o = s.option(form.Value, 'port', _('Port'));
|
||
o.datatype = 'port';
|
||
o.depends('type', 'anytls');
|
||
o.depends('type', 'http');
|
||
o.depends('type', 'hysteria');
|
||
o.depends('type', 'hysteria2');
|
||
o.depends('type', 'naive');
|
||
o.depends('type', 'shadowsocks');
|
||
o.depends('type', 'shadowtls');
|
||
o.depends('type', 'socks');
|
||
o.depends('type', 'ssh');
|
||
o.depends('type', 'trojan');
|
||
o.depends('type', 'tuic');
|
||
o.depends('type', 'vless');
|
||
o.depends('type', 'vmess');
|
||
o.depends('type', 'wireguard');
|
||
o.depends('type', 'amneziawg');
|
||
o.rmempty = false;
|
||
|
||
o = s.option(form.Value, 'username', _('Username'));
|
||
o.depends('type', 'http');
|
||
o.depends('type', 'mieru');
|
||
o.depends('type', 'naive');
|
||
o.depends('type', 'socks');
|
||
o.depends('type', 'ssh');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'password', _('Password'));
|
||
o.password = true;
|
||
o.depends('type', 'anytls');
|
||
o.depends('type', 'http');
|
||
o.depends('type', 'hysteria2');
|
||
o.depends('type', 'mieru');
|
||
o.depends('type', 'naive');
|
||
o.depends('type', 'shadowsocks');
|
||
o.depends('type', 'ssh');
|
||
o.depends('type', 'trojan');
|
||
o.depends('type', 'tuic');
|
||
o.depends({'type': 'shadowtls', 'shadowtls_version': '2'});
|
||
o.depends({'type': 'shadowtls', 'shadowtls_version': '3'});
|
||
o.depends({'type': 'socks', 'socks_version': '5'});
|
||
o.validate = function(section_id, value) {
|
||
if (section_id) {
|
||
let type = this.section.formvalue(section_id, 'type');
|
||
let required_type = [ 'anytls', 'shadowsocks', 'shadowtls', 'trojan' ];
|
||
|
||
if (required_type.includes(type)) {
|
||
if (type === 'shadowsocks') {
|
||
let encmode = this.section.formvalue(section_id, 'shadowsocks_encrypt_method');
|
||
if (encmode === 'none')
|
||
return true;
|
||
}
|
||
if (!value)
|
||
return _('Expecting: %s').format(_('non-empty value'));
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
o.modalonly = true;
|
||
|
||
/* Direct config */
|
||
o = s.option(form.ListValue, 'proxy_protocol', _('Proxy protocol'),
|
||
_('Write proxy protocol in the connection header.'));
|
||
o.value('', _('Disable'));
|
||
o.value('1', _('v1'));
|
||
o.value('2', _('v2'));
|
||
o.depends('type', 'direct');
|
||
o.modalonly = true;
|
||
|
||
/* AnyTLS config start */
|
||
o = s.option(form.Value, 'anytls_idle_session_check_interval', _('Idle session check interval'),
|
||
_('Interval checking for idle sessions, in seconds.'));
|
||
o.datatype = 'uinteger';
|
||
o.placeholder = '30';
|
||
o.depends('type', 'anytls');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'anytls_idle_session_timeout', _('Idle session check timeout'),
|
||
_('In the check, close sessions that have been idle for longer than this, in seconds.'));
|
||
o.datatype = 'uinteger';
|
||
o.placeholder = '30';
|
||
o.depends('type', 'anytls');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'anytls_min_idle_session', _('Minimum idle sessions'),
|
||
_('In the check, at least the first <code>n</code> idle sessions are kept open.'));
|
||
o.datatype = 'uinteger';
|
||
o.placeholder = '0';
|
||
o.depends('type', 'anytls');
|
||
o.modalonly = true;
|
||
/* AnyTLS config end */
|
||
|
||
/* Hysteria (2) config start */
|
||
o = s.option(form.DynamicList, 'hysteria_hopping_port', _('Hopping port'));
|
||
o.depends('type', 'hysteria');
|
||
o.depends('type', 'hysteria2');
|
||
o.validate = hp.validatePortRange;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'hysteria_hop_interval', _('Hop interval'),
|
||
_('Port hopping interval in seconds.'));
|
||
o.datatype = 'uinteger';
|
||
o.placeholder = '30';
|
||
o.depends({'type': 'hysteria', 'hysteria_hopping_port': /[\s\S]/});
|
||
o.depends({'type': 'hysteria2', 'hysteria_hopping_port': /[\s\S]/});
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'hysteria_protocol', _('Protocol'));
|
||
o.value('udp');
|
||
/* WeChat-Video / FakeTCP are unsupported by sing-box currently
|
||
* o.value('wechat-video');
|
||
* o.value('faketcp');
|
||
*/
|
||
o.default = 'udp';
|
||
o.depends('type', 'hysteria');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'hysteria_auth_type', _('Authentication type'));
|
||
o.value('', _('Disable'));
|
||
o.value('base64', _('Base64'));
|
||
o.value('string', _('String'));
|
||
o.depends('type', 'hysteria');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'hysteria_auth_payload', _('Authentication payload'));
|
||
o.password = true
|
||
o.depends({'type': 'hysteria', 'hysteria_auth_type': /[\s\S]/});
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'hysteria_obfs_type', _('Obfuscate type'));
|
||
o.value('', _('Disable'));
|
||
o.value('salamander', _('Salamander'));
|
||
o.depends('type', 'hysteria2');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'hysteria_obfs_password', _('Obfuscate password'));
|
||
o.password = true;
|
||
o.depends('type', 'hysteria');
|
||
o.depends({'type': 'hysteria2', 'hysteria_obfs_type': /[\s\S]/});
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'hysteria_down_mbps', _('Max download speed'),
|
||
_('Max download speed in Mbps.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'hysteria');
|
||
o.depends('type', 'hysteria2');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'hysteria_up_mbps', _('Max upload speed'),
|
||
_('Max upload speed in Mbps.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'hysteria');
|
||
o.depends('type', 'hysteria2');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'hysteria_recv_window_conn', _('QUIC stream receive window'),
|
||
_('The QUIC stream-level flow control window for receiving data.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'hysteria');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'hysteria_revc_window', _('QUIC connection receive window'),
|
||
_('The QUIC connection-level flow control window for receiving data.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'hysteria');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'hysteria_disable_mtu_discovery', _('Disable Path MTU discovery'),
|
||
_('Disables Path MTU Discovery (RFC 8899). Packets will then be at most 1252 (IPv4) / 1232 (IPv6) bytes in size.'));
|
||
o.depends('type', 'hysteria');
|
||
o.modalonly = true;
|
||
/* Hysteria (2) config end */
|
||
|
||
/* Shadowsocks config start */
|
||
o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method'));
|
||
for (let i of hp.shadowsocks_encrypt_methods)
|
||
o.value(i);
|
||
/* Stream ciphers */
|
||
o.value('aes-128-ctr');
|
||
o.value('aes-192-ctr');
|
||
o.value('aes-256-ctr');
|
||
o.value('aes-128-cfb');
|
||
o.value('aes-192-cfb');
|
||
o.value('aes-256-cfb');
|
||
o.value('chacha20');
|
||
o.value('chacha20-ietf');
|
||
o.value('rc4-md5');
|
||
o.default = 'aes-128-gcm';
|
||
o.depends('type', 'shadowsocks');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'shadowsocks_plugin', _('Plugin'));
|
||
o.value('', _('none'));
|
||
o.value('obfs-local');
|
||
o.value('v2ray-plugin');
|
||
o.depends('type', 'shadowsocks');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'shadowsocks_plugin_opts', _('Plugin opts'));
|
||
o.depends('shadowsocks_plugin', 'obfs-local');
|
||
o.depends('shadowsocks_plugin', 'v2ray-plugin');
|
||
o.modalonly = true;
|
||
/* Shadowsocks config end */
|
||
|
||
/* ShadowTLS transport (overlay for Shadowsocks) */
|
||
o = s.option(form.Flag, 'shadowtls_enabled', _('ShadowTLS transport'),
|
||
_('Wrap this Shadowsocks connection in ShadowTLS for TLS camouflage.'));
|
||
o.depends('type', 'shadowsocks');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'shadowtls_password', _('ShadowTLS password'));
|
||
o.password = true;
|
||
o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'});
|
||
o.modalonly = true;
|
||
|
||
/* ShadowTLS config */
|
||
o = s.option(form.ListValue, 'shadowtls_version', _('ShadowTLS version'));
|
||
o.value('1', _('v1'));
|
||
o.value('2', _('v2'));
|
||
o.value('3', _('v3'));
|
||
o.default = '1';
|
||
o.depends('type', 'shadowtls');
|
||
o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'});
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
/* Socks config */
|
||
o = s.option(form.ListValue, 'socks_version', _('Socks version'));
|
||
o.value('4', _('Socks4'));
|
||
o.value('4a', _('Socks4A'));
|
||
o.value('5', _('Socks5'));
|
||
o.default = '5';
|
||
o.depends('type', 'socks');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
/* SSH config start */
|
||
o = s.option(form.Value, 'ssh_client_version', _('Client version'),
|
||
_('Random version will be used if empty.'));
|
||
o.depends('type', 'ssh');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.DynamicList, 'ssh_host_key', _('Host key'),
|
||
_('Accept any if empty.'));
|
||
o.depends('type', 'ssh');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.DynamicList, 'ssh_host_key_algo', _('Host key algorithms'))
|
||
o.depends('type', 'ssh');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.DynamicList, 'ssh_priv_key', _('Private key'));
|
||
o.password = true;
|
||
o.depends('type', 'ssh');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'ssh_priv_key_pp', _('Private key passphrase'));
|
||
o.password = true;
|
||
o.depends('type', 'ssh');
|
||
o.modalonly = true;
|
||
/* SSH config end */
|
||
|
||
/* Mieru config start */
|
||
o = s.option(form.ListValue, 'mieru_protocol', _('Protocol'));
|
||
o.value('TCP', _('TCP'));
|
||
o.value('UDP', _('UDP'));
|
||
o.value('TCP_AND_UDP', _('TCP and UDP'));
|
||
o.depends('type', 'mieru');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'mieru_port_range', _('Port range'),
|
||
_('Port range for the Mieru connection, e.g. %s.').format('<code>8080-8180</code>'));
|
||
o.depends('type', 'mieru');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'mieru_multiplexing', _('Multiplexing'));
|
||
o.value('', _('Default'));
|
||
o.value('MULTIPLEXING_OFF', _('Off'));
|
||
o.value('MULTIPLEXING_LOW', _('Low'));
|
||
o.value('MULTIPLEXING_MIDDLE', _('Middle'));
|
||
o.value('MULTIPLEXING_HIGH', _('High'));
|
||
o.depends('type', 'mieru');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'mieru_handshake_mode', _('Handshake mode'));
|
||
o.depends('type', 'mieru');
|
||
o.modalonly = true;
|
||
/* Mieru config end */
|
||
|
||
/* TUIC config start */
|
||
o = s.option(form.Value, 'uuid', _('UUID'));
|
||
o.password = true;
|
||
o.depends('type', 'tuic');
|
||
o.depends('type', 'vless');
|
||
o.depends('type', 'vmess');
|
||
o.validate = hp.validateUUID;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'tuic_congestion_control', _('Congestion control algorithm'),
|
||
_('QUIC congestion control algorithm.'));
|
||
o.value('cubic', _('CUBIC'));
|
||
o.value('new_reno', _('New Reno'));
|
||
o.value('bbr', _('BBR'));
|
||
o.default = 'cubic';
|
||
o.depends('type', 'tuic');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'tuic_udp_relay_mode', _('UDP relay mode'),
|
||
_('UDP packet relay mode.'));
|
||
o.value('', _('Default'));
|
||
o.value('native', _('Native'));
|
||
o.value('quic', _('QUIC'));
|
||
o.depends('type', 'tuic');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'tuic_udp_over_stream', _('UDP over stream'),
|
||
_('This is the TUIC port of the UDP over TCP protocol, designed to provide a QUIC stream based UDP relay mode that TUIC does not provide.'));
|
||
o.depends({'type': 'tuic','tuic_udp_relay_mode': ''});
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'tuic_enable_zero_rtt', _('Enable 0-RTT handshake'),
|
||
_('Enable 0-RTT QUIC connection handshake on the client side. This is not impacting much on the performance, as the protocol is fully multiplexed.<br/>' +
|
||
'Disabling this is highly recommended, as it is vulnerable to replay attacks.'));
|
||
o.depends('type', 'tuic');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'tuic_heartbeat', _('Heartbeat interval'),
|
||
_('Interval for sending heartbeat packets for keeping the connection alive (in seconds).'));
|
||
o.datatype = 'uinteger';
|
||
o.default = '10';
|
||
o.depends('type', 'tuic');
|
||
o.modalonly = true;
|
||
/* Tuic config end */
|
||
|
||
/* VMess / VLESS config start */
|
||
o = s.option(form.ListValue, 'vless_flow', _('Flow'));
|
||
o.value('', _('None'));
|
||
o.value('xtls-rprx-vision');
|
||
o.depends('type', 'vless');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'vmess_alterid', _('Alter ID'),
|
||
_('Legacy protocol support (VMess MD5 Authentication) is provided for compatibility purposes only, use of alterId > 1 is not recommended.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'vmess');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'vmess_encrypt', _('Encrypt method'));
|
||
o.value('auto');
|
||
o.value('none');
|
||
o.value('zero');
|
||
o.value('aes-128-gcm');
|
||
o.value('chacha20-poly1305');
|
||
o.default = 'auto';
|
||
o.depends('type', 'vmess');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'vmess_global_padding', _('Global padding'),
|
||
_('Protocol parameter. Will waste traffic randomly if enabled (enabled by default in v2ray and cannot be disabled).'));
|
||
o.default = o.enabled;
|
||
o.depends('type', 'vmess');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'vmess_authenticated_length', _('Authenticated length'),
|
||
_('Protocol parameter. Enable length block encryption.'));
|
||
o.depends('type', 'vmess');
|
||
o.modalonly = true;
|
||
/* VMess config end */
|
||
|
||
/* Transport config start */
|
||
o = s.option(form.ListValue, 'transport', _('Transport'),
|
||
_('No TCP transport, plain HTTP is merged into the HTTP transport.'));
|
||
o.value('', _('None'));
|
||
o.value('grpc', _('gRPC'));
|
||
o.value('http', _('HTTP'));
|
||
o.value('httpupgrade', _('HTTPUpgrade'));
|
||
o.value('quic', _('QUIC'));
|
||
o.value('ws', _('WebSocket'));
|
||
o.value('xhttp', _('XHTTP'));
|
||
o.depends('type', 'trojan');
|
||
o.depends('type', 'vless');
|
||
o.depends('type', 'vmess');
|
||
o.onchange = function(ev, section_id, value) {
|
||
let desc = this.map.findElement('id', 'cbid.homeproxy.%s.transport'.format(section_id)).nextElementSibling;
|
||
if (value === 'http')
|
||
desc.innerHTML = _('TLS is not enforced. If TLS is not configured, plain HTTP 1.1 is used.');
|
||
else if (value === 'quic')
|
||
desc.innerHTML = _('No additional encryption support: It\'s basically duplicate encryption.');
|
||
else
|
||
desc.innerHTML = _('No TCP transport, plain HTTP is merged into the HTTP transport.');
|
||
|
||
let tls = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild;
|
||
if ((value === 'http' && tls.checked) || (value === 'grpc' && !features.with_grpc)) {
|
||
this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML =
|
||
_('Specifies the period of time (in seconds) after which a health check will be performed using a ping frame if no frames have been received on the connection.<br/>' +
|
||
'Please note that a ping response is considered a received frame, so if there is no other traffic on the connection, the health check will be executed every interval.');
|
||
|
||
this.map.findElement('id', 'cbid.homeproxy.%s.http_ping_timeout'.format(section_id)).nextElementSibling.innerHTML =
|
||
_('Specifies the timeout duration (in seconds) after sending a PING frame, within which a response must be received.<br/>' +
|
||
'If a response to the PING frame is not received within the specified timeout duration, the connection will be closed.');
|
||
} else if (value === 'grpc' && features.with_grpc) {
|
||
this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML =
|
||
_('If the transport doesn\'t see any activity after a duration of this time (in seconds), it pings the client to check if the connection is still active.');
|
||
|
||
this.map.findElement('id', 'cbid.homeproxy.%s.http_ping_timeout'.format(section_id)).nextElementSibling.innerHTML =
|
||
_('The timeout (in seconds) that after performing a keepalive check, the client will wait for activity. If no activity is detected, the connection will be closed.');
|
||
}
|
||
}
|
||
o.modalonly = true;
|
||
|
||
/* gRPC config start */
|
||
o = s.option(form.Value, 'grpc_servicename', _('gRPC service name'));
|
||
o.depends('transport', 'grpc');
|
||
o.modalonly = true;
|
||
|
||
if (features.with_grpc) {
|
||
o = s.option(form.Flag, 'grpc_permit_without_stream', _('gRPC permit without stream'),
|
||
_('If enabled, the client transport sends keepalive pings even with no active connections.'));
|
||
o.depends('transport', 'grpc');
|
||
o.modalonly = true;
|
||
}
|
||
/* gRPC config end */
|
||
|
||
/* HTTP(Upgrade) config start */
|
||
o = s.option(form.DynamicList, 'http_host', _('Host'));
|
||
o.datatype = 'hostname';
|
||
o.depends('transport', 'http');
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'httpupgrade_host', _('Host'));
|
||
o.datatype = 'hostname';
|
||
o.depends('transport', 'httpupgrade');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'http_path', _('Path'));
|
||
o.depends('transport', 'http');
|
||
o.depends('transport', 'httpupgrade');
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'http_method', _('Method'));
|
||
o.value('GET', _('GET'));
|
||
o.value('PUT', _('PUT'));
|
||
o.depends('transport', 'http');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'http_idle_timeout', _('Idle timeout'),
|
||
_('Specifies the period of time (in seconds) after which a health check will be performed using a ping frame if no frames have been received on the connection.<br/>' +
|
||
'Please note that a ping response is considered a received frame, so if there is no other traffic on the connection, the health check will be executed every interval.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('transport', 'grpc');
|
||
o.depends({'transport': 'http', 'tls': '1'});
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'http_ping_timeout', _('Ping timeout'),
|
||
_('Specifies the timeout duration (in seconds) after sending a PING frame, within which a response must be received.<br/>' +
|
||
'If a response to the PING frame is not received within the specified timeout duration, the connection will be closed.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('transport', 'grpc');
|
||
o.depends({'transport': 'http', 'tls': '1'});
|
||
o.modalonly = true;
|
||
/* HTTP config end */
|
||
|
||
/* WebSocket config start */
|
||
o = s.option(form.Value, 'ws_host', _('Host'));
|
||
o.depends('transport', 'ws');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'ws_path', _('Path'));
|
||
o.depends('transport', 'ws');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'websocket_early_data', _('Early data'),
|
||
_('Allowed payload size is in the request.'));
|
||
o.datatype = 'uinteger';
|
||
o.value('2048');
|
||
o.depends('transport', 'ws');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'websocket_early_data_header', _('Early data header name'));
|
||
o.value('Sec-WebSocket-Protocol');
|
||
o.depends('transport', 'ws');
|
||
o.modalonly = true;
|
||
/* WebSocket config end */
|
||
|
||
/* XHTTP config start */
|
||
o = s.option(form.ListValue, 'xhttp_mode', _('XHTTP mode'));
|
||
o.value('auto', _('Auto'));
|
||
o.value('packet-up', _('Packet up'));
|
||
o.value('packet-down', _('Packet down'));
|
||
o.value('stream-up', _('Stream up'));
|
||
o.value('stream-down', _('Stream down'));
|
||
o.value('bidi', _('Bidi'));
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'xhttp_headers', _('Extra headers'),
|
||
_('JSON object, e.g. {"X-Header": "value"}. Leave empty for none.'));
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'xhttp_padding_bytes', _('Padding bytes'),
|
||
_('Random padding length range, e.g. 100-1000. Leave empty for the core default.'));
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'xhttp_sc_max_each_post_bytes', _('Max bytes per POST'),
|
||
_('packet-up mode: max body size of each upload POST, e.g. 1000000-1000000.'));
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'xhttp_sc_min_posts_interval_ms', _('Min POST interval (ms)'),
|
||
_('packet-up mode: minimum interval between upload POSTs in ms, e.g. 30-30.'));
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
/* XHTTP split download ("dl=h2"/"dl=h3"): the download direction may use a
|
||
* separate host/path/server and TLS (different SNI/ALPN) from the upload.
|
||
* Leave these empty to use a single bidirectional stream. */
|
||
o = s.option(form.Value, 'xhttp_download_host', _('Download host'),
|
||
_('Split download: host (Host header / :authority) for the download direction. Leave empty to reuse the upload host.'));
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'xhttp_download_path', _('Download path'),
|
||
_('Split download: path for the download direction. Leave empty to reuse the upload path.'));
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'xhttp_download_server', _('Download server'),
|
||
_('Optional separate server address for the download direction. Empty reuses the main server (works on hiddify-core; set explicitly for sing-box).'));
|
||
o.datatype = 'host';
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'xhttp_download_port', _('Download port'));
|
||
o.datatype = 'port';
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'xhttp_download_sni', _('Download TLS server name'),
|
||
_('TLS SNI for the download direction (often a different CDN/edge host than the upload).'));
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'xhttp_download_alpn', _('Download ALPN'));
|
||
o.value('', _('Default'));
|
||
o.value('h2');
|
||
o.value('h3');
|
||
o.value('http/1.1');
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'xhttp_download_insecure', _('Download allow insecure'),
|
||
_('Allow insecure TLS for the download direction. Use only for testing.'));
|
||
o.default = o.disabled;
|
||
o.depends('transport', 'xhttp');
|
||
o.modalonly = true;
|
||
/* XHTTP config end */
|
||
|
||
o = s.option(form.ListValue, 'packet_encoding', _('Packet encoding'));
|
||
o.value('', _('none'));
|
||
o.value('packetaddr', _('packet addr (v2ray-core v5+)'));
|
||
o.value('xudp', _('Xudp (Xray-core)'));
|
||
o.depends('type', 'vless');
|
||
o.depends('type', 'vmess');
|
||
o.modalonly = true;
|
||
/* Transport config end */
|
||
|
||
/* Wireguard config start */
|
||
o = s.option(form.DynamicList, 'wireguard_local_address', _('Local address'),
|
||
_('List of IP (v4 or v6) addresses prefixes to be assigned to the interface.'));
|
||
o.datatype = 'cidr';
|
||
o.depends('type', 'wireguard');
|
||
o.depends('type', 'amneziawg');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'wireguard_private_key', _('Private key'),
|
||
_('WireGuard requires base64-encoded private keys.'));
|
||
o.password = true;
|
||
o.depends('type', 'wireguard');
|
||
o.depends('type', 'amneziawg');
|
||
o.validate = L.bind(hp.validateBase64Key, this, 44);
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'wireguard_peer_public_key', _('Peer pubkic key'),
|
||
_('WireGuard peer public key.'));
|
||
o.depends('type', 'wireguard');
|
||
o.depends('type', 'amneziawg');
|
||
o.validate = L.bind(hp.validateBase64Key, this, 44);
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'wireguard_pre_shared_key', _('Pre-shared key'),
|
||
_('WireGuard pre-shared key.'));
|
||
o.password = true;
|
||
o.depends('type', 'wireguard');
|
||
o.depends('type', 'amneziawg');
|
||
o.validate = L.bind(hp.validateBase64Key, this, 44);
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.DynamicList, 'wireguard_reserved', _('Reserved field bytes'));
|
||
o.datatype = 'integer';
|
||
o.depends('type', 'wireguard');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'wireguard_mtu', _('MTU'));
|
||
o.datatype = 'range(0,9000)';
|
||
o.placeholder = '1408';
|
||
o.depends('type', 'wireguard');
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'wireguard_persistent_keepalive_interval', _('Persistent keepalive interval'),
|
||
_('In seconds. Disabled by default.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'wireguard');
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
/* Wireguard config end */
|
||
|
||
/* AmneziaWG config start */
|
||
o = s.option(form.Value, 'amnezia_jc', _('Jc'),
|
||
_('Junk packet count.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'amneziawg');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_jmin', _('Jmin'),
|
||
_('Junk packet minimum size.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'amneziawg');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_jmax', _('Jmax'),
|
||
_('Junk packet maximum size.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'amneziawg');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_s1', _('S1'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'amneziawg');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_s2', _('S2'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'amneziawg');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_s3', _('S3'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_s4', _('S4'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_h1', _('H1'),
|
||
_('Magic header range, e.g. 426560850-1096521767'));
|
||
o.depends('type', 'amneziawg');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_h2', _('H2'),
|
||
_('Magic header range, e.g. 1603445073-1836768565'));
|
||
o.depends('type', 'amneziawg');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_h3', _('H3'),
|
||
_('Magic header range, e.g. 2047861992-2141668339'));
|
||
o.depends('type', 'amneziawg');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_h4', _('H4'),
|
||
_('Magic header range, e.g. 2141792848-2142674170'));
|
||
o.depends('type', 'amneziawg');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_i1', _('I1'),
|
||
_('Optional cookie byte sequence in <b 0x...> format.'));
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_i2', _('I2'));
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_i3', _('I3'));
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_i4', _('I4'));
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_i5', _('I5'));
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_j1', _('J1'));
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_j2', _('J2'));
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_j3', _('J3'));
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'amnezia_itime', _('ITime'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('type', 'amneziawg');
|
||
o.modalonly = true;
|
||
/* AmneziaWG config end */
|
||
|
||
/* Mux config start */
|
||
o = s.option(form.Flag, 'multiplex', _('Multiplex'));
|
||
o.depends('type', 'shadowsocks');
|
||
o.depends('type', 'trojan');
|
||
o.depends('type', 'vless');
|
||
o.depends('type', 'vmess');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'multiplex_protocol', _('Protocol'),
|
||
_('Multiplex protocol.'));
|
||
o.value('h2mux');
|
||
o.value('smux');
|
||
o.value('yamux');
|
||
o.default = 'h2mux';
|
||
o.depends('multiplex', '1');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'multiplex_max_connections', _('Maximum connections'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('multiplex', '1');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'multiplex_min_streams', _('Minimum streams'),
|
||
_('Minimum multiplexed streams in a connection before opening a new connection.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('multiplex', '1');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'multiplex_max_streams', _('Maximum streams'),
|
||
_('Maximum multiplexed streams in a connection before opening a new connection.<br/>' +
|
||
'Conflict with <code>%s</code> and <code>%s</code>.').format(
|
||
_('Maximum connections'), _('Minimum streams')));
|
||
o.datatype = 'uinteger';
|
||
o.depends({'multiplex': '1', 'multiplex_max_connections': '', 'multiplex_min_streams': ''});
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'multiplex_padding', _('Enable padding'));
|
||
o.depends('multiplex', '1');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'multiplex_brutal', _('Enable TCP Brutal'),
|
||
_('Enable TCP Brutal congestion control algorithm'));
|
||
o.depends('multiplex', '1');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'multiplex_brutal_down', _('Download bandwidth'),
|
||
_('Download bandwidth in Mbps.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('multiplex_brutal', '1');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'multiplex_brutal_up', _('Upload bandwidth'),
|
||
_('Upload bandwidth in Mbps.'));
|
||
o.datatype = 'uinteger';
|
||
o.depends('multiplex_brutal', '1');
|
||
o.modalonly = true;
|
||
/* Mux config end */
|
||
|
||
/* TLS config start */
|
||
o = s.option(form.Flag, 'tls', _('TLS'));
|
||
o.depends('type', 'anytls');
|
||
o.depends('type', 'http');
|
||
o.depends('type', 'hysteria');
|
||
o.depends('type', 'hysteria2');
|
||
o.depends('type', 'naive');
|
||
o.depends('type', 'shadowtls');
|
||
o.depends('type', 'trojan');
|
||
o.depends('type', 'tuic');
|
||
o.depends('type', 'vless');
|
||
o.depends('type', 'vmess');
|
||
o.validate = function(section_id, _value) {
|
||
if (section_id) {
|
||
let type = this.map.lookupOption('type', section_id)[0].formvalue(section_id);
|
||
let tls = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild;
|
||
|
||
if (['anytls', 'hysteria', 'hysteria2', 'shadowtls', 'tuic'].includes(type)) {
|
||
tls.checked = true;
|
||
tls.disabled = true;
|
||
} else {
|
||
tls.disabled = null;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'tls_sni', _('TLS SNI'),
|
||
_('Used to verify the hostname on the returned certificates unless insecure is given.'));
|
||
o.depends('tls', '1');
|
||
o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'});
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.DynamicList, 'tls_alpn', _('TLS ALPN'),
|
||
_('List of supported application level protocols, in order of preference.'));
|
||
o.depends('tls', '1');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'tls_insecure', _('Allow insecure'),
|
||
_('Allow insecure connection at TLS client.') +
|
||
'<br/>' +
|
||
_('This is <strong>DANGEROUS</strong>, your traffic is almost like <strong>PLAIN TEXT</strong>! Use at your own risk!'));
|
||
o.depends('tls', '1');
|
||
o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'});
|
||
o.onchange = allowInsecureConfirm;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'tls_min_version', _('Minimum TLS version'),
|
||
_('The minimum TLS version that is acceptable.'));
|
||
o.value('', _('default'));
|
||
for (let i of hp.tls_versions)
|
||
o.value(i);
|
||
o.depends('tls', '1');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'tls_max_version', _('Maximum TLS version'),
|
||
_('The maximum TLS version that is acceptable.'));
|
||
o.value('', _('default'));
|
||
for (let i of hp.tls_versions)
|
||
o.value(i);
|
||
o.depends('tls', '1');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(hp.CBIStaticList, 'tls_cipher_suites', _('Cipher suites'),
|
||
_('The elliptic curves that will be used in an ECDHE handshake, in preference order. If empty, the default will be used.'));
|
||
for (let i of hp.tls_cipher_suites)
|
||
o.value(i);
|
||
o.depends('tls', '1');
|
||
o.optional = true;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'tls_self_sign', _('Append self-signed certificate'),
|
||
_('If you have the root certificate, use this option instead of allowing insecure.'));
|
||
o.depends('tls_insecure', '0');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'tls_cert_path', _('Certificate path'),
|
||
_('The path to the server certificate, in PEM format.'));
|
||
o.value('/etc/homeproxy/certs/client_ca.pem');
|
||
o.depends('tls_self_sign', '1');
|
||
o.validate = hp.validateCertificatePath;
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Button, '_upload_cert', _('Upload certificate'),
|
||
_('<strong>Save your configuration before uploading files!</strong>'));
|
||
o.inputstyle = 'action';
|
||
o.inputtitle = _('Upload...');
|
||
o.depends({'tls_self_sign': '1', 'tls_cert_path': '/etc/homeproxy/certs/client_ca.pem'});
|
||
o.onclick = L.bind(hp.uploadCertificate, this, _('certificate'), 'client_ca');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'tls_ech', _('Enable ECH'),
|
||
_('ECH (Encrypted Client Hello) is a TLS extension that allows a client to encrypt the first part of its ClientHello message.'));
|
||
o.depends('tls', '1');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'tls_ech_config_path', _('ECH config path'),
|
||
_('The path to the ECH config, in PEM format. If empty, load from DNS will be attempted.'));
|
||
o.value('/etc/homeproxy/certs/client_ech_conf.pem');
|
||
o.depends('tls_ech', '1');
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Button, '_upload_ech_config', _('Upload ECH config'),
|
||
_('<strong>Save your configuration before uploading files!</strong>'));
|
||
o.inputstyle = 'action';
|
||
o.inputtitle = _('Upload...');
|
||
o.depends({'tls_ech': '1', 'tls_ech_config_path': '/etc/homeproxy/certs/client_ech_conf.pem'});
|
||
o.onclick = L.bind(hp.uploadCertificate, this, _('ECH config'), 'client_ech_conf');
|
||
o.modalonly = true;
|
||
|
||
if (features.with_utls) {
|
||
o = s.option(form.ListValue, 'tls_utls', _('uTLS fingerprint'),
|
||
_('uTLS is a fork of "crypto/tls", which provides ClientHello fingerprinting resistance.'));
|
||
o.value('', _('Disable'));
|
||
o.value('360');
|
||
o.value('android');
|
||
o.value('chrome');
|
||
o.value('edge');
|
||
o.value('firefox');
|
||
o.value('ios');
|
||
o.value('qq');
|
||
o.value('random');
|
||
o.value('randomized');
|
||
o.value('safari');
|
||
o.depends({'tls': '1', 'type': /^((?!hysteria2?|tuic$).)+$/});
|
||
o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'});
|
||
o.validate = function(section_id, value) {
|
||
if (section_id) {
|
||
let tls_reality = this.map.findElement('id', 'cbid.homeproxy.%s.tls_reality'.format(section_id)).firstElementChild;
|
||
if (tls_reality.checked && !value)
|
||
return _('Expecting: %s').format(_('non-empty value'));
|
||
|
||
let vless_flow = this.map.lookupOption('vless_flow', section_id)[0].formvalue(section_id);
|
||
if ((tls_reality.checked || vless_flow) && ['360', 'android'].includes(value))
|
||
return _('Unsupported fingerprint!');
|
||
}
|
||
|
||
return true;
|
||
}
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'tls_reality', _('REALITY'));
|
||
o.depends({'tls': '1', 'type': 'anytls'});
|
||
o.depends({'tls': '1', 'type': 'vless'});
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'tls_reality_public_key', _('REALITY public key'));
|
||
o.password = true;
|
||
o.depends('tls_reality', '1');
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'tls_reality_short_id', _('REALITY short ID'));
|
||
o.password = true;
|
||
o.depends('tls_reality', '1');
|
||
o.modalonly = true;
|
||
}
|
||
/* TLS config end */
|
||
|
||
/* Extra settings start */
|
||
o = s.option(form.Flag, 'tcp_fast_open', _('TCP fast open'));
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'tcp_multi_path', _('MultiPath TCP'));
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'udp_fragment', _('UDP Fragment'),
|
||
_('Enable UDP fragmentation.'));
|
||
o.modalonly = true;
|
||
|
||
o = s.option(widgets.DeviceSelect, 'bind_interface', _('Binded interface'),
|
||
_('The network interface to bind to.'));
|
||
o.multiple = false;
|
||
o.noaliases = true;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Flag, 'udp_over_tcp', _('UDP over TCP'),
|
||
_('Enable the SUoT protocol, requires server support. Conflict with multiplex.'));
|
||
o.depends('type', 'socks');
|
||
o.depends({'type': 'shadowsocks', 'multiplex': '0'});
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'udp_over_tcp_version', _('SUoT version'));
|
||
o.value('1', _('v1'));
|
||
o.value('2', _('v2'));
|
||
o.default = '2';
|
||
o.depends('udp_over_tcp', '1');
|
||
o.modalonly = true;
|
||
/* Extra settings end */
|
||
|
||
return s;
|
||
}
|
||
|
||
return view.extend({
|
||
load() {
|
||
return Promise.all([
|
||
uci.load('homeproxy'),
|
||
hp.getBuiltinFeatures(),
|
||
/* Zapret strategy candidates (shipped read-only); null if absent → builtin fallback used */
|
||
L.resolveDefault(fs.read('/etc/homeproxy/zapret_candidates.json'), null)
|
||
]);
|
||
},
|
||
|
||
render(data) {
|
||
let m, s, o, ss, so;
|
||
let main_node = uci.get(data[0], 'config', 'main_node');
|
||
let routing_mode = uci.get(data[0], 'config', 'routing_mode');
|
||
let features = data[1];
|
||
|
||
/* Parse the Zapret candidate list once; each entry = {name, args, group}.
|
||
* 'args' is the stable key (used for match/apply); 'name' is display-only. */
|
||
let zapret_candidates = null;
|
||
try {
|
||
const parsed = JSON.parse(data[2] || 'null');
|
||
if (parsed && Array.isArray(parsed.candidates) && parsed.candidates.length)
|
||
zapret_candidates = parsed.candidates;
|
||
} catch (e) { /* fall back to builtin below */ }
|
||
|
||
/* Cache subscription information, it will be called multiple times */
|
||
let subinfo = [];
|
||
for (let suburl of (uci.get(data[0], 'subscription', 'subscription_url') || [])) {
|
||
const url = new URL(suburl);
|
||
const urlhash = hp.calcStringMD5(suburl.replace(/#.*$/, ''));
|
||
const title = url.hash ? decodeURIComponent(url.hash.slice(1)) : url.hostname;
|
||
subinfo.push({ 'hash': urlhash, 'title': title });
|
||
}
|
||
|
||
m = new form.Map('homeproxy', _('Edit nodes'));
|
||
|
||
s = m.section(form.NamedSection, 'subscription', 'homeproxy');
|
||
|
||
/* Node settings start */
|
||
/* User nodes start */
|
||
s.tab('node', _('Nodes'));
|
||
o = s.taboption('node', form.SectionValue, '_node', form.GridSection, 'node');
|
||
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
|
||
ss.addremove = true;
|
||
ss.filter = function(section_id) {
|
||
for (let info of subinfo)
|
||
if (info.hash === uci.get(data[0], section_id, 'grouphash'))
|
||
return false;
|
||
|
||
return true;
|
||
}
|
||
/* Import subscription links start */
|
||
/* Thanks to luci-app-shadowsocks-libev */
|
||
ss.handleLinkImport = function() {
|
||
let textarea = new ui.Textarea();
|
||
ui.showModal(_('Import share links'), [
|
||
E('p', _('Support Amnezia (vpn://), Hysteria, Mieru, NaïveProxy (naive://), Shadowsocks (ss://), SSH, Trojan, v2rayN (VMess), WireGuard, and VLESS (vless://) online configuration delivery standard.')),
|
||
textarea.render(),
|
||
E('div', { class: 'right' }, [
|
||
E('button', {
|
||
class: 'btn',
|
||
click: ui.hideModal
|
||
}, [ _('Cancel') ]),
|
||
'',
|
||
E('button', {
|
||
class: 'btn cbi-button-action',
|
||
click: ui.createHandlerFn(this, function() {
|
||
let input_links = textarea.getValue().trim().split('\n')
|
||
.map(l => l.trim()).filter(Boolean);
|
||
if (!input_links.length)
|
||
return ui.hideModal();
|
||
|
||
/* Remove duplicates */
|
||
input_links = [...new Set(input_links)];
|
||
|
||
let allow_insecure = uci.get(data[0], 'subscription', 'allow_insecure');
|
||
let packet_encoding = uci.get(data[0], 'subscription', 'packet_encoding');
|
||
const total = input_links.length;
|
||
|
||
const vpnLinks = input_links.filter(l => l.startsWith('vpn://'));
|
||
const uriLinks = input_links.filter(l => !l.startsWith('vpn://'));
|
||
|
||
return Promise.all(vpnLinks.map(l => parseVpnLink(l).catch(() => null)))
|
||
.then(vpnConfigs => {
|
||
const configs = [
|
||
...vpnConfigs.filter(Boolean),
|
||
...uriLinks.map(l => parseShareLink(l, features)).filter(Boolean)
|
||
];
|
||
|
||
let imported_node = 0;
|
||
configs.forEach((config) => {
|
||
if (config.tls === '1' && allow_insecure === '1')
|
||
config.tls_insecure = '1';
|
||
if (['vless', 'vmess'].includes(config.type))
|
||
config.packet_encoding = packet_encoding;
|
||
|
||
let nameHash = hp.calcStringMD5(config.label);
|
||
let sid = uci.add(data[0], 'node', nameHash);
|
||
Object.keys(config).forEach((k) => {
|
||
uci.set(data[0], sid, k, config[k]);
|
||
});
|
||
imported_node++;
|
||
});
|
||
|
||
if (imported_node === 0)
|
||
ui.addNotification(null, E('p', _('No valid share link found.')));
|
||
else
|
||
ui.addNotification(null, E('p', _('Successfully imported %s nodes of total %s.').format(
|
||
imported_node, total)));
|
||
|
||
return uci.save()
|
||
.then(L.bind(this.map.load, this.map))
|
||
.then(L.bind(this.map.reset, this.map))
|
||
.then(L.ui.hideModal)
|
||
.catch(() => {});
|
||
});
|
||
})
|
||
}, [ _('Import') ])
|
||
])
|
||
])
|
||
}
|
||
ss.handleConfImport = function() {
|
||
const fileInput = E('input', { type: 'file', accept: '.conf', style: 'display:block;margin:8px 0' });
|
||
ui.showModal(_('Import .conf file'), [
|
||
E('p', _('Select a WireGuard or AmneziaWG .conf file.')),
|
||
fileInput,
|
||
E('div', { class: 'right' }, [
|
||
E('button', { class: 'btn', click: ui.hideModal }, [ _('Cancel') ]),
|
||
' ',
|
||
E('button', {
|
||
class: 'btn cbi-button-action',
|
||
click: ui.createHandlerFn(this, function() {
|
||
const file = fileInput.files[0];
|
||
if (!file) return ui.hideModal();
|
||
|
||
return new Promise((resolve) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => resolve(ev.target.result);
|
||
reader.readAsText(file);
|
||
}).then((text) => {
|
||
const config = parseWireGuardConf(text);
|
||
if (!config) {
|
||
ui.hideModal();
|
||
return ui.addNotification(null, E('p', _('No valid WireGuard/AmneziaWG config found.')));
|
||
}
|
||
|
||
const existingLabels = new Set(
|
||
uci.sections(data[0], 'node').map(s => s.label).filter(Boolean)
|
||
);
|
||
if (existingLabels.has(config.label)) {
|
||
const base = config.label;
|
||
let n = 2;
|
||
while (existingLabels.has(base + '-' + n)) n++;
|
||
config.label = base + '-' + n;
|
||
}
|
||
|
||
const sid = uci.add(data[0], 'node');
|
||
for (const [k, v] of Object.entries(config))
|
||
if (v != null) uci.set(data[0], sid, k, Array.isArray(v) ? v : String(v));
|
||
|
||
return uci.save()
|
||
.then(L.bind(this.map.load, this.map))
|
||
.then(L.bind(this.map.reset, this.map))
|
||
.then(L.ui.hideModal)
|
||
.then(() => ui.addNotification(null, E('p', _('Successfully imported node: %s').format(config.label))))
|
||
.catch(() => {});
|
||
});
|
||
})
|
||
}, [ _('Import') ])
|
||
])
|
||
]);
|
||
}
|
||
|
||
ss.renderSectionAdd = function(/* ... */) {
|
||
let el = form.GridSection.prototype.renderSectionAdd.apply(this, arguments),
|
||
nameEl = el.querySelector('.cbi-section-create-name');
|
||
|
||
ui.addValidator(nameEl, 'uciname', true, (v) => {
|
||
let button = el.querySelector('.cbi-section-create > .cbi-button-add');
|
||
let uciconfig = this.uciconfig || this.map.config;
|
||
|
||
if (!v) {
|
||
button.disabled = true;
|
||
return true;
|
||
} else if (uci.get(uciconfig, v)) {
|
||
button.disabled = true;
|
||
return _('Expecting: %s').format(_('unique UCI identifier'));
|
||
} else {
|
||
button.disabled = null;
|
||
return true;
|
||
}
|
||
}, 'blur', 'keyup');
|
||
|
||
el.appendChild(E('button', {
|
||
'class': 'cbi-button cbi-button-add',
|
||
'title': _('Import share links'),
|
||
'click': ui.createHandlerFn(this, 'handleLinkImport')
|
||
}, [ _('Import share links') ]));
|
||
|
||
el.appendChild(E('button', {
|
||
'class': 'cbi-button cbi-button-add',
|
||
'title': _('Import .conf file'),
|
||
'click': ui.createHandlerFn(this, 'handleConfImport')
|
||
}, [ _('Import .conf') ]));
|
||
|
||
return el;
|
||
}
|
||
/* Import subscription links end */
|
||
/* User nodes end */
|
||
|
||
/* Subscription nodes start */
|
||
for (const info of subinfo) {
|
||
s.tab('sub_' + info.hash, _('Sub (%s)').format(info.title));
|
||
o = s.taboption('sub_' + info.hash, form.SectionValue, '_sub_' + info.hash, form.GridSection, 'node');
|
||
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
|
||
ss.filter = function(section_id) {
|
||
return (uci.get(data[0], section_id, 'grouphash') === info.hash);
|
||
}
|
||
}
|
||
/* Subscription nodes end */
|
||
/* Node settings end */
|
||
|
||
/* Subscriptions settings start */
|
||
s.tab('subscription', _('Subscriptions'));
|
||
|
||
o = s.taboption('subscription', form.Flag, 'auto_update', _('Auto update'),
|
||
_('Auto update subscriptions and geodata.'));
|
||
o.rmempty = false;
|
||
|
||
o = s.taboption('subscription', form.ListValue, 'auto_update_time', _('Update time'));
|
||
for (let i = 0; i < 24; i++)
|
||
o.value(i, i + ':00');
|
||
o.default = '2';
|
||
o.depends('auto_update', '1');
|
||
|
||
o = s.taboption('subscription', form.Flag, 'update_via_proxy', _('Update via proxy'),
|
||
_('Update subscriptions via proxy.'));
|
||
o.rmempty = false;
|
||
|
||
o = s.taboption('subscription', form.DynamicList, 'subscription_url', _('Subscription URL-s'),
|
||
_('Support Hysteria, Shadowsocks, Trojan, v2rayN (VMess), and XTLS (VLESS) online configuration delivery standard.'));
|
||
o.validate = function(section_id, value) {
|
||
if (section_id && value) {
|
||
try {
|
||
let url = new URL(value);
|
||
if (!url.hostname)
|
||
return _('Expecting: %s').format(_('valid URL'));
|
||
}
|
||
catch(e) {
|
||
return _('Expecting: %s').format(_('valid URL'));
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
o = s.taboption('subscription', form.ListValue, 'filter_nodes', _('Filter nodes'),
|
||
_('Drop/keep specific nodes from subscriptions.'));
|
||
o.value('disabled', _('Disable'));
|
||
o.value('blacklist', _('Blacklist mode'));
|
||
o.value('whitelist', _('Whitelist mode'));
|
||
o.default = 'disabled';
|
||
o.rmempty = false;
|
||
|
||
o = s.taboption('subscription', form.DynamicList, 'filter_keywords', _('Filter keywords'),
|
||
_('Drop/keep nodes that contain the specific keywords. <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions">Regex</a> is supported.'));
|
||
o.depends({'filter_nodes': 'disabled', '!reverse': true});
|
||
o.rmempty = false;
|
||
|
||
o = s.taboption('subscription', form.Value, 'user_agent', _('User-Agent'));
|
||
o.placeholder = 'Wget/1.21 (HomeProxy, like v2rayN)';
|
||
|
||
o = s.taboption('subscription', form.Flag, 'allow_insecure', _('Allow insecure'),
|
||
_('Allow insecure connection by default when add nodes from subscriptions.') +
|
||
'<br/>' +
|
||
_('This is <strong>DANGEROUS</strong>, your traffic is almost like <strong>PLAIN TEXT</strong>! Use at your own risk!'));
|
||
o.rmempty = false;
|
||
o.onchange = allowInsecureConfirm;
|
||
|
||
o = s.taboption('subscription', form.ListValue, 'packet_encoding', _('Default packet encoding'));
|
||
o.value('', _('none'));
|
||
o.value('packetaddr', _('packet addr (v2ray-core v5+)'));
|
||
o.value('xudp', _('Xudp (Xray-core)'));
|
||
|
||
o = s.taboption('subscription', form.Button, '_save_subscriptions', _('Save subscriptions settings'),
|
||
_('NOTE: Save current settings before updating subscriptions.'));
|
||
o.inputstyle = 'apply';
|
||
o.inputtitle = _('Save current settings');
|
||
o.onclick = function() {
|
||
return this.map.save(null, true).then(() => {
|
||
ui.changes.apply(true);
|
||
});
|
||
}
|
||
|
||
o = s.taboption('subscription', form.Button, '_update_subscriptions', _('Update nodes from subscriptions'));
|
||
o.inputstyle = 'apply';
|
||
o.inputtitle = function(section_id) {
|
||
let sublist = uci.get(data[0], section_id, 'subscription_url') || [];
|
||
if (sublist.length > 0) {
|
||
return _('Update %s subscriptions').format(sublist.length);
|
||
} else {
|
||
this.readonly = true;
|
||
return _('No subscription available')
|
||
}
|
||
}
|
||
o.onclick = function() {
|
||
ui.showModal(_('Updating subscriptions'), [
|
||
E('p', { 'class': 'spinning' }, _('Fetching nodes, please wait...'))
|
||
]);
|
||
|
||
return fs.exec_direct('/etc/homeproxy/scripts/update_subscriptions.uc').then(() => {
|
||
return location.reload();
|
||
}).catch((err) => {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', _('An error occurred during updating subscriptions: %s').format(err)));
|
||
return this.map.reset();
|
||
});
|
||
}
|
||
|
||
o = s.taboption('subscription', form.Button, '_remove_subscriptions', _('Remove all nodes from subscriptions'));
|
||
o.inputstyle = 'reset';
|
||
o.inputtitle = function() {
|
||
let subnodes = [];
|
||
uci.sections(data[0], 'node', (res) => {
|
||
if (res.grouphash)
|
||
subnodes = subnodes.concat(res['.name'])
|
||
});
|
||
|
||
if (subnodes.length > 0) {
|
||
return _('Remove %s nodes').format(subnodes.length);
|
||
} else {
|
||
this.readonly = true;
|
||
return _('No subscription node');
|
||
}
|
||
}
|
||
o.onclick = function() {
|
||
let subnodes = [];
|
||
uci.sections(data[0], 'node', (res) => {
|
||
if (res.grouphash)
|
||
subnodes = subnodes.concat(res['.name'])
|
||
});
|
||
|
||
for (let i in subnodes)
|
||
uci.remove(data[0], subnodes[i]);
|
||
|
||
if (subnodes.includes(uci.get(data[0], 'config', 'main_node')))
|
||
uci.set(data[0], 'config', 'main_node', 'nil');
|
||
|
||
if (subnodes.includes(uci.get(data[0], 'config', 'main_udp_node')))
|
||
uci.set(data[0], 'config', 'main_udp_node', 'nil');
|
||
|
||
this.inputtitle = _('%s nodes removed').format(subnodes.length);
|
||
this.readonly = true;
|
||
|
||
return this.map.save(null, true);
|
||
}
|
||
/* Subscriptions settings end */
|
||
|
||
/* ByeDPI settings start */
|
||
s.tab('byedpi', _('ByeDPI'));
|
||
let byedpiSV = s.taboption('byedpi', form.SectionValue, '_byedpi', form.NamedSection, 'config', 'homeproxy');
|
||
ss = byedpiSV.subsection;
|
||
ss.anonymous = true;
|
||
ss.addremove = false;
|
||
|
||
o = ss.option(form.Flag, 'byedpi_udp_over_tcp', _('UDP over TCP'),
|
||
_('Wrap UDP traffic in TCP when routing to ByeDPI.'));
|
||
o.default = o.enabled;
|
||
o.rmempty = false;
|
||
|
||
(function() {
|
||
const BYEDPI_PRESETS = [
|
||
/* A — Community-contributed strategies (from issues / community testing) */
|
||
{ name: 'A1 — YouTube 1', args: '-o1 -r-5+se -a1 -At,r,s -d1 -n google.com -Qr -f-1' },
|
||
{ name: 'A2 — YouTube 2', args: '-d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -r1+s -S -a1 -As -d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -S -a1' },
|
||
{ name: 'A3 — General 1', args: '-d1+s -O1 -s29+s -t 5 -An -Ku -a5 -s443+s -d80+s -d443+s -s80+s -s443+s -d53+s -s53+s -d443+s -An' },
|
||
{ name: 'A4 — YouTube 3', args: '-H:"youtube.com googlevideo.com ggpht.com ytimg.com googleapis.com googleusercontent.com youtube-ui.l.google.com yt4.ggpht.com" -o1 -a1 -r-5+se -t6 -n rutube.com -An -f-1 -t8 -n www.google.com -d1 -s1+s -d1+s -s3+s -d6+s -s12+s -d14+s -s20+s -d24+s -s30+s -a1' },
|
||
{ name: 'A5 — General 2', args: '-H:"youtube.com googlevideo.com ytimg.com ggpht.com youtu.be youtubei.googleapis.com" -Kt,h -d1 -s1+s -s3+s -s6+s -s9+s -s12+s -s15+s -s20+s -s30+s -a1 -An -H:"soundcloud.com api.soundcloud.com api-v2.soundcloud.com m.soundcloud.com eventgateway.soundcloud.com api-partners.soundcloud.com api-mobile.soundcloud.com wis.sndcdn.com va.sndcdn.com invite.soundcloud.com events.soundcloud.com" -Kth -Qorig -n "www.google.com" -f-1 -t5 -d1 -s1+s -s3+s -s6+s -s9+s -s12+s -s15+s -s20+s -s30+s -Mh,d,r -An -H:"sndcdn.com a-v2.sndcdn.com cf-hls-media.sndcdn.com cf-media.sndcdn.com cf-preview-media.sndcdn.com cf-hls-opus-media.sndcdn.com i1.sndcdn.com i2.sndcdn.com i3.sndcdn.com i4.sndcdn.com assets.soundcloud.com playback.media-streaming.soundcloud.cloud" -Kth -Qorig -n "www.google.com" -f-1 -t5 -s1 -d2 -Mh,d,r -An -H:"discord.com discord.gg discord.media discordapp.com cdn.discordapp.com media.discordapp.net images-ext-1.discordapp.net images-ext-2.discordapp.net images.discordapp.net gateway.discord.gg status.discord.com api.discord.com discord-attachments-uploads-prd.storage.googleapis.com hcaptcha.com recaptcha.net accounts.google.com appleid.apple.com" -Kth -Qorig -n "www.google.com" -f-1 -t5 -o1 -s1+s -s2+s -s5+s -d3+s -s7+s -s10+s -s15+s -An -Ku' },
|
||
{ name: 'A6 — General 3', args: '-H:"youtube.com googlevideo.com ggpht.com ytimg.com googleapis.com googleusercontent.com youtube-ui.l.google.com yt4.ggpht.com" -o1 -a1 -r-5+se -t6 -n rutube.com -An -H:"cdn.discordapp.com canary.discord.com vdis.gd ptb.discord.com discord-attachments-uploads-prd.storage.googleapis.com discord-activities.com discord.co discord.com discord.design discord.dev discord.gg discord.gift discord.gifts discord.media discord.new discord.store discord.tools discordactivities.com discordapp.com discordapp.net media.discordapp.net images-ext-1.discordapp.net images-ext-2.discordapp.net stable.dl2.discordapp.net discordcdn.com discordmerch.com discordpartygames.com discordsays.com discordsez.com discordstatus.com" -f-1 -t8 -n www.google.com -s1+s -a5 -An -H:"soundcloud.com sndcdn.com soundcloud.app.goo.gl" -f-1 -T0.5 -Ars -d0+sm -At -r1+s -An -f-200 -s2 -s5+hm -t6 -Qr -n wb.ru' },
|
||
/* B — Disorder — most effective on Linux */
|
||
{ name: 'B1 — Disorder Basic', args: '--disorder 1' },
|
||
{ name: 'B2 — Disorder at SNI', args: '--disorder 1+s' },
|
||
{ name: 'B3 — Disorder TLS+HTTP', args: '--proto tls,http --disorder 1' },
|
||
{ name: 'B4 — Split + Disorder', args: '--split 1 --disorder 3' },
|
||
{ name: 'B5 — Disorder + Auto TLS Record', args: '--disorder 1 --auto=torst --tlsrec 1+s' },
|
||
/* C — Fake TTL */
|
||
{ name: 'C1 — Fake TTL=6', args: '--fake -1 --ttl 6' },
|
||
{ name: 'C2 — Fake TTL=8', args: '--fake -1 --ttl 8' },
|
||
{ name: 'C3 — Fake TTL=10', args: '--fake -1 --ttl 10' },
|
||
{ name: 'C4 — Fake TTL=12', args: '--fake -1 --ttl 12' },
|
||
{ name: 'C5 — Fake TTL=15', args: '--fake -1 --ttl 15' },
|
||
/* D — Fake MD5 — Linux-only TCP option */
|
||
{ name: 'D1 — Fake MD5', args: '--fake -1 --md5sig' },
|
||
{ name: 'D2 — Disorder + Fake MD5', args: '--disorder 1 --fake -1 --md5sig' },
|
||
{ name: 'D3 — Fake MD5 TLS+HTTP', args: '--proto tls,http --fake -1 --md5sig' },
|
||
/* E — TLS Record split */
|
||
{ name: 'E1 — TLS Record Split', args: '--tlsrec 1+s' },
|
||
{ name: 'E2 — TLS Record + Auto', args: '--auto=torst --tlsrec 1+s' },
|
||
{ name: 'E3 — TLS Record + Timeout', args: '--auto=torst --timeout 3 --tlsrec 1+s' },
|
||
{ name: 'E4 — Disorder + TLS Record', args: '--disorder 1 --tlsrec 1+s' },
|
||
/* F — OOB */
|
||
{ name: 'F1 — OOB at SNI', args: '--oob 1+s' },
|
||
{ name: 'F2 — OOB at SNI+3', args: '--oob 3+s' },
|
||
{ name: 'F3 — DisoOB at SNI', args: '--disoob 1+s' },
|
||
{ name: 'F4 — DisoOB + Fake MD5', args: '--disoob 1+s --fake -1 --md5sig' },
|
||
/* G — Split */
|
||
{ name: 'G1 — Split at SNI', args: '--split 1+s' },
|
||
{ name: 'G2 — Split at SNI Middle', args: '--split 0+sm' },
|
||
{ name: 'G3 — Split at 2', args: '--split 2' },
|
||
{ name: 'G4 — Split + OOB', args: '--split 1+s --oob 2+s' },
|
||
/* H — HTTP modification */
|
||
{ name: 'H1 — HTTP Host Case Mix', args: '--proto http --mod-http hcsmix' },
|
||
{ name: 'H2 — HTTP Host Double Mix', args: '--proto http --mod-http hcsmix,dcsmix' },
|
||
{ name: 'H3 — HTTP Full Mix', args: '--proto http --mod-http hcsmix,dcsmix,rmspace' },
|
||
{ name: 'H4 — HTTP Mix + Disorder', args: '--proto tls,http --mod-http hcsmix --disorder 1' },
|
||
/* I — Auto-mode */
|
||
{ name: 'I1 — Auto SSL Error Fallback', args: '--fake -1 --ttl 8 --auto=ssl_err --fake -1 --ttl 5' },
|
||
{ name: 'I2 — Auto Reset Fallback', args: '--fake -1 --md5sig --auto=torst --disorder 1' },
|
||
/* J — Fake TLS modification */
|
||
{ name: 'J1 — Random TLS Fake', args: '--fake -1 --fake-tls-mod rand' },
|
||
{ name: 'J2 — Original TLS Fake', args: '--fake -1 --fake-tls-mod orig' },
|
||
/* K — Aggressive combos */
|
||
{ name: 'K1 — Aggressive Split', args: '--split 1+s --disorder 3+s' },
|
||
{ name: 'K2 — Aggressive OOB + MD5', args: '--oob 1+s --disorder 1 --fake -1 --md5sig' },
|
||
{ name: 'K3 — Aggressive DisoOB', args: '--disoob 1+s --disorder 3+s' },
|
||
{ name: 'K4 — Aggressive Combo', args: '--split 1+s --oob 2+s --disorder 3+s' },
|
||
{ name: 'K5 — TLS+HTTP Disorder + Record', args: '--proto tls,http --disorder 1 --tlsrec 1+s' },
|
||
/* L — UDP */
|
||
{ name: 'L1 — UDP Fake', args: '--proto udp --udp-fake 5' },
|
||
{ name: 'L2 — TLS+UDP Fake MD5', args: '--proto tls,udp --fake -1 --md5sig --udp-fake 5' },
|
||
/* M — Full combo */
|
||
{ name: 'M1 — Full TLS Bypass', args: '--proto tls --fake -1 --md5sig --tlsrec 1+s' }
|
||
];
|
||
|
||
/* Groups are matched to presets by the letter prefix of the preset name
|
||
* (A1 → group A, B3 → group B, …). A preset can sit anywhere in the array and
|
||
* still render under its group, in both the dropdown and the test-all table —
|
||
* so adding a strategy is a one-line append, no index bookkeeping. */
|
||
const BYEDPI_GROUPS = [
|
||
{ key: 'A', label: _('Community') },
|
||
{ key: 'B', label: _('Disorder — reorders TCP segments') },
|
||
{ key: 'C', label: _('Fake TTL') },
|
||
{ key: 'D', label: _('Fake MD5') },
|
||
{ key: 'E', label: _('TLS Record Split') },
|
||
{ key: 'F', label: _('OOB') },
|
||
{ key: 'G', label: _('Split') },
|
||
{ key: 'H', label: _('HTTP Modification') },
|
||
{ key: 'I', label: _('Auto-mode') },
|
||
{ key: 'J', label: _('TLS Modification') },
|
||
{ key: 'K', label: _('Aggressive') },
|
||
{ key: 'L', label: _('UDP') },
|
||
{ key: 'M', label: _('Full Combo') },
|
||
];
|
||
|
||
const callByeDPITest = rpc.declare({
|
||
object: 'luci.homeproxy',
|
||
method: 'byedpi_strategy_test',
|
||
params: ['cmd_opts', 'port'],
|
||
expect: { '': {} }
|
||
});
|
||
|
||
const callCurlStatus = rpc.declare({
|
||
object: 'luci.homeproxy',
|
||
method: 'curl_status',
|
||
expect: { '': {} }
|
||
});
|
||
|
||
/* Reference to the Command-options option, set when it's created below.
|
||
* The preset dropdown drives this field through LuCI's own widget API. */
|
||
let cmdOptsOpt = null;
|
||
|
||
function applyPreset(idx) {
|
||
if (!BYEDPI_PRESETS[idx])
|
||
return;
|
||
const val = BYEDPI_PRESETS[idx].args;
|
||
const widget = cmdOptsOpt ? cmdOptsOpt.getUIElement('config') : null;
|
||
if (widget) {
|
||
widget.setValue(val);
|
||
widget.node.dispatchEvent(new Event('change', { bubbles: true }));
|
||
} else {
|
||
/* fallback if the widget isn't registered yet */
|
||
const el = document.querySelector('[name*=".byedpi_cmd_opts"]');
|
||
if (el) {
|
||
el.value = val;
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Preset selector — DummyValue so LuCI never tracks or resets it.
|
||
* It's a picker that fills byedpi_cmd_opts, not a stored value. On every
|
||
* render it pre-selects whichever preset matches the current options, so it
|
||
* reflects truth and survives section re-renders. */
|
||
o = ss.option(form.DummyValue, '_byedpi_preset', _('Strategy preset'));
|
||
o.render = function(option_index, section_id) {
|
||
const cur = (uci.get('homeproxy', 'config', 'byedpi_cmd_opts') || '').trim();
|
||
let curIdx = -1;
|
||
for (let i = 0; i < BYEDPI_PRESETS.length; i++)
|
||
if (BYEDPI_PRESETS[i].args === cur) { curIdx = i; break; }
|
||
|
||
const sel = E('select', {
|
||
'class': 'cbi-input-select',
|
||
'style': 'max-width:100%'
|
||
});
|
||
sel.appendChild(E('option', {
|
||
value: '',
|
||
selected: curIdx < 0 ? '' : null
|
||
}, curIdx < 0 ? _('— custom / select a preset —') : _('— select a preset —')));
|
||
for (let g of BYEDPI_GROUPS) {
|
||
const grp = E('optgroup', { label: g.label });
|
||
BYEDPI_PRESETS.forEach((p, i) => {
|
||
if (p.name.charAt(0) !== g.key) return;
|
||
grp.appendChild(E('option', {
|
||
value: String(i),
|
||
selected: i === curIdx ? '' : null
|
||
}, p.name));
|
||
});
|
||
sel.appendChild(grp);
|
||
}
|
||
sel.addEventListener('change', function() {
|
||
if (sel.value !== '')
|
||
applyPreset(parseInt(sel.value));
|
||
});
|
||
return E('div', { 'class': 'cbi-value' }, [
|
||
E('label', { 'class': 'cbi-value-title' }, _('Strategy preset')),
|
||
E('div', { 'class': 'cbi-value-field' }, [
|
||
sel,
|
||
E('div', { 'class': 'cbi-value-description' },
|
||
_('Grouped by technique. Based on <a href="https://github.com/hufrea/byedpi" target="_blank">hufrea/byedpi</a> and community testing. See also <a href="https://github.com/fatyzzz/Byedpi-Setup" target="_blank">fatyzzz/Byedpi-Setup</a>.'))
|
||
])
|
||
]);
|
||
};
|
||
o.write = function() {};
|
||
|
||
o = ss.option(form.Value, 'byedpi_cmd_opts',
|
||
_('Command options'),
|
||
_('Arguments passed to ByeDPI. Select a preset above or enter custom options. See <code>ciadpi --help</code> for full flag reference.'));
|
||
o.placeholder = '--disorder 1';
|
||
cmdOptsOpt = o;
|
||
|
||
/* Strategy tester */
|
||
o = ss.option(form.DummyValue, '_byedpi_tester',
|
||
_('Test current strategy'),
|
||
_('Starts ByeDPI with the current options on a temporary port and probes 4 sites — ' +
|
||
'<b>YouTube</b> and <b>Telegram</b> (far servers), <b>Discord</b> and <b>Speedtest.net</b> ' +
|
||
'(near Cloudflare/Ookla edges). A strategy that passes the far ones but fails the near ones ' +
|
||
'is destination-sensitive (typical of fixed <code>--fake --ttl</code>). ' +
|
||
'Requires curl; without it, only confirms ByeDPI starts.'));
|
||
o.render = function(option_index, section_id) {
|
||
const msgEl = E('span', { style: 'margin-left:8px; font-size:0.9em; color:gray' }, '');
|
||
const btn = E('button', {
|
||
'class': 'btn cbi-button cbi-button-action',
|
||
'click': ui.createHandlerFn(this, () => {
|
||
const el = document.querySelector('[name*=".byedpi_cmd_opts"]');
|
||
const opts = el ? el.value.trim() : '';
|
||
btn.disabled = true;
|
||
msgEl.style.color = 'gray';
|
||
msgEl.textContent = _('Testing...');
|
||
return L.resolveDefault(callByeDPITest(opts, '15335'), {}).then((ret) => {
|
||
btn.disabled = false;
|
||
if (ret.results && ret.results.length) {
|
||
const frag = [];
|
||
ret.results.forEach((r, i) => {
|
||
if (i) frag.push(document.createTextNode(' · '));
|
||
frag.push(E('span', {
|
||
style: 'font-weight:bold; color:' + (r.ok ? 'green' : '#cc3300'),
|
||
title: r.host + ' → ' + (r.ok ? r.code : (r.reason || 'fail'))
|
||
}, (r.label || r.tag) + (r.ok ? ' ✓' : ' ✗')));
|
||
});
|
||
frag.push(E('span', { style: 'color:gray; margin-left:6px' },
|
||
'(' + ret.passed + '/' + ret.total + ')'));
|
||
while (msgEl.firstChild) msgEl.removeChild(msgEl.firstChild);
|
||
frag.forEach((n) => msgEl.appendChild(n));
|
||
} else if (ret.result) {
|
||
msgEl.style.color = 'green';
|
||
msgEl.textContent = _('✓ ByeDPI started (install curl for full test)');
|
||
} else {
|
||
msgEl.style.color = 'red';
|
||
msgEl.textContent = ret.error || _('Test failed');
|
||
}
|
||
});
|
||
})
|
||
}, [ _('Test') ]);
|
||
|
||
return E('div', { 'class': 'cbi-value' }, [
|
||
E('label', { 'class': 'cbi-value-title' }, _('Test current strategy')),
|
||
E('div', { 'class': 'cbi-value-field' }, [ btn, msgEl ])
|
||
]);
|
||
};
|
||
o.write = function() {};
|
||
|
||
/* Test all strategies */
|
||
o = ss.option(form.DummyValue, '_byedpi_test_all', _('Test all strategies'));
|
||
o.render = function(option_index, section_id) {
|
||
const progressEl = E('div', {
|
||
style: 'font-size:0.9em; margin:6px 0; display:none'
|
||
}, '');
|
||
const tableEl = E('table', {
|
||
style: 'width:100%; border-collapse:collapse; font-size:0.85em; display:none; margin-top:4px'
|
||
});
|
||
|
||
let stopRequested = false;
|
||
const stopBtn = E('button', {
|
||
'class': 'btn cbi-button cbi-button-reset',
|
||
'style': 'display:none; margin-left:6px',
|
||
'click': function() {
|
||
stopRequested = true;
|
||
stopBtn.disabled = true;
|
||
stopBtn.textContent = _('Stopping…');
|
||
}
|
||
}, [ _('Stop') ]);
|
||
|
||
const btn = E('button', {
|
||
'class': 'btn cbi-button cbi-button-action',
|
||
'disabled': true,
|
||
'click': ui.createHandlerFn(this, function() {
|
||
if (!confirm(_('Test all strategies?') + '\n\n' +
|
||
_('Each is probed against 4 sites (~12 min total). You can Stop it at any point.') + '\n' +
|
||
_('LAN clients are not affected during testing.')))
|
||
return;
|
||
|
||
btn.disabled = true;
|
||
stopRequested = false;
|
||
stopBtn.disabled = false;
|
||
stopBtn.textContent = _('Stop');
|
||
stopBtn.style.display = '';
|
||
progressEl.style.display = '';
|
||
progressEl.style.color = 'gray';
|
||
progressEl.textContent = _('Preparing...');
|
||
tableEl.style.display = '';
|
||
tableEl.innerHTML = '';
|
||
|
||
/* Display order = group order, then array order within a group, so an
|
||
* appended preset shows under its letter-group here too (not at the bottom). */
|
||
const orderedIdx = [];
|
||
for (let g of BYEDPI_GROUPS)
|
||
BYEDPI_PRESETS.forEach((p, gi) => { if (p.name.charAt(0) === g.key) orderedIdx.push(gi); });
|
||
|
||
const rows = [];
|
||
for (let k = 0; k < orderedIdx.length; k++) {
|
||
const i = orderedIdx[k];
|
||
const dotsCell = E('td', {
|
||
style: 'white-space:nowrap; padding:2px 8px; font-size:0.95em; color:gray'
|
||
}, '–');
|
||
const applyBtn = E('button', {
|
||
'class': 'btn cbi-button cbi-button-save',
|
||
style: 'padding:1px 8px; font-size:0.8em; visibility:hidden',
|
||
click: (function(idx) {
|
||
return function() { applyPreset(idx); };
|
||
})(i)
|
||
}, [ _('Apply') ]);
|
||
|
||
tableEl.appendChild(E('tr', { style: 'border-bottom:1px solid #f0f0f0' }, [
|
||
dotsCell,
|
||
E('td', { style: 'padding:2px 8px; color:#555; white-space:nowrap' },
|
||
BYEDPI_PRESETS[i].name),
|
||
E('td', { style: 'padding:2px 8px; color:#888; font-family:monospace; font-size:0.85em; word-break:break-all' },
|
||
BYEDPI_PRESETS[i].args),
|
||
E('td', { style: 'padding:2px 4px; white-space:nowrap' }, [ applyBtn ])
|
||
]));
|
||
rows.push({ dotsCell, applyBtn });
|
||
}
|
||
|
||
let fullPass = 0, partial = 0;
|
||
let chain = Promise.resolve();
|
||
for (let k = 0; k < orderedIdx.length; k++) {
|
||
chain = chain.then((function(pos) {
|
||
return function() {
|
||
if (stopRequested) return;
|
||
const i = orderedIdx[pos];
|
||
const { dotsCell, applyBtn } = rows[pos];
|
||
progressEl.textContent =
|
||
(pos + 1) + ' / ' + orderedIdx.length +
|
||
': ' + BYEDPI_PRESETS[i].name;
|
||
dotsCell.textContent = '⏳';
|
||
return L.resolveDefault(
|
||
callByeDPITest(BYEDPI_PRESETS[i].args, '15335'), {}
|
||
).then(function(ret) {
|
||
if (ret.results && ret.results.length) {
|
||
const frag = [];
|
||
ret.results.forEach(function(r) {
|
||
frag.push(E('span', {
|
||
style: 'font-weight:bold; margin-right:4px; color:' + (r.ok ? 'green' : '#cc3300'),
|
||
title: (r.label || r.tag) + ' (' + r.host + ') → ' + (r.ok ? r.code : (r.reason || 'fail'))
|
||
}, r.ok ? '●' : '○'));
|
||
});
|
||
frag.push(E('span', { style: 'color:#888; margin-left:2px' },
|
||
ret.passed + '/' + ret.total));
|
||
while (dotsCell.firstChild) dotsCell.removeChild(dotsCell.firstChild);
|
||
frag.forEach(function(n) { dotsCell.appendChild(n); });
|
||
if (ret.passed === ret.total) { fullPass++; applyBtn.style.visibility = ''; }
|
||
else if (ret.passed > 0) { partial++; applyBtn.style.visibility = ''; }
|
||
} else {
|
||
dotsCell.textContent = ret.result ? '✓' : '✗';
|
||
dotsCell.style.color = ret.result ? 'green' : '#cc3300';
|
||
}
|
||
});
|
||
};
|
||
})(k));
|
||
}
|
||
|
||
return chain.then(function() {
|
||
stopBtn.style.display = 'none';
|
||
const tail =
|
||
(partial > 0 ? ', ' + partial + ' ' + _('partial') : '') +
|
||
((fullPass + partial) > 0 ? ' — ' + _('click Apply next to a strategy (prefer all-green)') : '');
|
||
if (stopRequested) {
|
||
progressEl.style.color = '#c80';
|
||
progressEl.textContent = _('Stopped') + ' — ' + fullPass + ' ' + _('work on all sites') + tail;
|
||
} else {
|
||
progressEl.style.color = fullPass > 0 ? 'green' : (partial > 0 ? '#c80' : '#cc3300');
|
||
progressEl.textContent = _('Done') + ': ' + fullPass + ' / ' + BYEDPI_PRESETS.length +
|
||
' ' + _('work on all sites') + tail;
|
||
}
|
||
btn.disabled = false;
|
||
});
|
||
})
|
||
}, [ _('Test all strategies') ]);
|
||
|
||
const hintEl = E('div', { style: 'font-size:0.85em; color:#666; margin-bottom:6px' },
|
||
_('Probes each preset against 4 sites (YouTube video CDN, Telegram, Discord, Speedtest). ' +
|
||
'● = TLS handshake got through, ○ = blocked; hover a dot for the site and result. ' +
|
||
'Tests a fixed preset list. You can Stop it anytime. ~12 min total.'));
|
||
|
||
L.resolveDefault(callCurlStatus(), {}).then(function(status) {
|
||
if (status.installed) {
|
||
btn.disabled = false;
|
||
} else {
|
||
hintEl.textContent = _('Requires curl. Install it on the Status page first.');
|
||
hintEl.style.color = '#c00';
|
||
}
|
||
});
|
||
|
||
return E('div', { 'class': 'cbi-value' }, [
|
||
E('label', { 'class': 'cbi-value-title' }, _('Test all strategies')),
|
||
E('div', { 'class': 'cbi-value-field' }, [
|
||
hintEl,
|
||
btn,
|
||
stopBtn,
|
||
progressEl,
|
||
tableEl
|
||
])
|
||
]);
|
||
};
|
||
o.write = function() {};
|
||
})();
|
||
|
||
o = ss.option(form.Flag, 'byedpi_block_quic',
|
||
_('Block QUIC (UDP port 443)') + ' ⚠️',
|
||
_('This nftables rule drops all outgoing UDP port 443 packets to external addresses, ' +
|
||
'forcing browsers and apps to fall back to TCP/TLS where ByeDPI can apply DPI bypass. ' +
|
||
'Side effects: may break services that require QUIC, and affects all LAN clients. ' +
|
||
'Enable if your strategy works but some sites still do not load.'));
|
||
o.default = o.disabled;
|
||
o.rmempty = false;
|
||
/* ByeDPI settings end */
|
||
|
||
/* Zapret settings start */
|
||
s.tab('zapret', _('Zapret'));
|
||
let zapretSV = s.taboption('zapret', form.SectionValue, '_zapret', form.NamedSection, 'config', 'homeproxy');
|
||
ss = zapretSV.subsection;
|
||
ss.anonymous = true;
|
||
ss.addremove = false;
|
||
|
||
(function() {
|
||
const HTTP = '--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new ';
|
||
const TLS = '--filter-tcp=443 --filter-l7=tls --payload=tls_client_hello ';
|
||
/* Domain-specific fakes shipped by the zapret2 package (registered via --blob=@file). */
|
||
const FK = '/opt/zapret2/files/fake';
|
||
/* Strategy candidates come from the shipped read-only file
|
||
* /etc/homeproxy/zapret_candidates.json (loaded in load()). Each entry =
|
||
* {name, args, group} where group is 'recommended' (curated, purpose-named)
|
||
* or 'auto' (the full-test pool, technique-named). If the file is missing,
|
||
* fall back to this minimal builtin set so the UI never breaks. */
|
||
const ZAPRET_PRESETS = zapret_candidates || [
|
||
{ name: _('Default (fake + multidisorder)'), group: 'recommended', args: HTTP + TLS + '--lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld' },
|
||
{ name: _('Multisplit'), group: 'recommended', args: HTTP + TLS + '--lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multisplit:pos=1,midsld' },
|
||
{ name: _('Fake only'), group: 'recommended', args: HTTP + TLS + '--lua-desync=fake:blob=fake_default_tls:tcp_md5' }
|
||
];
|
||
|
||
const callZapretTest = rpc.declare({
|
||
object: 'luci.homeproxy',
|
||
method: 'zapret_strategy_test',
|
||
params: ['cmd_opts', 'ips'],
|
||
expect: { '': {} }
|
||
});
|
||
|
||
const callZapretResolve = rpc.declare({
|
||
object: 'luci.homeproxy',
|
||
method: 'zapret_resolve_hosts',
|
||
expect: { '': {} }
|
||
});
|
||
|
||
/* Resolve the 4 test hosts ONCE (bounded + retried server-side), via the
|
||
* same secure-DNS/direct path production uses for these blocked domains, so
|
||
* the IP is representative for the TLS-handshake probe. Returns
|
||
* { ok, ips, error }; ips is the "tag=ip …" string fed to each candidate so
|
||
* the full-test sweep doesn't re-hit DNS for all 36 candidates. A failure
|
||
* here is the "DNS not ready right after Apply" case — surfaced as one
|
||
* clear message instead of 36 ubus errors. */
|
||
function resolveHosts() {
|
||
return L.resolveDefault(callZapretResolve(), {}).then((ret) => {
|
||
let d = {};
|
||
try { d = JSON.parse(ret.output || '{}'); } catch (e) {}
|
||
if (!d.ok || !d.ips)
|
||
return { ok: false, error: d.error || _('DNS not ready — wait a few seconds after Apply and retry') };
|
||
const ips = Object.keys(d.ips).map((k) => k + '=' + d.ips[k]).join(' ');
|
||
return { ok: true, ips: ips };
|
||
}, () => ({ ok: false, error: _('rpc error') }));
|
||
}
|
||
|
||
let cmdOptsOpt = null;
|
||
let presetSelEl = null;
|
||
function applyPreset(idx) {
|
||
if (!ZAPRET_PRESETS[idx]) return;
|
||
const val = ZAPRET_PRESETS[idx].args;
|
||
const widget = cmdOptsOpt ? cmdOptsOpt.getUIElement('config') : null;
|
||
if (widget) {
|
||
widget.setValue(val);
|
||
widget.node.dispatchEvent(new Event('change', { bubbles: true }));
|
||
} else {
|
||
const el = document.querySelector('[name*=".zapret_cmd_opts"]');
|
||
if (el) { el.value = val; el.dispatchEvent(new Event('change', { bubbles: true })); }
|
||
}
|
||
if (presetSelEl) presetSelEl.value = String(idx);
|
||
}
|
||
|
||
/* Preset picker — DummyValue, never stored; it just fills zapret_cmd_opts and
|
||
* pre-selects whichever preset matches the current value. */
|
||
o = ss.option(form.DummyValue, '_zapret_preset', _('Strategy preset'));
|
||
o.render = function(option_index, section_id) {
|
||
const cur = (uci.get('homeproxy', 'config', 'zapret_cmd_opts') || '').trim();
|
||
let curIdx = -1;
|
||
for (let i = 0; i < ZAPRET_PRESETS.length; i++)
|
||
if (ZAPRET_PRESETS[i].args === cur) { curIdx = i; break; }
|
||
const sel = E('select', { 'class': 'cbi-input-select', 'style': 'max-width:100%' });
|
||
presetSelEl = sel;
|
||
sel.appendChild(E('option', { value: '', selected: curIdx < 0 ? '' : null },
|
||
curIdx < 0 ? _('— custom / select a preset —') : _('— select a preset —')));
|
||
/* Group into two optgroups but keep the flat index as the option value
|
||
* (applyPreset / curIdx both index the flat ZAPRET_PRESETS array). */
|
||
const _groups = [
|
||
{ key: 'recommended', label: _('Recommended') },
|
||
{ key: 'auto', label: _('Full-test pool') }
|
||
];
|
||
_groups.forEach((g) => {
|
||
const og = E('optgroup', { label: g.label });
|
||
ZAPRET_PRESETS.forEach((p, i) => {
|
||
if ((p.group || 'recommended') !== g.key) return;
|
||
og.appendChild(E('option', { value: String(i), selected: i === curIdx ? '' : null }, p.name));
|
||
});
|
||
if (og.children.length) sel.appendChild(og);
|
||
});
|
||
sel.addEventListener('change', function() { if (sel.value !== '') applyPreset(parseInt(sel.value)); });
|
||
return E('div', { 'class': 'cbi-value' }, [
|
||
E('label', { 'class': 'cbi-value-title' }, _('Strategy preset')),
|
||
E('div', { 'class': 'cbi-value-field' }, [ sel,
|
||
E('div', { 'class': 'cbi-value-description' }, _('Fills the strategy below. Use the tester to find one that works on your ISP. ' +
|
||
'Based on <a href="https://github.com/bol-van/zapret2" target="_blank">bol-van/zapret2</a> (nfqws2/blockcheck2) and ' +
|
||
'<a href="https://github.com/flowseal/zapret-discord-youtube" target="_blank">flowseal/zapret-discord-youtube</a> (MIT). ' +
|
||
'Packages by <a href="https://github.com/1andrevich/zapret2-openwrt" target="_blank">1andrevich/zapret2-openwrt</a>.')) ])
|
||
]);
|
||
};
|
||
o.write = function() {};
|
||
|
||
o = ss.option(form.Value, 'zapret_cmd_opts', _('Desync strategy'),
|
||
_('nfqws2 options applied to every flow routed to Zapret. No hostlists — the routing rules already select what is sent here. ' +
|
||
'Pick a preset above or edit freely. The <code>--qnum</code>/<code>--user</code>/<code>--fwmark</code>/<code>--lua-init</code> arguments are added automatically.'));
|
||
o.default = ZAPRET_PRESETS[0].args;
|
||
o.rmempty = false;
|
||
cmdOptsOpt = o;
|
||
|
||
/* Strategy tester — runs the candidate on a temp queue scoped to 4 test sites,
|
||
* leaving the live queue/traffic untouched (zapret_test.sh). */
|
||
o = ss.option(form.DummyValue, '_zapret_tester', _('Test current strategy'),
|
||
_('Runs the current strategy on a temporary NFQUEUE scoped to 4 test sites — ' +
|
||
'<b>YouTube</b>, <b>Telegram</b>, <b>Discord</b>, <b>Speedtest.net</b> — and checks whether each TLS handshake completes. ' +
|
||
'Does not touch your live connection. A site that fails here means the strategy breaks it.'));
|
||
o.render = function(option_index, section_id) {
|
||
const msgEl = E('span', { style: 'margin-left:8px; font-size:0.9em; color:gray' }, '');
|
||
const btn = E('button', {
|
||
'class': 'btn cbi-button cbi-button-action',
|
||
'click': ui.createHandlerFn(this, async () => {
|
||
/* Read the strategy the SAME way the preset picker writes it (the
|
||
* widget), with a uci fallback — a raw querySelector can grab the
|
||
* wrong/no input and return '' → nfqws2 runs with no desync → every
|
||
* handshake is blocked → a false 0/4. The full test never had this
|
||
* because it uses the preset definitions directly. */
|
||
const w = cmdOptsOpt ? cmdOptsOpt.getUIElement(section_id) : null;
|
||
let opts = (w && w.getValue() != null) ? String(w.getValue())
|
||
: (uci.get('homeproxy', 'config', 'zapret_cmd_opts') || '');
|
||
opts = opts.trim();
|
||
btn.disabled = true;
|
||
msgEl.style.color = 'gray';
|
||
msgEl.textContent = _('Testing...');
|
||
if (!opts) {
|
||
btn.disabled = false;
|
||
msgEl.style.color = 'red';
|
||
msgEl.textContent = _('No strategy set — pick a preset or enter options first');
|
||
return;
|
||
}
|
||
const r0 = await resolveHosts();
|
||
if (!r0.ok) {
|
||
btn.disabled = false;
|
||
msgEl.style.color = 'red';
|
||
msgEl.textContent = r0.error;
|
||
return;
|
||
}
|
||
return L.resolveDefault(callZapretTest(opts, r0.ips), {}).then((ret) => {
|
||
btn.disabled = false;
|
||
let data = {};
|
||
try { data = JSON.parse(ret.output || '{}'); } catch (e) {}
|
||
if (data.error) {
|
||
msgEl.style.color = 'red';
|
||
msgEl.textContent = data.error;
|
||
} else if (data.results && data.results.length) {
|
||
const frag = [];
|
||
data.results.forEach((r, i) => {
|
||
if (i) frag.push(document.createTextNode(' · '));
|
||
frag.push(E('span', {
|
||
style: 'font-weight:bold; color:' + (r.ok ? 'green' : '#cc3300'),
|
||
title: r.host + (r.ok ? (' → ' + r.tls + 's') : (' → ' + (r.reason || 'fail')))
|
||
}, (r.label || r.tag) + (r.ok ? ' ✓' : ' ✗')));
|
||
});
|
||
frag.push(E('span', { style: 'color:gray; margin-left:6px' }, '(' + data.ok + '/' + data.total + ')'));
|
||
while (msgEl.firstChild) msgEl.removeChild(msgEl.firstChild);
|
||
frag.forEach((n) => msgEl.appendChild(n));
|
||
} else {
|
||
msgEl.style.color = 'red';
|
||
msgEl.textContent = _('Test failed');
|
||
}
|
||
});
|
||
})
|
||
}, [ _('Test') ]);
|
||
return E('div', { 'class': 'cbi-value' }, [
|
||
E('label', { 'class': 'cbi-value-title' }, _('Test current strategy')),
|
||
E('div', { 'class': 'cbi-value-field' }, [ btn, msgEl ])
|
||
]);
|
||
};
|
||
o.write = function() {};
|
||
|
||
/* Full strategy test — client-driven sweep over every candidate. Page-bound:
|
||
* runs only while the user is on the page, one candidate at a time through the
|
||
* same isolated probe RPC, with live X/N progress, a Stop button, and a per-winner
|
||
* Apply (writes that candidate into zapret_cmd_opts, like picking it in the
|
||
* dropdown). Leaving the page stops the sweep; applied results persist on Save. */
|
||
o = ss.option(form.DummyValue, '_zapret_fulltest', _('Full strategy test'),
|
||
_('Tries every strategy in the list one by one on a temporary NFQUEUE — your live connection is untouched — ' +
|
||
'and shows which pass. Stay on this page while it runs; leaving stops it. ' +
|
||
'Press <b>Apply</b> on any passing strategy to select it, then Save & Apply.'));
|
||
o.render = function(option_index, section_id) {
|
||
let running = false, stop = false;
|
||
const runBtn = E('button', { 'class': 'btn cbi-button cbi-button-action' }, [ _('Run full test') ]);
|
||
const stopBtn = E('button', { 'class': 'btn cbi-button cbi-button-negative', style: 'margin-left:6px; display:none' }, [ _('Stop') ]);
|
||
const prog = E('span', { style: 'margin-left:8px; font-size:0.9em; color:gray' }, '');
|
||
const list = E('div', { style: 'margin-top:8px' });
|
||
|
||
function addRow(p, idx, data) {
|
||
const ok = (data && data.ok) ? data.ok : 0;
|
||
const total = (data && data.total) ? data.total : 0;
|
||
const err = data && data.error;
|
||
const pass = ok > 0 && !err;
|
||
const r = E('div', { style: 'padding:2px 0; font-size:0.9em' });
|
||
r.appendChild(E('span', { style: 'font-weight:bold; color:' + (pass ? 'green' : '#999') }, pass ? '✓ ' : '✗ '));
|
||
r.appendChild(E('span', {}, p.name));
|
||
r.appendChild(E('span', { style: 'color:gray; margin-left:6px' }, err ? err : ('(' + ok + '/' + total + ')')));
|
||
if (pass) {
|
||
const ap = E('a', { href: '#', style: 'margin-left:10px' }, _('Apply'));
|
||
ap.addEventListener('click', function(ev) {
|
||
ev.preventDefault();
|
||
applyPreset(idx);
|
||
prog.style.color = 'green';
|
||
prog.textContent = _('Applied: %s — now Save & Apply').format(p.name);
|
||
});
|
||
r.appendChild(ap);
|
||
}
|
||
list.appendChild(r);
|
||
}
|
||
|
||
async function runAll() {
|
||
running = true; stop = false;
|
||
runBtn.disabled = true; stopBtn.disabled = false; stopBtn.style.display = '';
|
||
while (list.firstChild) list.removeChild(list.firstChild);
|
||
/* Resolve the test hosts ONCE up front and reuse the IPs for every
|
||
* candidate. If DNS isn't ready (Apply just restarted it), stop with
|
||
* one clear message instead of grinding out 36 failures. */
|
||
prog.style.color = 'gray';
|
||
prog.textContent = _('Resolving test hosts…');
|
||
const r0 = await resolveHosts();
|
||
if (!r0.ok) {
|
||
prog.style.color = '#cc3300';
|
||
prog.textContent = r0.error;
|
||
runBtn.disabled = false; stopBtn.style.display = 'none'; running = false;
|
||
return;
|
||
}
|
||
const N = ZAPRET_PRESETS.length;
|
||
let passed = 0;
|
||
for (let i = 0; i < N; i++) {
|
||
if (stop) break;
|
||
const p = ZAPRET_PRESETS[i];
|
||
prog.style.color = 'gray';
|
||
prog.textContent = _('Testing %d/%d: %s').format(i + 1, N, p.name);
|
||
let data = {};
|
||
try {
|
||
const ret = await callZapretTest(p.args, r0.ips);
|
||
data = JSON.parse(ret.output || '{}');
|
||
} catch (e) { data = { error: _('rpc error') }; }
|
||
if (data && data.ok > 0 && !data.error) passed++;
|
||
addRow(p, i, data);
|
||
}
|
||
prog.style.color = stop ? '#cc3300' : 'green';
|
||
prog.textContent = (stop ? _('Stopped') : _('Done')) + ' — ' + _('%d/%d passed').format(passed, ZAPRET_PRESETS.length);
|
||
runBtn.disabled = false; stopBtn.style.display = 'none'; running = false;
|
||
}
|
||
|
||
runBtn.addEventListener('click', function() { if (!running) runAll(); });
|
||
stopBtn.addEventListener('click', function() { stop = true; stopBtn.disabled = true; });
|
||
|
||
return E('div', { 'class': 'cbi-value' }, [
|
||
E('label', { 'class': 'cbi-value-title' }, _('Full strategy test')),
|
||
E('div', { 'class': 'cbi-value-field' }, [ runBtn, stopBtn, prog, list ])
|
||
]);
|
||
};
|
||
o.write = function() {};
|
||
|
||
/* Discord voice opt-in: Discord voice is raw RTP/STUN UDP to bare IPs with no
|
||
* sniffable domain, so the hostlist/routing can't catch it. This adds a port-based
|
||
* rule (UDP 19294-19344, 50000-50100) sending it to Zapret. Only emitted when both
|
||
* Zapret and this switch are on; off = those ports follow the normal call rules. */
|
||
o = ss.option(form.Flag, 'zapret_voice', _('Discord calls via Zapret'),
|
||
_('Route Discord\'s voice UDP ports (19294–19344, 50000–50100) through Zapret. ' +
|
||
'Discord voice has no domain to match, so it can only be selected by port. ' +
|
||
'Leave off if your VPN already handles calls.'));
|
||
o.default = '0';
|
||
o.rmempty = false;
|
||
})();
|
||
|
||
/* Zapret settings end */
|
||
|
||
return m.render();
|
||
}
|
||
});
|