feat: Reality target scanner (item 16)
Panel-side POST /api/reality/scan performs real TLS handshakes against operator input (domains / IPs / CIDRs, port suffixes) or the built-in pool (form presets plus microsoft/google/nvidia/samsung/ cloudflare and friends) and reports per target: usable or not, TLS version, ALPN, key exchange, first cert SAN, latency, and a short failure reason. Key-exchange support is proven by restricted-group handshakes (X25519MLKEM768 first, then X25519, then a combined offer) since the negotiated group is not exposed to Go clients. Bounds: 32 targets, 8 concurrent, 4s per probe. Node form gains a 查找目标 button beside the Reality preset; the scanner is a stacked overlay so form state survives, and 使用 backfills SNI/dest/port.
This commit is contained in:
+53
-1
@@ -901,7 +901,7 @@ function nodeForm(item = null) {
|
||||
<label>网络栈<select name="networkMode"><option value="ipv4"${selected('ipv4', node.networkMode || 'ipv4')}>IPv4</option><option value="ipv6"${selected('ipv6', node.networkMode)}>IPv6</option><option value="dual"${selected('dual', node.networkMode)}>双栈监听</option></select></label>
|
||||
<label>入口协议<select name="protocol" data-node-sync>${protocolOptions('relay-ingress', node.protocol)}</select></label>
|
||||
<label>入口端口<div class="inline-fields"><select name="portMode" data-node-sync><option value="random"${selected('random', node.portMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.portMode)}>固定端口</option></select><input name="port" type="number" value="${esc(node.port || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
|
||||
<label data-reality-only>Reality 目标预设<select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id, preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom', preset.id)}>自定义</option></select></label>
|
||||
<label data-reality-only>Reality 目标预设<div class="inline-fields"><select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id, preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom', preset.id)}>自定义</option></select><button type="button" class="btn" data-action="scan-reality">查找目标</button></div></label>
|
||||
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div></label>
|
||||
${item ? '<div class="wide cred-group" data-reality-only id="reality-keys-box"><div class="g-head">服务器 Reality 密钥对(主控托管)</div><div class="empty"><b>加载中…</b></div></div>' : ''}
|
||||
<label data-socks-only>SOCKS5 固定用户名<div class="inline-fields"><input name="socksUser" value="${esc(node.socksUser || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksUser">${ICONS.copy}</button></div></label>
|
||||
@@ -1017,6 +1017,57 @@ function applyExitLink(input) {
|
||||
syncNodeForm();
|
||||
toast('已从链接填入落地端点');
|
||||
}
|
||||
|
||||
/* Reality 目标扫描器(第 16 条):叠加在节点表单之上的第二层弹窗,
|
||||
主控侧真实 TLS 握手探测;「使用」把目标回填到表单并关闭。 */
|
||||
function closeRealityScanner() { const overlay = $('#scan-overlay'); if (overlay) overlay.remove(); }
|
||||
function openRealityScanner() {
|
||||
closeRealityScanner();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'scan-overlay';
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.style.zIndex = '95';
|
||||
overlay.innerHTML = `<section class="modal panel wide" role="dialog" aria-modal="true">
|
||||
<header class="modal-head"><h2>查找 Reality 目标</h2><button type="button" class="icon-btn" data-scan-close aria-label="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6L6 18M6 6l12 12"/></svg></button></header>
|
||||
<div class="modal-body">
|
||||
<p class="links-note">从主控所在网络发起探测:可达性与延迟是主控视角,节点所在网络可能不同。</p>
|
||||
<div class="inline-fields"><input id="scan-input" placeholder="域名 / IP / CIDR,留空扫描常用候选池"><button type="button" class="btn primary" id="scan-run">扫描</button></div>
|
||||
<div id="scan-results" style="margin-top:12px"></div>
|
||||
</div></section>`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.addEventListener('click', async (event) => {
|
||||
if (event.target === overlay || event.target.closest('[data-scan-close]')) { closeRealityScanner(); return; }
|
||||
if (event.target.closest('#scan-run')) { await runRealityScan(); return; }
|
||||
const use = event.target.closest('[data-scan-use]');
|
||||
if (use) {
|
||||
const form = $('#node-form');
|
||||
if (form) {
|
||||
form.elements.realityServerName.value = use.dataset.host;
|
||||
form.elements.realityDestPort.value = use.dataset.port;
|
||||
if (form.elements.realityPreset) form.elements.realityPreset.value = 'custom';
|
||||
}
|
||||
closeRealityScanner();
|
||||
toast('已回填 Reality 目标');
|
||||
}
|
||||
});
|
||||
runRealityScan();
|
||||
}
|
||||
async function runRealityScan() {
|
||||
const box = $('#scan-results'); if (!box) return;
|
||||
box.innerHTML = '<div class="empty"><b>扫描中…</b></div>';
|
||||
try {
|
||||
const input = $('#scan-input') ? $('#scan-input').value.trim() : '';
|
||||
const data = await api('/api/reality/scan', { method: 'POST', body: JSON.stringify({ input }) });
|
||||
const rows = (data.results || []).map((r) => `<tr>
|
||||
<td>${esc(r.target)}${Number(r.port) !== 443 ? `:${esc(r.port)}` : ''}</td>
|
||||
<td>${r.ok ? '<span class="badge green">可用</span>' : '<span class="badge gray">不可用</span>'}</td>
|
||||
<td>${esc(r.tlsVersion || '—')}</td><td>${esc(r.alpn || '—')}</td>
|
||||
<td>${esc(r.keyExchange || '—')}</td><td>${esc(r.certName || '—')}</td>
|
||||
<td>${r.ok ? `${esc(r.latencyMs)} ms` : esc(r.reason || '')}</td>
|
||||
<td>${r.ok ? `<button type="button" class="btn small" data-scan-use data-host="${esc(r.host)}" data-port="${esc(r.port)}">使用</button>` : ''}</td></tr>`).join('');
|
||||
box.innerHTML = `<div class="table-wrap visible"><table class="scan-table"><thead><tr><th>目标</th><th>状态</th><th>TLS</th><th>ALPN</th><th>密钥交换</th><th>证书域名</th><th>延迟 / 原因</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="8">无结果</td></tr>'}</tbody></table></div>`;
|
||||
} catch (error) { box.innerHTML = `<div class="empty"><b>${esc(error.message)}</b></div>`; }
|
||||
}
|
||||
function nodeClientsModal(node) {
|
||||
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
|
||||
const rows = assigned.map((nc) => {
|
||||
@@ -1186,6 +1237,7 @@ document.addEventListener('click', async (event) => {
|
||||
const action = button.dataset.action; const itemId = button.dataset.id;
|
||||
try {
|
||||
if (action === 'copy-text') { await copyText(button.dataset.value); toast('已复制到剪贴板'); }
|
||||
else if (action === 'scan-reality') openRealityScanner();
|
||||
else if (action === 'copy-field') { const input = button.form ? button.form.elements[button.dataset.field] : null; await copyText(input ? input.value : ''); toast('已复制到剪贴板'); }
|
||||
else if (action === 'add-server') serverForm();
|
||||
else if (action === 'edit-server') serverForm(serverById(itemId));
|
||||
|
||||
@@ -403,3 +403,8 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
|
||||
|
||||
@media (max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}.mid-grid{grid-template-columns:1fr}.info-bar{flex-direction:column;gap:14px}.info-bar .seg{padding:0}.info-bar .seg+.seg{border-left:none}.stats{grid-template-columns:repeat(2,1fr)}.stats.six{grid-template-columns:repeat(3,1fr)}}
|
||||
@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.content{padding:8px}}
|
||||
|
||||
/* Reality 目标扫描器结果表(第 16 条) */
|
||||
.scan-table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.scan-table th,.scan-table td{padding:6px 8px;text-align:left;border-bottom:1px solid var(--border-soft);white-space:nowrap}
|
||||
.scan-table th{color:var(--mut);font-weight:600}
|
||||
|
||||
Reference in New Issue
Block a user