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.
263 lines
7.5 KiB
Go
263 lines
7.5 KiB
Go
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
|
|
}
|