~bigbes/shroud

999bf1cb8045e0b245640d8f284d92f41b31b8a8 — Eugene Blikh 2 months ago 3defea8
feat(reality): add autodetect command for decoy server

Add REALITY decoy server auto-detection with two scanning modes:
subnet scan (/24 around server IP) and domain list scan.
- Add vless autodetect CLI command with --write flag to update config.
- Add domain list mode for custom target selection.
- Add public IP detection via external services.
- Update install.sh to auto-configure REALITY with --vless flag.
- Track TLS handshake latency in ScanResult for best server selection.
4 files changed, 431 insertions(+), 3 deletions(-)

M cmd/shroud/main.go
M install.sh
A internal/reality/autodetect.go
M internal/reality/scanner.go
M cmd/shroud/main.go => cmd/shroud/main.go +129 -0
@@ 14,6 14,7 @@ import (
	"os"
	"os/signal"
	"path/filepath"
	"strings"
	"sync"
	"syscall"
	"time"


@@ 828,6 829,7 @@ func newVLESSCmd(configFile *string) *cobra.Command {
		newVLESSInfoCmd(configFile),
		newVLESSShareCmd(configFile),
		newVLESSScanCmd(),
		newVLESSAutodetectCmd(configFile),
	)
	return cmd
}


@@ 1144,6 1146,133 @@ func runVLESSScan(cmd *cobra.Command, addr, inFile, urlFlag string, port, thread
	return nil
}

func newVLESSAutodetectCmd(configFile *string) *cobra.Command {
	var (
		serverIP   string
		domainList string
		threads    int
		timeout    int
		write      bool
	)

	cmd := &cobra.Command{
		Use:   "autodetect",
		Short: "Auto-detect the best REALITY decoy server",
		Long: `Find the best TLS server to use as a REALITY decoy target.

Two modes:
  Subnet mode (default): scans the /24 subnet around the server's public IP.
  Domain list mode:      scans domains from a file (--domain-list).

Picks the fastest feasible server and outputs the recommended
server_names and dest values. Use --write to update the config file.`,
		RunE: func(cmd *cobra.Command, args []string) error {
			logger := slog.Default()

			acfg := reality.AutodetectConfig{
				ServerIP: serverIP,
				Port:     443,
				Threads:  threads,
				Timeout:  time.Duration(timeout) * time.Second,
				Logger:   logger,
			}

			// Load domains from file if specified.
			if domainList != "" {
				data, err := os.ReadFile(domainList)
				if err != nil {
					return fmt.Errorf("reading domain list %s: %w", domainList, err)
				}
				for _, line := range strings.Split(string(data), "\n") {
					line = strings.TrimSpace(line)
					if line != "" && !strings.HasPrefix(line, "#") {
						acfg.Domains = append(acfg.Domains, line)
					}
				}
				if len(acfg.Domains) == 0 {
					return fmt.Errorf("no domains found in %s", domainList)
				}
			}

			result, err := reality.Autodetect(cmd.Context(), acfg)
			if err != nil {
				return err
			}

			fmt.Printf("\nBest REALITY decoy target found:\n")
			fmt.Printf("  Server Name: %s\n", result.ServerName)
			fmt.Printf("  IP:          %s\n", result.IP)
			fmt.Printf("  Issuer:      %s\n", result.Issuer)
			fmt.Printf("  Latency:     %s\n", result.Latency.Round(time.Millisecond))
			fmt.Printf("\nRecommended config:\n")
			fmt.Printf("  server_names:\n    - %s\n", result.ServerName)
			fmt.Printf("  dest: \"%s:443\"\n", result.ServerName)

			if write {
				return writeAutodetectToConfig(*configFile, result)
			}
			return nil
		},
	}

	f := cmd.Flags()
	f.StringVar(&serverIP, "ip", "", "server public IP (auto-detected if empty; subnet mode)")
	f.StringVar(&domainList, "domain-list", "", "file with domains to scan (one per line; domain list mode)")
	f.IntVar(&threads, "threads", 32, "concurrent scan workers")
	f.IntVar(&timeout, "timeout", 5, "per-scan timeout in seconds")
	f.BoolVar(&write, "write", false, "write results to config file")

	return cmd
}

// writeAutodetectToConfig updates server_names and dest in the VLESS section of the config file.
func writeAutodetectToConfig(configFile string, result *reality.AutodetectResult) error {
	data, err := os.ReadFile(configFile)
	if err != nil {
		return fmt.Errorf("reading config %s: %w", configFile, err)
	}

	content := string(data)

	// Parse existing config to check if vless section exists.
	var raw map[string]any
	if err := yaml.Unmarshal(data, &raw); err != nil {
		return fmt.Errorf("parsing config: %w", err)
	}

	vlessSection, hasVLESS := raw["vless"].(map[string]any)
	if !hasVLESS {
		// Append a vless section.
		content += fmt.Sprintf(`
vless:
  enabled: true
  server_names:
    - %s
  dest: "%s:443"
`, result.ServerName, result.ServerName)
	} else {
		// Update existing section — use simple text replacement via yaml round-trip.
		vlessSection["server_names"] = []string{result.ServerName}
		vlessSection["dest"] = result.ServerName + ":443"
		if enabled, ok := vlessSection["enabled"].(bool); !ok || !enabled {
			vlessSection["enabled"] = true
		}
		raw["vless"] = vlessSection

		out, err := yaml.Marshal(raw)
		if err != nil {
			return fmt.Errorf("marshaling config: %w", err)
		}
		content = string(out)
	}

	if err := os.WriteFile(configFile, []byte(content), 0o640); err != nil {
		return fmt.Errorf("writing config %s: %w", configFile, err)
	}
	fmt.Printf("\nConfig updated: %s\n", configFile)
	return nil
}

