@@ 14,6 14,7 @@ import (
"os"
"os/signal"
"path/filepath"
+ "sync"
"syscall"
"time"
@@ 32,6 33,7 @@ import (
"sourcecraft.dev/bigbes/shroud/internal/config"
"sourcecraft.dev/bigbes/shroud/internal/metrics"
"sourcecraft.dev/bigbes/shroud/internal/mmdb"
+ "sourcecraft.dev/bigbes/shroud/internal/reality"
"sourcecraft.dev/bigbes/shroud/internal/ssserver"
"sourcecraft.dev/bigbes/shroud/internal/store"
"sourcecraft.dev/bigbes/shroud/internal/vless"
@@ 825,6 827,7 @@ func newVLESSCmd(configFile *string) *cobra.Command {
newVLESSKeygenCmd(),
newVLESSInfoCmd(configFile),
newVLESSShareCmd(configFile),
+ newVLESSScanCmd(),
)
return cmd
}
@@ 945,6 948,202 @@ func newVLESSShareCmd(configFile *string) *cobra.Command {
}
}
+func newVLESSScanCmd() *cobra.Command {
+ var (
+ addr string
+ inFile string
+ urlFlag string
+ port int
+ threads int
+ timeout int
+ outFile string
+ ipv6 bool
+ )
+
+ cmd := &cobra.Command{
+ Use: "scan",
+ Short: "Scan TLS servers to find REALITY-suitable decoy targets",
+ Long: `Scan TLS servers and identify those suitable for use as REALITY decoy targets.
+A server is feasible if it negotiates TLS 1.3 with HTTP/2 ALPN and presents
+a certificate with a valid Common Name and Issuer.
+
+Exactly one of --addr, --in, or --url must be specified.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runVLESSScan(cmd, addr, inFile, urlFlag, port, threads, timeout, outFile, ipv6)
+ },
+ }
+
+ f := cmd.Flags()
+ f.StringVar(&addr, "addr", "", "single target: IP, CIDR, or domain")
+ f.StringVar(&inFile, "in", "", "file with line-separated targets")
+ f.StringVar(&urlFlag, "url", "", "HTTP URL to crawl for domains")
+ f.IntVar(&port, "port", 443, "target TLS port")
+ f.IntVar(&threads, "threads", 2, "number of concurrent scan workers")
+ f.IntVar(&timeout, "timeout", 10, "per-scan timeout in seconds")
+ f.StringVar(&outFile, "out", "out.csv", "output CSV file path")
+ f.BoolVar(&ipv6, "ipv6", false, "include IPv6 addresses")
+
+ return cmd
+}
+
+func runVLESSScan(cmd *cobra.Command, addr, inFile, urlFlag string, port, threads, timeout int, outFile string, ipv6 bool) error {
+ // Validate: exactly one input source.
+ sources := 0
+ if addr != "" {
+ sources++
+ }
+ if inFile != "" {
+ sources++
+ }
+ if urlFlag != "" {
+ sources++
+ }
+ if sources != 1 {
+ return fmt.Errorf("exactly one of --addr, --in, or --url must be specified")
+ }
+
+ logger := slog.Default()
+
+ scanCfg := reality.ScanConfig{
+ Port: port,
+ Timeout: time.Duration(timeout) * time.Second,
+ }
+
+ // Set up GeoIP resolver (best-effort).
+ var geo *reality.GeoResolver
+ cfg, cfgErr := config.Load(cmd.Root().Flag("config").Value.String())
+ if cfgErr == nil {
+ mmdbCfg := mmdb.Config{
+ CountryURL: cfg.Shadowsocks.IPCountryDB,
+ CacheDir: cfg.Shadowsocks.IPDBCacheDir,
+ }
+ mgr, err := mmdb.NewManager(mmdbCfg, logger)
+ if err == nil {
+ result, err := mgr.Resolve()
+ if err == nil {
+ geo, _ = reality.NewGeoResolver(result.CountryPath)
+ }
+ }
+ }
+ if geo == nil {
+ // Try with defaults if config loading failed.
+ mgr, err := mmdb.NewManager(mmdb.Config{}, logger)
+ if err == nil {
+ result, err := mgr.Resolve()
+ if err == nil {
+ geo, _ = reality.NewGeoResolver(result.CountryPath)
+ }
+ }
+ }
+ defer geo.Close()
+
+ // Open CSV output file.
+ csvFile, err := os.Create(outFile)
+ if err != nil {
+ return fmt.Errorf("creating output file: %w", err)
+ }
+ defer csvFile.Close()
+ fmt.Fprintln(csvFile, "IP,ORIGIN,CERT_DOMAIN,CERT_ISSUER,GEO_CODE")
+
+ // Set up context with SIGINT cancellation.
+ ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
+ defer cancel()
+
+ // Build host channel from input source.
+ hostCh := make(chan reality.Host, threads*4)
+ go func() {
+ defer close(hostCh)
+ switch {
+ case addr != "":
+ hosts, err := reality.ParseTarget(addr, ipv6)
+ if err != nil {
+ logger.Error("Failed to parse target.", "addr", addr, "err", err)
+ return
+ }
+ for _, h := range hosts {
+ select {
+ case hostCh <- h:
+ case <-ctx.Done():
+ return
+ }
+ }
+ case inFile != "":
+ f, err := os.Open(inFile)
+ if err != nil {
+ logger.Error("Failed to open input file.", "path", inFile, "err", err)
+ return
+ }
+ defer f.Close()
+ for h := range reality.ParseTargetsFromReader(f, ipv6) {
+ select {
+ case hostCh <- h:
+ case <-ctx.Done():
+ return
+ }
+ }
+ case urlFlag != "":
+ hosts, err := reality.ParseTargetsFromURL(urlFlag, ipv6)
+ if err != nil {
+ logger.Error("Failed to fetch URL targets.", "url", urlFlag, "err", err)
+ return
+ }
+ for _, h := range hosts {
+ select {
+ case hostCh <- h:
+ case <-ctx.Done():
+ return
+ }
+ }
+ }
+ }()
+
+ // Worker pool.
+ var (
+ wg sync.WaitGroup
+ mu sync.Mutex
+ total int
+ feasible int
+ )
+ for i := 0; i < threads; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for host := range hostCh {
+ result := reality.ScanHost(ctx, host, scanCfg)
+ result.GeoCode = geo.Lookup(host.IP)
+
+ mu.Lock()
+ total++
+ if result.Error != nil {
+ logger.Debug("Scan failed.",
+ "ip", result.IP, "origin", result.Origin, "err", result.Error)
+ } else if result.Feasible {
+ feasible++
+ fmt.Fprintf(csvFile, "%s,%s,%s,%q,%s\n",
+ result.IP, result.Origin, result.CertDomain, result.CertIssuer, result.GeoCode)
+ logger.Info("FEASIBLE",
+ "ip", result.IP, "origin", result.Origin,
+ "tls", reality.TLSVersionName(result.TLSVersion),
+ "alpn", result.ALPN,
+ "cn", result.CertDomain, "issuer", result.CertIssuer,
+ "geo", result.GeoCode)
+ } else {
+ logger.Debug("Not feasible.",
+ "ip", result.IP, "origin", result.Origin,
+ "tls", reality.TLSVersionName(result.TLSVersion),
+ "alpn", result.ALPN,
+ "cn", result.CertDomain, "issuer", result.CertIssuer)
+ }
+ mu.Unlock()
+ }
+ }()
+ }
+
+ wg.Wait()
+ fmt.Printf("\nScan complete: %d scanned, %d feasible. Results written to %s\n", total, feasible, outFile)
+ return nil
+}
+
func pickRandomPort() (int, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ 0,0 1,144 @@
+package reality
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/netip"
+ "regexp"
+ "strings"
+ "time"
+)
+
+var domainRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9\-.]+\.[A-Za-z]{2,}$`)
+
+// urlDomainRe extracts domains from HTML/text content.
+var urlDomainRe = regexp.MustCompile(`https?://([A-Za-z0-9][A-Za-z0-9\-.]+\.[A-Za-z]{2,})`)
+
+// ParseTarget parses a single string as IP, CIDR, or domain and returns hosts.
+func ParseTarget(s string, ipv6 bool) ([]Host, error) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return nil, nil
+ }
+
+ // Try as CIDR.
+ if strings.Contains(s, "/") {
+ prefix, err := netip.ParsePrefix(s)
+ if err != nil {
+ return nil, fmt.Errorf("invalid CIDR %q: %w", s, err)
+ }
+ return expandCIDR(prefix, ipv6), nil
+ }
+
+ // Try as IP.
+ if ip := net.ParseIP(s); ip != nil {
+ if !ipv6 && ip.To4() == nil {
+ return nil, nil
+ }
+ return []Host{{IP: ip, Origin: s}}, nil
+ }
+
+ // Try as domain.
+ if domainRe.MatchString(s) {
+ return resolveDomain(s, ipv6)
+ }
+
+ return nil, fmt.Errorf("unrecognized target %q", s)
+}
+
+// ParseTargetsFromReader reads line-separated targets and sends hosts to the returned channel.
+func ParseTargetsFromReader(r io.Reader, ipv6 bool) <-chan Host {
+ ch := make(chan Host, 64)
+ go func() {
+ defer close(ch)
+ scanner := bufio.NewScanner(r)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ hosts, err := ParseTarget(line, ipv6)
+ if err != nil {
+ continue // skip invalid lines
+ }
+ for _, h := range hosts {
+ ch <- h
+ }
+ }
+ }()
+ return ch
+}
+
+// ParseTargetsFromURL fetches a URL, extracts domains from the response body, and returns hosts.
+func ParseTargetsFromURL(url string, ipv6 bool) ([]Host, error) {
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Get(url)
+ if err != nil {
+ return nil, fmt.Errorf("fetching %s: %w", url, err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10 MB limit
+ if err != nil {
+ return nil, fmt.Errorf("reading response: %w", err)
+ }
+
+ matches := urlDomainRe.FindAllSubmatch(body, -1)
+ seen := make(map[string]bool)
+ var hosts []Host
+
+ for _, m := range matches {
+ domain := string(m[1])
+ if seen[domain] {
+ continue
+ }
+ seen[domain] = true
+
+ resolved, err := resolveDomain(domain, ipv6)
+ if err != nil {
+ continue
+ }
+ hosts = append(hosts, resolved...)
+ }
+
+ return hosts, nil
+}
+
+func expandCIDR(prefix netip.Prefix, ipv6 bool) []Host {
+ var hosts []Host
+ origin := prefix.String()
+ for addr := prefix.Addr(); prefix.Contains(addr); addr = addr.Next() {
+ if !ipv6 && !addr.Is4() {
+ continue
+ }
+ hosts = append(hosts, Host{
+ IP: addr.AsSlice(),
+ Origin: origin,
+ })
+ }
+ return hosts
+}
+
+func resolveDomain(domain string, ipv6 bool) ([]Host, error) {
+ ips, err := net.LookupIP(domain)
+ if err != nil {
+ return nil, fmt.Errorf("resolving %s: %w", domain, err)
+ }
+ var hosts []Host
+ for _, ip := range ips {
+ if !ipv6 && ip.To4() == nil {
+ continue
+ }
+ hosts = append(hosts, Host{
+ IP: ip,
+ Origin: domain,
+ })
+ }
+ if len(hosts) == 0 {
+ return nil, fmt.Errorf("no suitable addresses for %s", domain)
+ }
+ return hosts, nil
+}