|
|
@@ -4,6 +4,7 @@ import (
|
|
|
"context"
|
|
|
"crypto/tls"
|
|
|
"crypto/x509"
|
|
|
+ "errors"
|
|
|
"fmt"
|
|
|
"net"
|
|
|
"slices"
|
|
|
@@ -12,6 +13,7 @@ import (
|
|
|
"sync"
|
|
|
"time"
|
|
|
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
|
|
)
|
|
|
@@ -38,24 +40,30 @@ var defaultRealityScanCandidates = []string{
|
|
|
}
|
|
|
|
|
|
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:""`
|
|
|
+ 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"`
|
|
|
+ // PrivateTarget marks a target that resolves to a loopback/private/link-local
|
|
|
+ // address: blocked before the probe unless the caller opted in, then flagged.
|
|
|
+ PrivateTarget bool `json:"privateTarget" example:"false"`
|
|
|
+ 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"`
|
|
|
+ // CertChainValid ignores the name: a trusted chain presented for other names
|
|
|
+ // still has serverNames the panel can offer instead of the failing SNI.
|
|
|
+ CertChainValid bool `json:"certChainValid" 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 {
|
|
|
@@ -126,6 +134,11 @@ func firstUsableName(leaf *x509.Certificate) string {
|
|
|
return ""
|
|
|
}
|
|
|
|
|
|
+func leafVerifies(leaf *x509.Certificate, opts x509.VerifyOptions) bool {
|
|
|
+ _, err := leaf.Verify(opts)
|
|
|
+ return err == nil
|
|
|
+}
|
|
|
+
|
|
|
func splitRealityTarget(target string) (string, int, error) {
|
|
|
target = strings.TrimSpace(target)
|
|
|
if target == "" {
|
|
|
@@ -170,30 +183,38 @@ func enumerateCIDR(cidr string, max int) ([]string, error) {
|
|
|
return ips, nil
|
|
|
}
|
|
|
|
|
|
-func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration, xver int) *RealityScanResult {
|
|
|
+func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration, xver int, allowPrivate bool) *RealityScanResult {
|
|
|
addr := net.JoinHostPort(dialHost, strconv.Itoa(port))
|
|
|
res := &RealityScanResult{Port: port}
|
|
|
if net.ParseIP(dialHost) != nil {
|
|
|
res.IP = dialHost
|
|
|
}
|
|
|
+ // Target stays the dialed address (it is what the inbound dials); Host is
|
|
|
+ // the SNI the handshake sent, which may differ for a fronting proxy.
|
|
|
+ res.Host = dialHost
|
|
|
+ res.Target = addr
|
|
|
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)
|
|
|
+ ctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(context.Background(), allowPrivate), timeout)
|
|
|
defer cancel()
|
|
|
|
|
|
start := time.Now()
|
|
|
conn, err := netsafe.SSRFGuardedDialContext(ctx, "tcp", addr)
|
|
|
if err != nil {
|
|
|
+ res.PrivateTarget = errors.Is(err, netsafe.ErrPrivateAddressBlocked)
|
|
|
res.Reason = "connection failed: " + err.Error()
|
|
|
return res
|
|
|
}
|
|
|
defer conn.Close()
|
|
|
+ if remote, ok := conn.RemoteAddr().(*net.TCPAddr); ok {
|
|
|
+ res.PrivateTarget = netsafe.IsBlockedIP(remote.IP)
|
|
|
+ // The opt-in bypasses the SSRF guard, so leave an audit trail of it.
|
|
|
+ if res.PrivateTarget && allowPrivate {
|
|
|
+ logger.Infof("reality scan reached private target %s (%s) with the operator opt-in", addr, remote.IP)
|
|
|
+ }
|
|
|
+ }
|
|
|
_ = conn.SetDeadline(time.Now().Add(timeout))
|
|
|
|
|
|
// A REALITY inbound with xver>=1 fronts a target that speaks the PROXY
|
|
|
@@ -253,13 +274,18 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string,
|
|
|
}
|
|
|
|
|
|
if verifyHost != "" {
|
|
|
- opts := x509.VerifyOptions{DNSName: verifyHost, Intermediates: x509.NewCertPool()}
|
|
|
+ opts := x509.VerifyOptions{Intermediates: x509.NewCertPool()}
|
|
|
for _, c := range st.PeerCertificates[1:] {
|
|
|
opts.Intermediates.AddCert(c)
|
|
|
}
|
|
|
- if _, verr := leaf.Verify(opts); verr == nil {
|
|
|
+ // The chain is checked without the name first: a publicly trusted
|
|
|
+ // certificate for other names still carries usable serverNames.
|
|
|
+ res.CertChainValid = leafVerifies(leaf, opts)
|
|
|
+ opts.DNSName = verifyHost
|
|
|
+ if leafVerifies(leaf, opts) {
|
|
|
res.CertValid = true
|
|
|
} else {
|
|
|
+ _, verr := leaf.Verify(opts)
|
|
|
res.Reason = "certificate not trusted: " + verr.Error()
|
|
|
}
|
|
|
} else {
|
|
|
@@ -283,16 +309,20 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string,
|
|
|
return res
|
|
|
}
|
|
|
|
|
|
-func (s *ServerService) probeRealityTarget(host string, port int, xver int) *RealityScanResult {
|
|
|
- return s.probeRealityAddr(host, port, host, realityScanTimeout, xver)
|
|
|
-}
|
|
|
-
|
|
|
-func (s *ServerService) ScanRealityTarget(target string, xver int) (*RealityScanResult, error) {
|
|
|
+// ScanRealityTarget probes one operator-supplied target. An empty sni falls back
|
|
|
+// to the target host; allowPrivate lifts the SSRF guard for this probe only.
|
|
|
+func (s *ServerService) ScanRealityTarget(target string, sni string, xver int, allowPrivate bool) (*RealityScanResult, error) {
|
|
|
host, port, err := splitRealityTarget(target)
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
- return s.probeRealityTarget(host, port, xver), nil
|
|
|
+ sni = strings.TrimSpace(sni)
|
|
|
+ if sni == "" {
|
|
|
+ sni = host
|
|
|
+ } else if sni, err = netsafe.NormalizeHost(sni); err != nil {
|
|
|
+ return nil, common.NewError("invalid SNI: ", err)
|
|
|
+ }
|
|
|
+ return s.probeRealityAddr(host, port, sni, realityScanTimeout, xver, allowPrivate), nil
|
|
|
}
|
|
|
|
|
|
func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) {
|
|
|
@@ -347,7 +377,9 @@ func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanRes
|
|
|
go func(idx int, tk realityProbeTask) {
|
|
|
defer wg.Done()
|
|
|
defer func() { <-sem }()
|
|
|
- r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout, 0)
|
|
|
+ // The bulk/CIDR scanner never reaches private ranges: the opt-in
|
|
|
+ // there would turn it into an internal network scanner.
|
|
|
+ r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout, 0, false)
|
|
|
if tk.bulk && r.TLSVersion == "" {
|
|
|
return
|
|
|
}
|