~bigbes/shroud

ref: 999bf1cb8045e0b245640d8f284d92f41b31b8a8 shroud/internal/reality/autodetect.go -rw-r--r-- 6.4 KiB
999bf1cb — Eugene Blikh feat(reality): add autodetect command for decoy server 2 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
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")
}