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:
@@ -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)
|
SendJSON(w, 201, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil)
|
||||||
return true, 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 {
|
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
|
// Round-3 item 17: display-only view of the panel-hosted Reality
|
||||||
// keypair for the node's server. This NEVER generates a pair —
|
// keypair for the node's server. This NEVER generates a pair —
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-1
@@ -901,7 +901,7 @@ function nodeForm(item = null) {
|
|||||||
<label>网络栈<select name="networkMode"><option value="ipv4"${selected('ipv4', node.networkMode || 'ipv4')}>IPv4</option><option value="ipv6"${selected('ipv6', node.networkMode)}>IPv6</option><option value="dual"${selected('dual', node.networkMode)}>双栈监听</option></select></label>
|
<label>网络栈<select name="networkMode"><option value="ipv4"${selected('ipv4', node.networkMode || 'ipv4')}>IPv4</option><option value="ipv6"${selected('ipv6', node.networkMode)}>IPv6</option><option value="dual"${selected('dual', node.networkMode)}>双栈监听</option></select></label>
|
||||||
<label>入口协议<select name="protocol" data-node-sync>${protocolOptions('relay-ingress', node.protocol)}</select></label>
|
<label>入口协议<select name="protocol" data-node-sync>${protocolOptions('relay-ingress', node.protocol)}</select></label>
|
||||||
<label>入口端口<div class="inline-fields"><select name="portMode" data-node-sync><option value="random"${selected('random', node.portMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.portMode)}>固定端口</option></select><input name="port" type="number" value="${esc(node.port || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
|
<label>入口端口<div class="inline-fields"><select name="portMode" data-node-sync><option value="random"${selected('random', node.portMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.portMode)}>固定端口</option></select><input name="port" type="number" value="${esc(node.port || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
|
||||||
<label data-reality-only>Reality 目标预设<select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id, preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom', preset.id)}>自定义</option></select></label>
|
<label data-reality-only>Reality 目标预设<div class="inline-fields"><select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id, preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom', preset.id)}>自定义</option></select><button type="button" class="btn" data-action="scan-reality">查找目标</button></div></label>
|
||||||
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div></label>
|
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div></label>
|
||||||
${item ? '<div class="wide cred-group" data-reality-only id="reality-keys-box"><div class="g-head">服务器 Reality 密钥对(主控托管)</div><div class="empty"><b>加载中…</b></div></div>' : ''}
|
${item ? '<div class="wide cred-group" data-reality-only id="reality-keys-box"><div class="g-head">服务器 Reality 密钥对(主控托管)</div><div class="empty"><b>加载中…</b></div></div>' : ''}
|
||||||
<label data-socks-only>SOCKS5 固定用户名<div class="inline-fields"><input name="socksUser" value="${esc(node.socksUser || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksUser">${ICONS.copy}</button></div></label>
|
<label data-socks-only>SOCKS5 固定用户名<div class="inline-fields"><input name="socksUser" value="${esc(node.socksUser || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksUser">${ICONS.copy}</button></div></label>
|
||||||
@@ -1017,6 +1017,57 @@ function applyExitLink(input) {
|
|||||||
syncNodeForm();
|
syncNodeForm();
|
||||||
toast('已从链接填入落地端点');
|
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 = `<section class="modal panel wide" role="dialog" aria-modal="true">
|
||||||
|
<header class="modal-head"><h2>查找 Reality 目标</h2><button type="button" class="icon-btn" data-scan-close aria-label="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6L6 18M6 6l12 12"/></svg></button></header>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="links-note">从主控所在网络发起探测:可达性与延迟是主控视角,节点所在网络可能不同。</p>
|
||||||
|
<div class="inline-fields"><input id="scan-input" placeholder="域名 / IP / CIDR,留空扫描常用候选池"><button type="button" class="btn primary" id="scan-run">扫描</button></div>
|
||||||
|
<div id="scan-results" style="margin-top:12px"></div>
|
||||||
|
</div></section>`;
|
||||||
|
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 = '<div class="empty"><b>扫描中…</b></div>';
|
||||||
|
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) => `<tr>
|
||||||
|
<td>${esc(r.target)}${Number(r.port) !== 443 ? `:${esc(r.port)}` : ''}</td>
|
||||||
|
<td>${r.ok ? '<span class="badge green">可用</span>' : '<span class="badge gray">不可用</span>'}</td>
|
||||||
|
<td>${esc(r.tlsVersion || '—')}</td><td>${esc(r.alpn || '—')}</td>
|
||||||
|
<td>${esc(r.keyExchange || '—')}</td><td>${esc(r.certName || '—')}</td>
|
||||||
|
<td>${r.ok ? `${esc(r.latencyMs)} ms` : esc(r.reason || '')}</td>
|
||||||
|
<td>${r.ok ? `<button type="button" class="btn small" data-scan-use data-host="${esc(r.host)}" data-port="${esc(r.port)}">使用</button>` : ''}</td></tr>`).join('');
|
||||||
|
box.innerHTML = `<div class="table-wrap visible"><table class="scan-table"><thead><tr><th>目标</th><th>状态</th><th>TLS</th><th>ALPN</th><th>密钥交换</th><th>证书域名</th><th>延迟 / 原因</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="8">无结果</td></tr>'}</tbody></table></div>`;
|
||||||
|
} catch (error) { box.innerHTML = `<div class="empty"><b>${esc(error.message)}</b></div>`; }
|
||||||
|
}
|
||||||
function nodeClientsModal(node) {
|
function nodeClientsModal(node) {
|
||||||
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
|
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
|
||||||
const rows = assigned.map((nc) => {
|
const rows = assigned.map((nc) => {
|
||||||
@@ -1186,6 +1237,7 @@ document.addEventListener('click', async (event) => {
|
|||||||
const action = button.dataset.action; const itemId = button.dataset.id;
|
const action = button.dataset.action; const itemId = button.dataset.id;
|
||||||
try {
|
try {
|
||||||
if (action === 'copy-text') { await copyText(button.dataset.value); toast('已复制到剪贴板'); }
|
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 === '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 === 'add-server') serverForm();
|
||||||
else if (action === 'edit-server') serverForm(serverById(itemId));
|
else if (action === 'edit-server') serverForm(serverById(itemId));
|
||||||
|
|||||||
@@ -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: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}}
|
@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}
|
||||||
|
|||||||
Reference in New Issue
Block a user