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 }