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()