func pickRandomPort() (int, error) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {

M install.sh => install.sh +42 -0
@@ 42,11 42,16 @@ AWG_DOMAIN=""
AWG_MTU="1420"
AWG_TUN="awg0"

VLESS_ENABLED="false"
VLESS_PORT="443"

# --- Parse arguments ---

while [[ $# -gt 0 ]]; do
    case "$1" in
        --awg)          AWG_ENABLED="true"; shift ;;
        --vless)        VLESS_ENABLED="true"; shift ;;
        --vless-port)   VLESS_PORT="$2"; shift 2 ;;
        --domain)       AWG_DOMAIN="$2"; shift 2 ;;
        --hostname)     HOSTNAME="$2"; shift 2 ;;
        --name)         SERVER_NAME="$2"; shift 2 ;;


@@ 62,6 67,8 @@ Usage: install.sh [OPTIONS]

Options:
  --awg                  Enable AmneziaWG protocol (with HTTP/3 mux)
  --vless                Enable VLESS+REALITY transport (auto-detects decoy server)
  --vless-port PORT      VLESS+REALITY listen port (default: 443)
  --domain DOMAIN        Domain for Let's Encrypt (enables HTTP/3 cover)
  --hostname HOST        Server hostname/IP for access key URLs
  --name NAME            Server display name


@@ 232,6 239,12 @@ amneziawg:
  h3: "${AWG_H3}"
  h4: "${AWG_H4}"

vless:
  enabled: ${VLESS_ENABLED}
  listen_addr: ":${VLESS_PORT}"
  server_names: []
  dest: ""

acme:
  cert_cache: ${DATA_DIR}/certs
  http_port: 80


@@ 244,6 257,18 @@ YAML
    ok "Config: $CONFIG_FILE"
fi

# --- VLESS autodetect ---

if [[ "$VLESS_ENABLED" == "true" ]]; then
    info "Auto-detecting best REALITY decoy server (scanning /24 subnet)..."
    if "$BINARY" vless autodetect --ip "$HOSTNAME" --write -c "$CONFIG_FILE" 2>&1; then
        ok "REALITY decoy server configured automatically"
    else
        warn "Auto-detection failed. You can run it manually later:"
        warn "  $BINARY vless autodetect --write -c $CONFIG_FILE"
    fi
fi

# --- Install systemd unit ---

info "Installing systemd unit..."


@@ 310,11 335,15 @@ if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "active"; then
        ufw allow "$AWG_PORT"/udp comment "shroud AWG+HTTP3" >/dev/null 2>&1 || true
        ufw allow 80/tcp comment "shroud ACME" >/dev/null 2>&1 || true
    fi
    if [[ "$VLESS_ENABLED" == "true" ]]; then
        ufw allow "$VLESS_PORT"/tcp comment "shroud VLESS+REALITY" >/dev/null 2>&1 || true
    fi
    ok "Firewall rules added"
else
    warn "ufw is not active. Ensure these ports are open:"
    warn "  TCP ${API_PORT} (management API)"
    [[ "$AWG_ENABLED" == "true" ]] && warn "  UDP ${AWG_PORT} (AWG + HTTP/3)  |  TCP 80 (ACME)"
    [[ "$VLESS_ENABLED" == "true" ]] && warn "  TCP ${VLESS_PORT} (VLESS+REALITY)"
fi

# --- IP forwarding for AWG ---


@@ 402,5 431,18 @@ EOF
EOF
fi

if [[ "$VLESS_ENABLED" == "true" ]]; then
    cat <<EOF
  VLESS+REALITY: TCP :${VLESS_PORT}

  Generate share link:
    ${BINARY} vless share <key-id> -c ${CONFIG_FILE}

  Re-run auto-detection:
    ${BINARY} vless autodetect --write -c ${CONFIG_FILE}

EOF
fi

echo "  Open the Shadowsocks port once keys are created (see 'key list')."
echo ""

A internal/reality/autodetect.go => internal/reality/autodetect.go +254 -0
@@ 0,0 1,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")
}

M internal/reality/scanner.go => internal/reality/scanner.go +6 -3
@@ 20,11 20,12 @@ type Host struct {
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
	CertDomain string        // certificate Subject.CommonName
	CertIssuer string        // pipe-delimited Issuer.Organization
	GeoCode    string        // ISO country code, filled in by caller
	TLSVersion uint16
	ALPN       string
	Latency    time.Duration // TLS handshake duration
	Feasible   bool
	Error      error
}


@@ 66,10 67,12 @@ func ScanHost(ctx context.Context, host Host, cfg ScanConfig) ScanResult {
	_ = conn.SetDeadline(time.Now().Add(cfg.Timeout))

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

	state := tlsConn.ConnectionState()