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:
aaaamoooo
2026-10-21 10:47:10 +00:00
parent 4199a43660
commit 0ab31ae32c
5 changed files with 467 additions and 1 deletions
+53 -1
View File
@@ -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));
+5
View File
@@ -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}