~bigbes/shroud

3defea82da661a79fbae52bda7225127ce4b13f8 — Eugene Blikh 2 months ago 3804bcf
feat(vless): add scan command for reality targets

Add CLI subcommand to scan TLS servers and identify those suitable for REALITY decoy configuration.
- Add vless scan command with multiple input sources (--addr, --in, --url).
- Implement TLS 1.3 and HTTP/2 ALPN feasibility check.
- Add GeoIP country code lookup for scanned hosts.
- Update geoip2-golang dependency to v1.13.0.
6 files changed, 499 insertions(+), 1 deletions(-)

M cmd/shroud/main.go
M go.mod
M go.sum
A internal/reality/geo.go
A internal/reality/input.go
A internal/reality/scanner.go
M cmd/shroud/main.go => cmd/shroud/main.go +199 -0
@@ 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 {

M go.mod => go.mod +1 -1
@@ 44,7 44,7 @@ require (
	github.com/mdlayher/wifi v0.6.0 // indirect
	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
	github.com/opencontainers/selinux v1.12.0 // indirect
	github.com/oschwald/geoip2-golang v1.11.0 // indirect
	github.com/oschwald/geoip2-golang v1.13.0 // indirect
	github.com/oschwald/maxminddb-golang v1.13.1 // indirect
	github.com/pires/go-proxyproto v0.7.0 // indirect
	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect

M go.sum => go.sum +2 -0
@@ 76,6 76,8 @@ github.com/opencontainers/selinux v1.12.0 h1:6n5JV4Cf+4y0KNXW48TLj5DwfXpvWlxXplU
github.com/opencontainers/selinux v1.12.0/go.mod h1:BTPX+bjVbWGXw7ZZWUbdENt8w0htPSrlgOOysQaU62U=
github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=

A internal/reality/geo.go => internal/reality/geo.go +44 -0
@@ 0,0 1,44 @@
package reality

import (
	"net"

	"github.com/oschwald/geoip2-golang"
)

// GeoResolver performs GeoIP country lookups using a MaxMind MMDB file.
type GeoResolver struct {
	reader *geoip2.Reader
}

// NewGeoResolver opens an MMDB file for country lookups.
// Returns nil resolver (not an error) if path is empty — callers get "N/A" for all lookups.
func NewGeoResolver(mmdbPath string) (*GeoResolver, error) {
	if mmdbPath == "" {
		return nil, nil
	}
	reader, err := geoip2.Open(mmdbPath)
	if err != nil {
		return nil, err
	}
	return &GeoResolver{reader: reader}, nil
}

// Lookup returns the ISO country code for an IP, or "N/A" if unavailable.
func (g *GeoResolver) Lookup(ip net.IP) string {
	if g == nil || g.reader == nil {
		return "N/A"
	}
	record, err := g.reader.Country(ip)
	if err != nil || record.Country.IsoCode == "" {
		return "N/A"
	}
	return record.Country.IsoCode
}

// Close releases the MMDB reader resources.
func (g *GeoResolver) Close() {
	if g != nil && g.reader != nil {
		g.reader.Close()
	}
}

A internal/reality/input.go => internal/reality/input.go +144 -0
@@ 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
}

A internal/reality/scanner.go => internal/reality/scanner.go +109 -0
@@ 0,0 1,109 @@
// Package reality implements TLS scanning to find servers suitable for REALITY protocol.
package reality

import (
	"context"
	"crypto/tls"
	"fmt"
	"net"
	"strings"
	"time"
)

// Host represents a target to scan.
type Host struct {
	IP     net.IP
	Origin string // original input (domain, IP, or CIDR string)
}

// ScanResult holds the outcome of a TLS scan.
type ScanResult struct {
	IP         string
	Origin     string
	CertDomain string // certificate Subject.CommonName
	CertIssuer string // pipe-delimited Issuer.Organization
	GeoCode    string // ISO country code, filled in by caller
	TLSVersion uint16
	ALPN       string
	Feasible   bool
	Error      error
}

// ScanConfig controls scanning behavior.
type ScanConfig struct {
	Port    int
	Timeout time.Duration
}

// ScanHost performs a TLS handshake against the given host and checks REALITY feasibility.
func ScanHost(ctx context.Context, host Host, cfg ScanConfig) ScanResult {
	result := ScanResult{
		IP:     host.IP.String(),
		Origin: host.Origin,
	}

	addr := fmt.Sprintf("%s:%d", host.IP.String(), cfg.Port)

	tlsCfg := &tls.Config{
		InsecureSkipVerify: true,
		NextProtos:         []string{"h2", "http/1.1"},
		CurvePreferences:   []tls.CurveID{tls.X25519},
	}

	// Set SNI if the origin is a domain name.
	if net.ParseIP(host.Origin) == nil {
		tlsCfg.ServerName = host.Origin
	}

	dialer := &net.Dialer{Timeout: cfg.Timeout}
	conn, err := dialer.DialContext(ctx, "tcp", addr)
	if err != nil {
		result.Error = fmt.Errorf("dial: %w", err)
		return result
	}
	defer conn.Close()

	_ = conn.SetDeadline(time.Now().Add(cfg.Timeout))

	tlsConn := tls.Client(conn, tlsCfg)
	if err := tlsConn.HandshakeContext(ctx); err != nil {
		result.Error = fmt.Errorf("tls handshake: %w", err)
		return result
	}
	defer tlsConn.Close()

	state := tlsConn.ConnectionState()
	result.TLSVersion = state.Version
	result.ALPN = state.NegotiatedProtocol

	if len(state.PeerCertificates) > 0 {
		cert := state.PeerCertificates[0]
		result.CertDomain = cert.Subject.CommonName
		if len(cert.Issuer.Organization) > 0 {
			result.CertIssuer = strings.Join(cert.Issuer.Organization, " | ")
		}
	}

	result.Feasible = result.TLSVersion == tls.VersionTLS13 &&
		result.ALPN == "h2" &&
		result.CertDomain != "" &&
		result.CertIssuer != ""

	return result
}

// TLSVersionName returns a human-readable name for a TLS version.
func TLSVersionName(v uint16) string {
	switch v {
	case tls.VersionTLS10:
		return "TLS 1.0"
	case tls.VersionTLS11:
		return "TLS 1.1"
	case tls.VersionTLS12:
		return "TLS 1.2"
	case tls.VersionTLS13:
		return "TLS 1.3"
	default:
		return fmt.Sprintf("0x%04x", v)
	}
}