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 ? '
从主控所在网络发起探测:可达性与延迟是主控视角,节点所在网络可能不同。
+ + +| 目标 | 状态 | TLS | ALPN | 密钥交换 | 证书域名 | 延迟 / 原因 | |
|---|---|---|---|---|---|---|---|
| 无结果 | |||||||