Files
3x-ui/internal/web/service/reality_scan.go
T
MHSanaei 6964d84742 feat(reality): add live REALITY target scanner with IP/CIDR discovery
Replace the static reality-targets list with a server-side TLS 1.3 probe that checks TLS 1.3 + HTTP/2 + X25519 + a trusted certificate.

- Single-domain validate auto-fills target and serverNames from the cert SAN
- Discovery scans an IP/CIDR without SNI to find new targets from their certificates, deduped and ranked by feasibility then latency, private-IP guarded via netsafe
- New endpoints scanRealityTarget and scanRealityTargets with RealityScanResult, plus openapigen and api-docs entries
- Add scanner strings to all 13 locales
- Replace deprecated AntD Alert message prop with title across the panel
2026-06-26 22:18:47 +02:00

392 lines
10 KiB
Go

package service
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
)
const (
realityScanTimeout = 10 * time.Second
realityDiscoverTimeout = 4 * time.Second
realityScanConcurrency = 32
realityDiscoverMaxIPs = 256
realityScanMaxTotal = 512
)
var defaultRealityScanCandidates = []string{
"www.cloudflare.com:443",
"www.microsoft.com:443",
"www.amazon.com:443",
"aws.amazon.com:443",
"www.samsung.com:443",
"www.nvidia.com:443",
"www.amd.com:443",
"www.intel.com:443",
"www.sony.com:443",
"dl.google.com:443",
}
type RealityScanResult struct {
Target string `json:"target" example:"www.cloudflare.com:443"`
Host string `json:"host" example:"www.cloudflare.com"`
IP string `json:"ip" example:"104.16.124.96"`
Port int `json:"port" example:"443"`
Feasible bool `json:"feasible" example:"true"`
TLS13 bool `json:"tls13" example:"true"`
TLSVersion string `json:"tlsVersion" example:"1.3"`
H2 bool `json:"h2" example:"true"`
ALPN string `json:"alpn" example:"h2"`
X25519 bool `json:"x25519" example:"true"`
CurveID string `json:"curveID" example:"X25519"`
CertValid bool `json:"certValid" example:"true"`
CertSubject string `json:"certSubject" example:"cloudflare.com"`
CertIssuer string `json:"certIssuer" example:"Google Trust Services"`
NotAfter string `json:"notAfter" example:"2026-08-01T00:00:00Z"`
ServerNames []string `json:"serverNames"`
LatencyMs int `json:"latencyMs" example:"180"`
Reason string `json:"reason" example:""`
}
type realityProbeTask struct {
dialHost string
port int
sni string
timeout time.Duration
bulk bool
}
func tlsVersionName(v uint16) string {
switch v {
case tls.VersionTLS13:
return "1.3"
case tls.VersionTLS12:
return "1.2"
case tls.VersionTLS11:
return "1.1"
case tls.VersionTLS10:
return "1.0"
default:
return "unknown"
}
}
func realityCurveName(id tls.CurveID) string {
switch id {
case tls.X25519:
return "X25519"
case tls.X25519MLKEM768:
return "X25519MLKEM768"
case tls.CurveP256:
return "P-256"
case tls.CurveP384:
return "P-384"
case tls.CurveP521:
return "P-521"
case 0:
return ""
default:
return fmt.Sprintf("0x%04x", uint16(id))
}
}
func filterUsableSANs(dnsNames []string) []string {
out := make([]string, 0, len(dnsNames))
for _, n := range dnsNames {
n = strings.TrimSpace(n)
if n == "" || strings.HasPrefix(n, "*.") {
continue
}
out = append(out, n)
}
return out
}
func firstUsableName(leaf *x509.Certificate) string {
cn := strings.TrimSpace(leaf.Subject.CommonName)
if cn != "" && !strings.HasPrefix(cn, "*.") {
return cn
}
for _, n := range leaf.DNSNames {
n = strings.TrimSpace(n)
if n != "" && !strings.HasPrefix(n, "*.") {
return n
}
}
return ""
}
func splitRealityTarget(target string) (string, int, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", 0, common.NewError("target is required")
}
host, portStr := target, "443"
if h, p, err := net.SplitHostPort(target); err == nil {
host, portStr = h, p
}
host, err := netsafe.NormalizeHost(host)
if err != nil {
return "", 0, common.NewError("invalid target host: ", err)
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
return "", 0, common.NewError("invalid target port")
}
return host, port, nil
}
func incIP(ip net.IP) {
for j := len(ip) - 1; j >= 0; j-- {
ip[j]++
if ip[j] > 0 {
break
}
}
}
func enumerateCIDR(cidr string, max int) ([]string, error) {
_, ipnet, err := net.ParseCIDR(strings.TrimSpace(cidr))
if err != nil {
return nil, err
}
ips := make([]string, 0, max)
for ip := ipnet.IP.Mask(ipnet.Mask); ipnet.Contains(ip); incIP(ip) {
ips = append(ips, ip.String())
if len(ips) >= max {
break
}
}
return ips, nil
}
func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration) *RealityScanResult {
addr := net.JoinHostPort(dialHost, strconv.Itoa(port))
res := &RealityScanResult{Port: port}
if net.ParseIP(dialHost) != nil {
res.IP = dialHost
}
if sni != "" {
res.Host = sni
res.Target = net.JoinHostPort(sni, strconv.Itoa(port))
} else {
res.Host = dialHost
res.Target = addr
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
start := time.Now()
conn, err := netsafe.SSRFGuardedDialContext(ctx, "tcp", addr)
if err != nil {
res.Reason = "connection failed: " + err.Error()
return res
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
cfg := &tls.Config{
ServerName: sni,
InsecureSkipVerify: true,
NextProtos: []string{"h2", "http/1.1"},
CurvePreferences: []tls.CurveID{tls.X25519, tls.X25519MLKEM768},
MinVersion: tls.VersionTLS12,
}
tlsConn := tls.Client(conn, cfg)
if err := tlsConn.HandshakeContext(ctx); err != nil {
res.Reason = "TLS handshake failed: " + err.Error()
return res
}
res.LatencyMs = int(time.Since(start).Milliseconds())
st := tlsConn.ConnectionState()
res.TLS13 = st.Version == tls.VersionTLS13
res.TLSVersion = tlsVersionName(st.Version)
res.ALPN = st.NegotiatedProtocol
res.H2 = st.NegotiatedProtocol == "h2"
res.CurveID = realityCurveName(st.CurveID)
res.X25519 = st.CurveID == tls.X25519 || st.CurveID == tls.X25519MLKEM768
verifyHost := sni
if len(st.PeerCertificates) > 0 {
leaf := st.PeerCertificates[0]
res.CertSubject = leaf.Subject.CommonName
if res.CertSubject == "" && len(leaf.DNSNames) > 0 {
res.CertSubject = leaf.DNSNames[0]
}
if len(leaf.Issuer.Organization) > 0 {
res.CertIssuer = leaf.Issuer.Organization[0]
} else {
res.CertIssuer = leaf.Issuer.CommonName
}
res.NotAfter = leaf.NotAfter.UTC().Format(time.RFC3339)
res.ServerNames = filterUsableSANs(leaf.DNSNames)
if sni == "" {
if discovered := firstUsableName(leaf); discovered != "" {
res.Host = discovered
res.Target = net.JoinHostPort(discovered, strconv.Itoa(port))
verifyHost = discovered
}
}
if verifyHost != "" {
opts := x509.VerifyOptions{DNSName: verifyHost, Intermediates: x509.NewCertPool()}
for _, c := range st.PeerCertificates[1:] {
opts.Intermediates.AddCert(c)
}
if _, verr := leaf.Verify(opts); verr == nil {
res.CertValid = true
} else {
res.Reason = "certificate not trusted: " + verr.Error()
}
} else {
res.Reason = "no usable domain in certificate"
}
} else {
res.Reason = "no certificate presented"
}
res.Feasible = res.TLS13 && res.H2 && res.X25519 && res.CertValid
if !res.Feasible && res.Reason == "" {
switch {
case !res.TLS13:
res.Reason = "server does not negotiate TLS 1.3"
case !res.H2:
res.Reason = "server does not negotiate HTTP/2 (h2)"
case !res.X25519:
res.Reason = "server did not use X25519 key exchange"
}
}
return res
}
func (s *ServerService) probeRealityTarget(host string, port int) *RealityScanResult {
return s.probeRealityAddr(host, port, host, realityScanTimeout)
}
func (s *ServerService) ScanRealityTarget(target string) (*RealityScanResult, error) {
host, port, err := splitRealityTarget(target)
if err != nil {
return nil, err
}
return s.probeRealityTarget(host, port), nil
}
func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) {
var tokens []string
for _, raw := range strings.Split(targetsCSV, ",") {
if t := strings.TrimSpace(raw); t != "" {
tokens = append(tokens, t)
}
}
if len(tokens) == 0 {
tokens = append(tokens, defaultRealityScanCandidates...)
}
var tasks []realityProbeTask
var invalid []*RealityScanResult
for _, token := range tokens {
if len(tasks) >= realityScanMaxTotal {
break
}
if strings.Contains(token, "/") {
ips, err := enumerateCIDR(token, realityDiscoverMaxIPs)
if err != nil {
invalid = append(invalid, &RealityScanResult{Target: token, Reason: "invalid CIDR: " + err.Error()})
continue
}
for _, ip := range ips {
if len(tasks) >= realityScanMaxTotal {
break
}
tasks = append(tasks, realityProbeTask{dialHost: ip, port: 443, timeout: realityDiscoverTimeout, bulk: true})
}
continue
}
host, port, err := splitRealityTarget(token)
if err != nil {
invalid = append(invalid, &RealityScanResult{Target: token, Reason: err.Error()})
continue
}
if net.ParseIP(host) != nil {
tasks = append(tasks, realityProbeTask{dialHost: host, port: port, timeout: realityDiscoverTimeout})
} else {
tasks = append(tasks, realityProbeTask{dialHost: host, port: port, sni: host, timeout: realityScanTimeout})
}
}
probed := make([]*RealityScanResult, len(tasks))
sem := make(chan struct{}, realityScanConcurrency)
var wg sync.WaitGroup
for i, task := range tasks {
wg.Add(1)
sem <- struct{}{}
go func(idx int, tk realityProbeTask) {
defer wg.Done()
defer func() { <-sem }()
r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout)
if tk.bulk && r.TLSVersion == "" {
return
}
probed[idx] = r
}(i, task)
}
wg.Wait()
results := dedupRealityResults(append(probed, invalid...))
sortRealityResults(results)
return results, nil
}
func dedupRealityResults(results []*RealityScanResult) []*RealityScanResult {
best := make(map[string]*RealityScanResult)
order := make([]string, 0, len(results))
for _, r := range results {
if r == nil {
continue
}
if ex, ok := best[r.Target]; !ok {
best[r.Target] = r
order = append(order, r.Target)
} else if betterRealityResult(r, ex) {
best[r.Target] = r
}
}
out := make([]*RealityScanResult, 0, len(order))
for _, k := range order {
out = append(out, best[k])
}
return out
}
func betterRealityResult(a, b *RealityScanResult) bool {
if a.Feasible != b.Feasible {
return a.Feasible
}
return a.LatencyMs > 0 && (b.LatencyMs == 0 || a.LatencyMs < b.LatencyMs)
}
func sortRealityResults(results []*RealityScanResult) {
slices.SortStableFunc(results, func(a, b *RealityScanResult) int {
if a.Feasible != b.Feasible {
if a.Feasible {
return -1
}
return 1
}
return a.LatencyMs - b.LatencyMs
})
}