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
+25
View File
@@ -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 —
+262
View File
@@ -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
}
+122
View File
@@ -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)
}
}