From 0ab31ae32c78bcaeed797690a998c101ce8e7723 Mon Sep 17 00:00:00 2001 From: aaaamoooo Date: Wed, 21 Oct 2026 10:47:10 +0000 Subject: [PATCH] feat: Reality target scanner (item 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- panel-go/handlers.go | 25 ++++ panel-go/realityscan.go | 262 +++++++++++++++++++++++++++++++++++ panel-go/realityscan_test.go | 122 ++++++++++++++++ public/app.js | 54 +++++++- public/styles.css | 5 + 5 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 panel-go/realityscan.go create mode 100644 panel-go/realityscan_test.go diff --git a/panel-go/handlers.go b/panel-go/handlers.go index def1aa5..e7765a5 100644 --- a/panel-go/handlers.go +++ b/panel-go/handlers.go @@ -2580,6 +2580,31 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st SendJSON(w, 201, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil) return true, nil } + if r.Method == http.MethodPost && pathname == "/api/reality/scan" { + // Round-3 item 16: panel-side Reality target scan. Bounded by + // parse/probe limits in realityscan.go. + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + input, _ := body["input"].(string) + if raw, ok := body["targets"].([]any); ok { + parts := []string{} + for _, item := range raw { + if s, ok := item.(string); ok { + parts = append(parts, s) + } + } + input = strings.Join(parts, ",") + } + targets, herr := parseScanTargets(input) + if herr != nil { + return true, herr + } + results := scanRealityTargets(targets) + SendJSON(w, 200, map[string]any{"results": results}, nil) + return true, nil + } if params, ok := RoutePattern("/api/nodes/:id/reality-keys", pathname); ok && r.Method == http.MethodGet { // Round-3 item 17: display-only view of the panel-hosted Reality // keypair for the node's server. This NEVER generates a pair — diff --git a/panel-go/realityscan.go b/panel-go/realityscan.go new file mode 100644 index 0000000..efd337f --- /dev/null +++ b/panel-go/realityscan.go @@ -0,0 +1,262 @@ +package main + +// Reality target scanner (round-3 item 16, modeled on the 3x-ui +// scanner): the panel itself performs a real TLS handshake against +// each candidate and reports whether it can serve as a Reality dest — +// handshake success plus an X25519-family key exchange. Results are +// the PANEL's point of view (the router reaches the internet through +// its own egress; a node's view can differ), and the UI says so. +// +// Bounds: at most scanMaxTargets probes per request, scanConcurrency +// in flight, scanProbeTimeout per probe — a scan can never wedge the +// panel. + +import ( + "crypto/tls" + "encoding/binary" + "fmt" + "net" + "strconv" + "strings" + "sync" + "time" +) + +const ( + scanMaxTargets = 32 + scanConcurrency = 8 + scanProbeTimeout = 4 * time.Second + scanCIDRMaxHosts = 32 +) + +// scanPool is the built-in candidate pool: the form's Reality presets +// (same source) plus the usual scanner extras. +func scanPool() []string { + seen := map[string]bool{} + pool := []string{} + add := func(host string) { + if !seen[host] { + seen[host] = true + pool = append(pool, host) + } + } + for _, preset := range RealityPresets { + add(preset.ServerName) + } + for _, host := range []string{ + "www.microsoft.com", "www.google.com", "www.nvidia.com", + "www.samsung.com", "www.cloudflare.com", "www.bing.com", + "www.github.com", "www.yahoo.com", "www.mozilla.org", + } { + add(host) + } + return pool +} + +type scanTarget struct { + Host string + Port int +} + +// parseScanTargets expands operator input (comma/space separated +// domains, IPs, host:port pairs and CIDRs) into probe targets. Empty +// input means the built-in pool. +func parseScanTargets(input string) ([]scanTarget, error) { + input = strings.TrimSpace(input) + if input == "" { + pool := scanPool() + out := make([]scanTarget, 0, len(pool)) + for _, host := range pool { + out = append(out, scanTarget{Host: host, Port: 443}) + } + return out, nil + } + out := []scanTarget{} + for _, part := range strings.FieldsFunc(input, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == ';' + }) { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if strings.Contains(part, "/") { + hosts, err := expandCIDR(part) + if err != nil { + return nil, httpErr(400, "CIDR 无效: %s", part) + } + for _, host := range hosts { + out = append(out, scanTarget{Host: host, Port: 443}) + } + continue + } + host, port := part, 443 + if h, p, err := net.SplitHostPort(part); err == nil { + n, err := strconv.Atoi(p) + if err != nil || n < 1 || n > 65535 { + return nil, httpErr(400, "端口无效: %s", part) + } + host, port = h, n + } + host = strings.Trim(host, "[]") + if host == "" || strings.ContainsAny(host, " \t") { + return nil, httpErr(400, "目标无效: %s", part) + } + out = append(out, scanTarget{Host: host, Port: port}) + if len(out) > scanMaxTargets { + return nil, httpErr(400, "一次最多扫描 %d 个目标", scanMaxTargets) + } + } + if len(out) == 0 { + return nil, httpErr(400, "没有可扫描的目标") + } + if len(out) > scanMaxTargets { + out = out[:scanMaxTargets] + } + return out, nil +} + +func expandCIDR(cidr string) ([]string, error) { + ip, network, err := net.ParseCIDR(cidr) + if err != nil { + return nil, err + } + if ip.To4() == nil { + return nil, fmt.Errorf("only IPv4 CIDR supported") + } + base := network.IP.To4() + mask := binary.BigEndian.Uint32(network.Mask) + start := binary.BigEndian.Uint32(base) & mask + count := uint32(1) << (32 - onesCount(network.Mask)) + hosts := []string{} + for i := uint32(0); i < count && len(hosts) < scanCIDRMaxHosts; i++ { + addr := make(net.IP, 4) + binary.BigEndian.PutUint32(addr, start+i) + hosts = append(hosts, addr.String()) + } + return hosts, nil +} + +func onesCount(mask net.IPMask) int { + ones, _ := mask.Size() + return ones +} + +type scanResult struct { + Target string `json:"target"` + Host string `json:"host"` + Port int `json:"port"` + OK bool `json:"ok"` + Reason string `json:"reason,omitempty"` + TLSVersion string `json:"tlsVersion,omitempty"` + ALPN string `json:"alpn,omitempty"` + KeyExchange string `json:"keyExchange,omitempty"` + KeyExchangeOK bool `json:"keyExchangeOk"` + CertName string `json:"certName,omitempty"` + LatencyMs int64 `json:"latencyMs"` +} + +func tlsVersionName(v uint16) string { + switch v { + case tls.VersionTLS13: + return "TLS 1.3" + case tls.VersionTLS12: + return "TLS 1.2" + case tls.VersionTLS11: + return "TLS 1.1" + case tls.VersionTLS10: + return "TLS 1.0" + } + return "" +} + +// dialScanTLS performs one bounded TLS handshake. curves, when +// non-nil, restricts the offered key-exchange groups — the only way a +// Go TLS client can learn which groups a server accepts (the negotiated +// group is not exposed on ConnectionState in this Go version). +func dialScanTLS(target scanTarget, curves []tls.CurveID) (*tls.Conn, error) { + dialer := &net.Dialer{Timeout: scanProbeTimeout} + conf := &tls.Config{ + ServerName: target.Host, + NextProtos: []string{"h2", "http/1.1"}, + MinVersion: tls.VersionTLS12, + // The probe judges reachability and key exchange, not trust: + // Reality dests are public sites whose cert the panel has no + // business pinning. The SAN is still reported for the operator. + InsecureSkipVerify: true, //nolint:gosec -- probe only, see above + } + if curves != nil { + conf.CurvePreferences = curves + } + return tls.DialWithDialer(dialer, "tcp", net.JoinHostPort(target.Host, strconv.Itoa(target.Port)), conf) +} + +func curveSupported(target scanTarget, curves ...tls.CurveID) bool { + conn, err := dialScanTLS(target, curves) + if err != nil { + return false + } + _ = conn.Close() + return true +} + +// probeRealityTarget judges a target by the Reality dest criteria: a +// TLS handshake completes and the key exchange can be X25519 or +// X25519MLKEM768 (probed strongest first; a combined-offer handshake +// is the fallback so a server that refuses single-group offers is not +// misjudged). +func probeRealityTarget(target scanTarget) scanResult { + result := scanResult{Target: target.Host, Host: target.Host, Port: target.Port} + start := time.Now() + conn, err := dialScanTLS(target, nil) + result.LatencyMs = time.Since(start).Milliseconds() + if err != nil { + result.Reason = "连接或握手失败: " + truncateRunes(err.Error(), 120) + return result + } + state := conn.ConnectionState() + _ = conn.Close() + result.TLSVersion = tlsVersionName(state.Version) + result.ALPN = state.NegotiatedProtocol + if len(state.PeerCertificates) > 0 { + cert := state.PeerCertificates[0] + if len(cert.DNSNames) > 0 { + result.CertName = cert.DNSNames[0] + } else { + result.CertName = cert.Subject.CommonName + } + } + switch { + case curveSupported(target, tls.X25519MLKEM768): + result.KeyExchange = "X25519MLKEM768" + case curveSupported(target, tls.X25519): + result.KeyExchange = "X25519" + case curveSupported(target, tls.X25519MLKEM768, tls.X25519): + result.KeyExchange = "X25519 / X25519MLKEM768" + } + result.KeyExchangeOK = result.KeyExchange != "" + if !result.KeyExchangeOK { + result.Reason = "密钥交换不支持 X25519" + return result + } + result.OK = true + return result +} + +// scanRealityTargets probes all targets with bounded concurrency and +// returns results in input order. +func scanRealityTargets(targets []scanTarget) []scanResult { + results := make([]scanResult, len(targets)) + sem := make(chan struct{}, scanConcurrency) + var wg sync.WaitGroup + for i, target := range targets { + wg.Add(1) + go func(i int, target scanTarget) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + results[i] = probeRealityTarget(target) + }(i, target) + } + wg.Wait() + return results +} diff --git a/panel-go/realityscan_test.go b/panel-go/realityscan_test.go new file mode 100644 index 0000000..1365ca3 --- /dev/null +++ b/panel-go/realityscan_test.go @@ -0,0 +1,122 @@ +package main + +// Item 16 scanner tests: input parsing (offline) and the probe against +// a local TLS server (loopback only — the live-domain check happens in +// the sandbox smoke run). + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "strings" + "testing" + "time" +) + +func TestParseScanTargetsPool(t *testing.T) { + targets, err := parseScanTargets("") + if err != nil { + t.Fatal(err) + } + if len(targets) < len(RealityPresets) || len(targets) > scanMaxTargets { + t.Fatalf("pool size %d out of bounds", len(targets)) + } + seen := map[string]bool{} + for _, target := range targets { + seen[target.Host] = true + if target.Port != 443 { + t.Fatalf("pool target %s port %d, want 443", target.Host, target.Port) + } + } + for _, want := range []string{"www.microsoft.com", "www.google.com", "www.nvidia.com", "www.samsung.com", "www.intel.com"} { + if !seen[want] { + t.Fatalf("pool missing %s", want) + } + } +} + +func TestParseScanTargetsInput(t *testing.T) { + targets, err := parseScanTargets("a.example.com, b.example.com:8443 203.0.113.7") + if err != nil { + t.Fatal(err) + } + if len(targets) != 3 || targets[0].Port != 443 || targets[1].Port != 8443 || targets[2].Host != "203.0.113.7" { + t.Fatalf("targets %+v", targets) + } + cidr, err := parseScanTargets("203.0.113.0/30") + if err != nil { + t.Fatal(err) + } + if len(cidr) != 4 { + t.Fatalf("cidr /30 expanded to %d hosts, want 4", len(cidr)) + } + if _, err := parseScanTargets("10.0.0.0/33"); err == nil { + t.Fatal("invalid CIDR must error") + } + if _, err := parseScanTargets("a.example.com:99999"); err == nil { + t.Fatal("invalid port must error") + } + many := strings.TrimSuffix(strings.Repeat("a.example.com,", scanMaxTargets+1), ",") + if _, err := parseScanTargets(many); err == nil { + t.Fatal("over-cap input must error") + } +} + +func TestProbeRealityTargetLocalTLS(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + DNSNames: []string{"localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} + listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}}) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + _ = conn.(*tls.Conn).Handshake() + }() + } + }() + port := listener.Addr().(*net.TCPAddr).Port + result := probeRealityTarget(scanTarget{Host: "127.0.0.1", Port: port}) + if !result.OK { + t.Fatalf("local TLS target must be OK: %+v", result) + } + if result.TLSVersion != "TLS 1.3" { + t.Fatalf("tlsVersion %q, want TLS 1.3", result.TLSVersion) + } + if result.KeyExchange == "" || !result.KeyExchangeOK { + t.Fatalf("keyExchange %q ok=%v", result.KeyExchange, result.KeyExchangeOK) + } + // A dead port reports not-OK with a reason, never a panic. + dead := probeRealityTarget(scanTarget{Host: "127.0.0.1", Port: 1}) + if dead.OK || dead.Reason == "" { + t.Fatalf("dead target: %+v", dead) + } +} diff --git a/public/app.js b/public/app.js index b78982e..7bca144 100644 --- a/public/app.js +++ b/public/app.js @@ -901,7 +901,7 @@ function nodeForm(item = null) { - + ${item ? '
服务器 Reality 密钥对(主控托管)
加载中…
' : ''} @@ -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 = ``; + 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 = '
扫描中…
'; + 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) => ` + ${esc(r.target)}${Number(r.port) !== 443 ? `:${esc(r.port)}` : ''} + ${r.ok ? '可用' : '不可用'} + ${esc(r.tlsVersion || '—')}${esc(r.alpn || '—')} + ${esc(r.keyExchange || '—')}${esc(r.certName || '—')} + ${r.ok ? `${esc(r.latencyMs)} ms` : esc(r.reason || '')} + ${r.ok ? `` : ''}`).join(''); + box.innerHTML = `
${rows || ''}
目标状态TLSALPN密钥交换证书域名延迟 / 原因
无结果
`; + } catch (error) { box.innerHTML = `
${esc(error.message)}
`; } +} 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)); diff --git a/public/styles.css b/public/styles.css index 7ba8715..2998228 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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}