From 476aa9096861687168c8a89aa711f5dd42387b1a Mon Sep 17 00:00:00 2001 From: sajadMRjl Date: Wed, 28 Jan 2026 03:07:06 +0330 Subject: [PATCH] fix: add config and logger, cleanup --- .env.example | 4 ++ cmd/main.go | 79 ++++++++++++++++----------------- go.mod | 6 ++- go.sum | 4 ++ internal/config/config.go | 36 +++++++++++++++ internal/filter/filter.go | 40 ++++++++--------- internal/logger/logger.go | 31 +++++++++++++ internal/parser/parser.go | 93 +++++++++++++++++++++++++++++---------- internal/tester/runner.go | 53 ++++++++++------------ 9 files changed, 228 insertions(+), 118 deletions(-) create mode 100644 .env.example create mode 100644 internal/config/config.go create mode 100644 internal/logger/logger.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1250d10 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +LOG_LEVEL=DEBUG +MAX_WORKERS=20 +SING_BOX_PATH=./bin/sing-box +TCP_TIMEOUT=1500ms \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 1b5c54f..86049f4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,95 +1,90 @@ package main import ( - "fmt" + "log/slog" "os" "sync" "time" + "find-me-internet/internal/config" "find-me-internet/internal/filter" + "find-me-internet/internal/logger" "find-me-internet/internal/parser" "find-me-internet/internal/tester" ) -const ( - SingBoxPath = "./bin/sing-box" // Make sure this exists! - TestTarget = "http://cp.cloudflare.com" - MaxWorkers = 10 -) - func main() { - // 0. Setup - // Check if binary exists - if _, err := os.Stat(SingBoxPath); os.IsNotExist(err) { - fmt.Printf("Error: sing-box binary not found at %s\n", SingBoxPath) - return + // 1. Initialization + cfg := config.Load() + logger.Setup(cfg.LogLevel) + + // Verify Sing-box binary + if _, err := os.Stat(cfg.SingBoxPath); os.IsNotExist(err) { + slog.Error("Sing-box binary not found", "path", cfg.SingBoxPath) + os.Exit(1) } - // Mock Input Data (Replace with file reading logic later) + slog.Info("Starting Proxy Scanner", "workers", cfg.Workers, "level", cfg.LogLevel) + + // Mock Data (Replace with File Reader later) rawLinks := []string{ "vless://4525c260-df3c-4f62-b8f1-f4f5f305694b@66.81.247.155:443?encryption=none&security=tls&sni=yyzsuabw9e3qd5ud7ihi5dxm96oglnsvr83cjojnm1efncfhr9ucordq.zjde5.de5.net&fp=chrome&insecure=0&allowInsecure=0&type=ws&host=yyzsuabw9e3qd5ud7ihi5dxm96oglnsvr83cjojnm1efncfhr9ucordq.zjde5.de5.net&path=%2F%3Fed#%DA%86%D9%86%D9%84%20%D8%AA%D9%84%DA%AF%D8%B1%D8%A7%D9%85%20%3A%20%40CroSs_Guildd%F0%9F%92%8A", "vless://efdb2890-6dd7-4e65-8984-f0b1d3ae4e01@here-we-go-again.embeddedonline.org:443?encryption=none&security=tls&sni=here-we-go-again.embeddedonline.org&fp=chrome&alpn=http%2F1.1&insecure=0&allowInsecure=0&type=ws&host=here-we-go-again.embeddedonline.org&path=%2FJ1jTS0GMxqS0Atmd5x#here-we-go-again.embeddedonline.org%20tls%20WS%20direct%20vless", // Add more links here... } - fmt.Printf("Loaded %d links. Starting scan...\n", len(rawLinks)) + // 2. Setup Pipelines + netFilter := filter.NewPipeline(cfg.TcpTimeout) + boxRunner := tester.NewRunner(cfg.SingBoxPath, cfg.TestURL, cfg.TestTimeout) - // Pipelines - netFilter := filter.NewPipeline(2 * time.Second) - boxRunner := tester.NewRunner(SingBoxPath, TestTarget, 5*time.Second) - - // Concurrency Controls + // 3. Concurrency Control var wg sync.WaitGroup - semaphore := make(chan struct{}, MaxWorkers) // Limit active Sing-box instances - - // Results - successCount := 0 - var mu sync.Mutex + semaphore := make(chan struct{}, cfg.Workers) // Limits concurrent Sing-box instances startTotal := time.Now() + validCount := 0 + // 4. Processing Loop for _, link := range rawLinks { wg.Add(1) go func(raw string) { defer wg.Done() - // --- STAGE 1: PARSE --- + // Step A: Parse proxy, err := parser.ParseLink(raw) if err != nil { - // fmt.Printf("Invalid Link: %v\n", err) + slog.Debug("Parse failed", "err", err) return } - // --- STAGE 2: CHEAP FILTER --- - // Check TCP and TLS Handshake first + // Step B: Cheap Filter (TCP/TLS) if !netFilter.Check(proxy) { - // fmt.Printf("[DEAD] %s:%d\n", proxy.Address, proxy.Port) + // Failed cheap checks, discard silently or debug return } - // --- STAGE 3: EXPENSIVE TEST --- - semaphore <- struct{}{} // Acquire worker slot + // Step C: Expensive Test (Sing-box) + semaphore <- struct{}{} // Acquire lock err = boxRunner.Test(proxy) - <-semaphore // Release worker slot + <-semaphore // Release lock if err != nil { - fmt.Printf("[FAIL] %s (%v)\n", proxy.SNI, err) + slog.Debug("Test failed", "proxy", proxy.Address, "err", err) return } - // --- SUCCESS --- - mu.Lock() - successCount++ - mu.Unlock() - - fmt.Printf("✅ [OK] %s | Latency: %dms | Type: %s\n", - proxy.Address, proxy.Latency.Milliseconds(), proxy.Type) + // Success! + validCount++ + slog.Info("Valid Proxy Found", + "addr", proxy.Address, + "latency", proxy.Latency.Milliseconds(), + "type", proxy.Type, + ) }(link) } wg.Wait() - fmt.Printf("\n--- Scan Complete in %s ---\n", time.Since(startTotal)) - fmt.Printf("Valid Proxies Found: %d\n", successCount) + slog.Info("Scan Complete", "duration", time.Since(startTotal), "valid", validCount) } \ No newline at end of file diff --git a/go.mod b/go.mod index 04fa476..5a5c42e 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,11 @@ module find-me-internet go 1.25.6 -require github.com/gvcgo/vpnparser v0.2.7 +require ( + github.com/gvcgo/vpnparser v0.2.7 + github.com/joho/godotenv v1.5.1 + github.com/kelseyhightower/envconfig v1.4.0 +) require ( atomicgo.dev/cursor v0.1.1 // indirect diff --git a/go.sum b/go.sum index 4ebff3e..3a0c73a 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,10 @@ github.com/gvcgo/goutils v0.8.5 h1:0QPLOl5lfjV9vIAUnB5MPze5YpfMkrnxUW512ZjH82Q= github.com/gvcgo/goutils v0.8.5/go.mod h1:g/gPJxbpSiLK0q8a1qpkaf6ec3LOcxb7jj1ekDsKqzY= github.com/gvcgo/vpnparser v0.2.7 h1:+uezF5c00ROSKsD6a3ysmx3BdHxzI1ICkzuWeCHDQww= github.com/gvcgo/vpnparser v0.2.7/go.mod h1:JQwo6gDtzYAQgO0o63FvP0db/eO+7QKy3TnTAI0o2wA= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f592f8e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,36 @@ +package config + +import ( + "log" + "time" + + "github.com/joho/godotenv" + "github.com/kelseyhightower/envconfig" +) + +type Config struct { + // App Settings + LogLevel string `envconfig:"LOG_LEVEL" default:"INFO"` // INFO, DEBUG, ERROR + Workers int `envconfig:"MAX_WORKERS" default:"10"` + + // Paths + SingBoxPath string `envconfig:"SING_BOX_PATH" default:"./bin/sing-box"` + + // Testing Parameters + TestURL string `envconfig:"TEST_URL" default:"http://cp.cloudflare.com"` + TcpTimeout time.Duration `envconfig:"TCP_TIMEOUT" default:"2s"` + TestTimeout time.Duration `envconfig:"TEST_TIMEOUT" default:"10s"` +} + +// Load reads .env and maps variables to Config struct +func Load() *Config { + // 1. Try loading .env file (optional, for local dev) + _ = godotenv.Load() + + var cfg Config + // 2. Process environment variables + if err := envconfig.Process("", &cfg); err != nil { + log.Fatalf("Failed to load config: %v", err) + } + return &cfg +} \ No newline at end of file diff --git a/internal/filter/filter.go b/internal/filter/filter.go index cc3468a..49a23c2 100644 --- a/internal/filter/filter.go +++ b/internal/filter/filter.go @@ -2,14 +2,15 @@ package filter import ( "crypto/tls" + "log/slog" "net" - "time" "strconv" - + "time" + "find-me-internet/internal/model" ) -// Pipeline represents the filter configuration +// Pipeline manages the "Cheap Check" logic type Pipeline struct { Timeout time.Duration } @@ -18,29 +19,29 @@ func NewPipeline(timeout time.Duration) *Pipeline { return &Pipeline{Timeout: timeout} } -// Check performs the TCP & TLS checks -// Returns true if the proxy is worth testing with Sing-box +// Check runs a sequence of low-cost network tests. +// Returns false immediately if any stage fails. func (f *Pipeline) Check(p *model.Proxy) bool { - // 1. Syntax Check - if p.Address == "" || p.Port == 0 || p.Type == model.TypeUnknown { + // 1. Sanity Check + if p.Address == "" || p.Port == 0 { return false } - // 2. TCP Connect (Liveness) + // 2. TCP Liveness (Is the port open?) if !f.checkTCP(p) { + slog.Debug("TCP Connection failed", "addr", p.Address, "port", p.Port) p.IsOnline = false - return false // Dead + return false } p.IsOnline = true - // 3. TLS Handshake (Validity) - // Only run this if the proxy uses TLS (SNI is present or it's a TLS protocol) - // For VLESS/Trojan, TLS is standard. For VMess, it's optional. + // 3. TLS Validity (Does it handshake?) + // Only required if SNI is present or port is standard HTTPS if p.SNI != "" || p.Port == 443 { if !f.checkTLS(p) { + slog.Debug("TLS Handshake failed", "addr", p.Address, "sni", p.SNI) p.IsTLSSecure = false - // If it expects TLS but fails handshake, it's likely a firewall block or dead cert - return false + return false } p.IsTLSSecure = true } @@ -48,7 +49,6 @@ func (f *Pipeline) Check(p *model.Proxy) bool { return true } -// checkTCP attempts a raw socket connection func (f *Pipeline) checkTCP(p *model.Proxy) bool { address := net.JoinHostPort(p.Address, strconv.Itoa(p.Port)) conn, err := net.DialTimeout("tcp", address, f.Timeout) @@ -59,20 +59,18 @@ func (f *Pipeline) checkTCP(p *model.Proxy) bool { return true } -// checkTLS attempts a TLS handshake func (f *Pipeline) checkTLS(p *model.Proxy) bool { address := net.JoinHostPort(p.Address, strconv.Itoa(p.Port)) - dialer := &net.Dialer{Timeout: f.Timeout} - // We use InsecureSkipVerify because many proxies use self-signed certs or Reality. - // We only care that the server *speaks* TLS and accepts our SNI. + // We skip verification because many proxies use self-signed certs or Reality. + // The goal is to check if the server *speaks* TLS, not if the cert is trusted by Root CAs. conf := &tls.Config{ InsecureSkipVerify: true, - ServerName: p.SNI, + ServerName: p.SNI, } - // If no SNI is parsed, try the host address (common for direct connections) + // Fallback SNI if none provided if conf.ServerName == "" { conf.ServerName = p.Address } diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..715f835 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,31 @@ +package logger + +import ( + "log/slog" + "os" + "strings" +) + +// Setup initializes the global logger based on config +func Setup(level string) { + var logLevel slog.Level + + switch strings.ToUpper(level) { + case "DEBUG": + logLevel = slog.LevelDebug + case "WARN": + logLevel = slog.LevelWarn + case "ERROR": + logLevel = slog.LevelError + default: + logLevel = slog.LevelInfo + } + + // Create a structured text handler (easier to read than JSON in terminal) + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: logLevel, + }) + + logger := slog.New(handler) + slog.SetDefault(logger) +} \ No newline at end of file diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 9d262fe..b2950da 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -3,6 +3,7 @@ package parser import ( "encoding/json" "fmt" + "log/slog" "strings" "find-me-internet/internal/model" @@ -10,11 +11,13 @@ import ( "github.com/gvcgo/vpnparser/pkgs/outbound" ) -// tempConfig allows us to extract deep fields from the Sing-box JSON +// tempConfig covers multiple locations where SNI might be hidden in the JSON type tempConfig struct { Transport struct { Type string `json:"type"` } `json:"transport"` + + // Standard TLS TLS struct { ServerName string `json:"server_name"` } `json:"tls"` @@ -26,22 +29,42 @@ func ParseLink(raw string) (*model.Proxy, error) { return nil, fmt.Errorf("empty link") } - // 1. Parse Raw Link - // We omit the second argument to let the library use default parsing + // 1. Parse using library item := outbound.ParseRawUriToProxyItem(raw) if item == nil { - return nil, fmt.Errorf("unknown protocol or invalid link") + return nil, fmt.Errorf("invalid link") } - // 2. Initialize Proxy Model p := &model.Proxy{ RawLink: raw, Address: item.Address, Port: item.Port, } - // 3. Extract SNI and Network from the Outbound JSON - // The library packs the details into 'item.Outbound' string + // 2. Clean up Protocol (Fixing the "vless://" bug) + // Some versions of the lib return "vless://" instead of "vless" + scheme := strings.ToLower(item.Scheme) + scheme = strings.TrimSuffix(scheme, "://") + + switch scheme { + case "vless": + p.Type = model.TypeVLESS + if strings.Contains(raw, "reality") { + p.Type = model.TypeVLESS + } + case "vmess": + p.Type = model.TypeVMess + case "trojan": + p.Type = model.TypeTrojan + case "ss", "shadowsocks": + p.Type = model.TypeShadowsocks + default: + p.Type = model.TypeUnknown + slog.Warn("Unknown protocol", "scheme", scheme, "raw", item.Scheme) + } + + // 3. Extract SNI (Fixing the "sni=" bug) + // We first try to get it from the standard fields if item.Outbound != "" { var cfg tempConfig if err := json.Unmarshal([]byte(item.Outbound), &cfg); err == nil { @@ -49,26 +72,48 @@ func ParseLink(raw string) (*model.Proxy, error) { p.SNI = cfg.TLS.ServerName } } - - // Fallback: If JSON extraction failed but we have a generic "Host" (sometimes used as SNI) - // Note: 'item.Host' doesn't exist either, so we rely solely on JSON extraction above. - // 4. Map Protocol (Library uses 'Scheme') - switch strings.ToLower(item.Scheme) { - case "vless": - p.Type = model.TypeVLESS - if strings.Contains(raw, "reality") { - p.Type = model.TypeVLESS // Reality is technically VLESS + // 4. Fallback for SNI (Crucial for Reality/VLESS) + // If JSON extraction failed, try to parse the raw URL query parameters manually. + // This is often more reliable than the JSON dump for simple fields. + if p.SNI == "" { + // Quick and dirty manual check for "&sni=..." or "&peer=..." + if val := extractQueryParam(raw, "sni"); val != "" { + p.SNI = val + } else if val := extractQueryParam(raw, "peer"); val != "" { + p.SNI = val // "peer" is often used in Telegram proxies as SNI + } else if val := extractQueryParam(raw, "host"); val != "" { + p.SNI = val } - case "vmess": - p.Type = model.TypeVMess - case "trojan": - p.Type = model.TypeTrojan - case "shadowsocks", "ss": - p.Type = model.TypeShadowsocks - default: - p.Type = model.TypeUnknown + } + + // 5. Final Safety: Reality MUST have an SNI + // If we still don't have one, the TLS check will inevitably fail. + // We can warn here. + if p.Type == model.TypeVLESS && p.SNI == "" { + slog.Debug("Warning: VLESS proxy has no SNI", "addr", p.Address) } return p, nil +} + +// Helper to manually grab query params from the raw string +// because sometimes the parser library logic is opaque. +func extractQueryParam(url, key string) string { + // Find "key=" + keyStr := key + "=" + start := strings.Index(url, keyStr) + if start == -1 { + return "" + } + // Move to value start + start += len(keyStr) + + // Find end of value (either '&' or '#') + rest := url[start:] + end := strings.IndexAny(rest, "&#") + if end == -1 { + return rest + } + return rest[:end] } \ No newline at end of file diff --git a/internal/tester/runner.go b/internal/tester/runner.go index c2a6afd..c06402d 100644 --- a/internal/tester/runner.go +++ b/internal/tester/runner.go @@ -3,6 +3,7 @@ package tester import ( "context" "fmt" + "log/slog" "net" "net/http" "net/url" @@ -14,14 +15,13 @@ import ( "find-me-internet/internal/model" ) -// Runner handles the execution of Sing-box type Runner struct { BinPath string TestURL string Timeout time.Duration } -func NewRunner(binPath string, testURL string, timeout time.Duration) *Runner { +func NewRunner(binPath, testURL string, timeout time.Duration) *Runner { return &Runner{ BinPath: binPath, TestURL: testURL, @@ -29,73 +29,67 @@ func NewRunner(binPath string, testURL string, timeout time.Duration) *Runner { } } -// Test performs the full latency check +// Test spins up a Sing-box instance and measures HTTP latency func (r *Runner) Test(p *model.Proxy) error { - // 1. Get a random free port + // 1. Acquire Local Port port, err := getFreePort() if err != nil { - return fmt.Errorf("no free ports: %v", err) + return fmt.Errorf("failed to get port: %w", err) } - // 2. Generate Config + // 2. Generate Configuration configData, err := GenerateConfig(p, port) if err != nil { return err } - // 3. Write Config to Temp File - // specific name helps debugging if needed: config_.json - configName := filepath.Join(os.TempDir(), fmt.Sprintf("sb_config_%d.json", port)) + // 3. Write Config File + // Using a unique name prevents collisions in concurrent tests + configName := filepath.Join(os.TempDir(), fmt.Sprintf("sb_%d_%s.json", port, p.Address)) if err := os.WriteFile(configName, configData, 0644); err != nil { return err } - defer os.Remove(configName) // Cleanup after test + defer os.Remove(configName) // Cleanup - // 4. Start Sing-box Process + // 4. Execute Sing-box ctx, cancel := context.WithTimeout(context.Background(), r.Timeout+2*time.Second) defer cancel() cmd := exec.CommandContext(ctx, r.BinPath, "run", "-c", configName) - // cmd.Stdout = os.Stdout // Uncomment for debugging - // cmd.Stderr = os.Stderr - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start sing-box: %v", err) + return fmt.Errorf("startup failed: %w", err) } - - // Ensure process is killed when function exits + + // Ensure cleanup happens even if panic occurs defer func() { if cmd.Process != nil { - cmd.Process.Kill() + _ = cmd.Process.Kill() } }() - // 5. Wait for Sing-box to initialize - // A smart retry loop is better than a fixed sleep - proxyReady := waitForPort(port, 2*time.Second) - if !proxyReady { - return fmt.Errorf("sing-box did not start in time") + // 5. Wait for Binding + if !waitForPort(port, 2*time.Second) { + return fmt.Errorf("sing-box failed to bind port %d", port) } - // 6. Perform HTTP Latency Test + // 6. HTTP Latency Test latency, err := r.measureLatency(port) if err != nil { + slog.Debug("Latency test failed", "err", err, "proxy", p.Address) return err } - // 7. Success! Update the model p.Latency = latency return nil } -// measureLatency makes the actual HTTP request func (r *Runner) measureLatency(port int) (time.Duration, error) { proxyUrl, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", port)) client := &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyURL(proxyUrl), - DisableKeepAlives: true, + DisableKeepAlives: true, // Force new connection }, Timeout: r.Timeout, } @@ -107,15 +101,14 @@ func (r *Runner) measureLatency(port int) (time.Duration, error) { } defer resp.Body.Close() - // Check for valid response codes (200 OK or 204 No Content) if resp.StatusCode != 200 && resp.StatusCode != 204 { - return 0, fmt.Errorf("bad status code: %d", resp.StatusCode) + return 0, fmt.Errorf("invalid status code: %d", resp.StatusCode) } return time.Since(start), nil } -// Helpers +// getFreePort asks the kernel for a random open port func getFreePort() (int, error) { addr, err := net.ResolveTCPAddr("tcp", "localhost:0") if err != nil {