package reality
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"sort"
"strings"
"sync"
"time"
)
// AutodetectConfig controls the autodetect scan.
type AutodetectConfig struct {
// ServerIP is the server's own public IP. If empty, it will be detected automatically.
// Used for /24 subnet scanning mode.
ServerIP string
// Domains is a list of domain names to scan directly (instead of /24 subnet).
// When set, ServerIP is ignored and domains are resolved and scanned.
Domains []string
// Port to scan (default 443).
Port int
// Threads for concurrent scanning.
Threads int
// Timeout per TLS handshake.
Timeout time.Duration
// Logger for progress output.
Logger *slog.Logger
}
// AutodetectResult holds the best server found by autodetect.
type AutodetectResult struct {
ServerName string // domain to use for server_names and dest
IP string // IP address of the server
Issuer string // certificate issuer
Latency time.Duration // TLS handshake latency
}
// Autodetect finds the best REALITY decoy target by scanning either:
// - a list of domains (when cfg.Domains is set), or
// - the /24 subnet around the server's public IP (default).
//
// Returns the fastest feasible result.
func Autodetect(ctx context.Context, cfg AutodetectConfig) (*AutodetectResult, error) {
if cfg.Port == 0 {
cfg.Port = 443
}
if cfg.Threads == 0 {
cfg.Threads = 32
}
if cfg.Timeout == 0 {
cfg.Timeout = 5 * time.Second
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
var hosts []Host
var description string
if len(cfg.Domains) > 0 {
// Domain list mode: resolve each domain and scan.
description = fmt.Sprintf("%d domains", len(cfg.Domains))
cfg.Logger.Info("Scanning domain list for REALITY-suitable servers...",
"domains", len(cfg.Domains), "threads", cfg.Threads)
for _, domain := range cfg.Domains {
domain = strings.TrimSpace(domain)
if domain == "" || strings.HasPrefix(domain, "#") {
continue
}
resolved, err := resolveDomainForAutodetect(domain)
if err != nil {
cfg.Logger.Debug("Failed to resolve domain.", "domain", domain, "err", err)
continue
}
hosts = append(hosts, resolved...)
}
if len(hosts) == 0 {
return nil, fmt.Errorf("no domains could be resolved from the provided list")
}
} else {
// Subnet mode: scan /24 around server IP.
serverIP := cfg.ServerIP
if serverIP == "" {
var err error
serverIP, err = DetectPublicIP(ctx, cfg.Timeout)
if err != nil {
return nil, fmt.Errorf("detecting public IP: %w", err)
}
cfg.Logger.Info("Detected public IP.", "ip", serverIP)
}
addr, err := netip.ParseAddr(serverIP)
if err != nil {
return nil, fmt.Errorf("parsing server IP %q: %w", serverIP, err)
}
prefix := netip.PrefixFrom(addr, 24)
prefix = prefix.Masked()
description = prefix.String()
cfg.Logger.Info("Scanning /24 subnet for REALITY-suitable servers...",
"subnet", prefix.String(), "threads", cfg.Threads)
for a := prefix.Addr(); prefix.Contains(a); a = a.Next() {
ip := a.As4()
if ip[3] == 0 || ip[3] == 255 {
continue
}
if a == addr {
continue
}
hosts = append(hosts, Host{
IP: net.IP(a.AsSlice()),
Origin: a.String(),
})
}
}
return scanAndPickBest(ctx, hosts, cfg, description)
}
func scanAndPickBest(ctx context.Context, hosts []Host, cfg AutodetectConfig, description string) (*AutodetectResult, error) {
scanCfg := ScanConfig{
Port: cfg.Port,
Timeout: cfg.Timeout,
}
hostCh := make(chan Host, cfg.Threads*2)
go func() {
defer close(hostCh)
for _, h := range hosts {
select {
case hostCh <- h:
case <-ctx.Done():
return
}
}
}()
var (
mu sync.Mutex
results []ScanResult
wg sync.WaitGroup
scanned int
feasible int
)
for i := 0; i < cfg.Threads; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for host := range hostCh {
result := ScanHost(ctx, host, scanCfg)
mu.Lock()
scanned++
if result.Feasible {
feasible++
results = append(results, result)
cfg.Logger.Info("Found feasible server.",
"ip", result.IP, "origin", result.Origin, "cn", result.CertDomain,
"issuer", result.CertIssuer, "latency", result.Latency.Round(time.Millisecond))
}
if scanned%50 == 0 {
cfg.Logger.Info("Scan progress...", "scanned", scanned, "total", len(hosts), "feasible", feasible)
}
mu.Unlock()
}
}()
}
wg.Wait()
cfg.Logger.Info("Scan complete.", "scanned", scanned, "feasible", feasible)
if len(results) == 0 {
return nil, fmt.Errorf("no REALITY-suitable servers found in %s", description)
}
sort.Slice(results, func(i, j int) bool {
return results[i].Latency < results[j].Latency
})
best := results[0]
return &AutodetectResult{
ServerName: best.CertDomain,
IP: best.IP,
Issuer: best.CertIssuer,
Latency: best.Latency,
}, nil
}
// resolveDomainForAutodetect resolves a domain to IPv4 hosts with the domain as Origin
// (so that ScanHost sets SNI correctly).
func resolveDomainForAutodetect(domain string) ([]Host, error) {
ips, err := net.LookupIP(domain)
if err != nil {
return nil, err
}
var hosts []Host
for _, ip := range ips {
if ip.To4() == nil {
continue // IPv4 only
}
hosts = append(hosts, Host{
IP: ip,
Origin: domain,
})
}
if len(hosts) == 0 {
return nil, fmt.Errorf("no IPv4 addresses for %s", domain)
}
return hosts, nil
}
// ipServices are HTTP endpoints that return the caller's public IP as plain text.
var ipServices = []string{
"https://ifconfig.me",
"https://api.ipify.org",
"https://icanhazip.com",
}
// DetectPublicIP discovers the server's public IPv4 address by querying external services.
func DetectPublicIP(ctx context.Context, timeout time.Duration) (string, error) {
if timeout == 0 {
timeout = 5 * time.Second
}
client := &http.Client{Timeout: timeout}
for _, svc := range ipServices {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc, nil)
if err != nil {
continue
}
resp, err := client.Do(req)
if err != nil {
continue
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
resp.Body.Close()
if err != nil || resp.StatusCode != http.StatusOK {
continue
}
ip := strings.TrimSpace(string(body))
if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil {
return ip, nil
}
}
return "", fmt.Errorf("could not determine public IP from any service")
}