From 3defea82da661a79fbae52bda7225127ce4b13f8 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 19 Mar 2026 10:46:54 +0300 Subject: [PATCH] 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. --- cmd/shroud/main.go | 199 ++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 2 + internal/reality/geo.go | 44 ++++++++ internal/reality/input.go | 144 ++++++++++++++++++++++++++ internal/reality/scanner.go | 109 ++++++++++++++++++++ 6 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 internal/reality/geo.go create mode 100644 internal/reality/input.go create mode 100644 internal/reality/scanner.go diff --git a/cmd/shroud/main.go b/cmd/shroud/main.go index 51a46e3be5f69224046c01fd1eff376009ff58e6..9bc52824b27f98f3fb48af3f2975806bdc424364 100644 --- a/cmd/shroud/main.go +++ b/cmd/shroud/main.go @@ -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 { diff --git a/go.mod b/go.mod index 1f868b1709504e20a19bb6686744fd679de20193..101440f4b1f4f2fb1a63b0c23f2fe42de49b6602 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 03ea1e74726fbf5998893760c369e2419dfb8b79..519e3ea494da65381d84503558f655638f2d74ee 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/reality/geo.go b/internal/reality/geo.go new file mode 100644 index 0000000000000000000000000000000000000000..b1fb502228dd4a35c0ec0561eb15ff78e443654a --- /dev/null +++ b/internal/reality/geo.go @@ -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() + } +} diff --git a/internal/reality/input.go b/internal/reality/input.go new file mode 100644 index 0000000000000000000000000000000000000000..ab5cab3b35e01f460c24506f3c38077780a9188d --- /dev/null +++ b/internal/reality/input.go @@ -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 +} diff --git a/internal/reality/scanner.go b/internal/reality/scanner.go new file mode 100644 index 0000000000000000000000000000000000000000..bf53f83ef24b9193893a1abece2f80c3e827d41f --- /dev/null +++ b/internal/reality/scanner.go @@ -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) + } +}