From 999bf1cb8045e0b245640d8f284d92f41b31b8a8 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 21 Mar 2026 01:30:46 +0300 Subject: [PATCH] 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. --- cmd/shroud/main.go | 129 +++++++++++++++++ install.sh | 42 ++++++ internal/reality/autodetect.go | 254 +++++++++++++++++++++++++++++++++ internal/reality/scanner.go | 9 +- 4 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 internal/reality/autodetect.go diff --git a/cmd/shroud/main.go b/cmd/shroud/main.go index 9bc52824b27f98f3fb48af3f2975806bdc424364..f667ad768998be470f7e2912b4d89adfab2bc071 100644 --- a/cmd/shroud/main.go +++ b/cmd/shroud/main.go @@ -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 { diff --git a/install.sh b/install.sh index e98e69a4d6fe3f5bd3de3e86c4f3f8656956af4d..fe7b9945022e2e3052895186949b99f379bec8bb 100755 --- a/install.sh +++ b/install.sh @@ -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 < -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 "" diff --git a/internal/reality/autodetect.go b/internal/reality/autodetect.go new file mode 100644 index 0000000000000000000000000000000000000000..6331a92e639c5ec4678f4f1f176cbbe31059266c --- /dev/null +++ b/internal/reality/autodetect.go @@ -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") +} diff --git a/internal/reality/scanner.go b/internal/reality/scanner.go index bf53f83ef24b9193893a1abece2f80c3e827d41f..853d3872b1a62a1bf144aaaaa902e0afa7050b00 100644 --- a/internal/reality/scanner.go +++ b/internal/reality/scanner.go @@ -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()