package main import ( "context" "encoding/json" "flag" "fmt" "log" "net/http" "os" "os/signal" "strings" "time" ipdata "speedtest/data" appclient "speedtest/internal/client" "speedtest/internal/geo" "speedtest/internal/probe" recordstore "speedtest/internal/store" appweb "speedtest/internal/web" webassets "speedtest/web" ) func main() { args := os.Args[1:] if len(args) > 0 { switch args[0] { case "client": runClient(args[1:]) return case "serve": runServer(args[1:]) return } } runServer(args) } func runServer(args []string) { flags := flag.NewFlagSet("serve", flag.ExitOnError) listen := flags.String("listen", ":8080", "HTTP listen address") dataPath := flags.String("data", "data/records.jsonl", "JSONL record file") staticDir := flags.String("static", "", "optional static asset directory; embedded assets are used when empty") downloadsDir := flags.String("downloads-dir", "", "optional directory for public client binaries, served from /downloads/") geoBase := flags.String("geo-base", "https://ipapi.co", "online IP geolocation fallback API base URL; set empty to disable online fallback") geoTimeout := flags.Duration("geo-timeout", 2*time.Second, "IP geolocation request timeout") trustProxyHeaders := flags.Bool("trust-proxy-headers", false, "trust CF-Connecting-IP, X-Real-IP, and X-Forwarded-For headers") bandwidthLimitMbps := flags.Float64("bandwidth-limit-mbps", 0, "theoretical program bandwidth limit in Mbps; 0 means unlimited") iperf3Path := flags.String("iperf3-path", "iperf3", "iperf3 executable path for one-off CLI iperf3 sessions") _ = flags.Parse(args) store := recordstore.New(*dataPath) var resolvers []appweb.ClientResolver if db, err := ipdata.FileSystem().ReadFile("ip2region_v4.xdb"); err != nil { log.Printf("embedded ip2region database unavailable: %v", err) } else if localResolver, err := geo.NewXDBResolver(db); err != nil { log.Printf("embedded ip2region database failed to load: %v", err) } else { resolvers = append(resolvers, localResolver.Resolve) } if *geoBase != "" { if *geoBase == "https://ipapi.co" { resolvers = append(resolvers, geo.Chain( geo.New(*geoBase, *geoTimeout), geo.New("https://ipinfo.io", *geoTimeout), )) } else { resolvers = append(resolvers, geo.New(*geoBase, *geoTimeout).Resolve) } } resolver := chainResolvers(resolvers...) options := appweb.Options{ Store: store, Runner: appweb.DefaultRunner, ClientResolver: resolver, TrustProxyHeaders: *trustProxyHeaders, BandwidthLimitMbps: *bandwidthLimitMbps, Iperf3Path: *iperf3Path, DownloadsDir: *downloadsDir, StaticDir: *staticDir, } if *staticDir == "" { options.StaticFS = webassets.FileSystem() } server := appweb.New(options) httpServer := http.Server{ Addr: *listen, Handler: server.Handler(), ReadHeaderTimeout: 5 * time.Second, } log.Printf("netstable listening on %s", *listen) log.Printf("records file: %s", *dataPath) if *bandwidthLimitMbps > 0 { log.Printf("bandwidth limit: %.2f Mbps", *bandwidthLimitMbps) } else { log.Printf("bandwidth limit: unlimited") } if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server failed: %v", err) } } func runClient(args []string) { flags := flag.NewFlagSet("client", flag.ExitOnError) serverURL := flags.String("server", "", "NetStable server URL, for example http://103.46.93.38:18080") mode := flags.String("mode", "http", "client mode: http or iperf3") target := flags.String("target", "", "target label saved in records; defaults to server host") durationSeconds := flags.Int("duration", 30, "seconds for each phase: download first, then upload") timeoutMillis := flags.Int("timeout", 3000, "request timeout in milliseconds") downloadBytes := flags.Int("download-bytes", appclient.DefaultDownloadBytes, "bytes per download request") uploadBytes := flags.Int("upload-bytes", appclient.DefaultUploadBytes, "bytes per upload request") protocol := flags.String("protocol", "tcp", "iperf3 protocol: tcp or udp") reverse := flags.Bool("reverse", false, "iperf3 reverse mode: server sends to client") bandwidthMbps := flags.Float64("bandwidth-mbps", 0, "iperf3 target bandwidth in Mbps; defaults to server limit") iperf3Path := flags.String("iperf3-path", "iperf3", "iperf3 executable path for iperf3 mode") jsonOutput := flags.Bool("json", false, "print summary as JSON") quiet := flags.Bool("quiet", false, "suppress sample progress output") _ = flags.Parse(args) if *serverURL == "" { _, _ = fmt.Fprintln(os.Stderr, "client requires -server, for example: netstable client -server http://103.46.93.38:18080") flags.Usage() os.Exit(2) } if strings.EqualFold(*mode, "iperf3") { runIperf3Client(*serverURL, *target, *durationSeconds, *timeoutMillis, *protocol, *reverse, *bandwidthMbps, *iperf3Path, *jsonOutput, *quiet) return } if !strings.EqualFold(*mode, "http") { _, _ = fmt.Fprintf(os.Stderr, "unknown client mode %q; use http or iperf3\n", *mode) os.Exit(2) } options := appclient.Options{ ServerURL: *serverURL, Target: *target, Duration: time.Duration(*durationSeconds) * time.Second, Timeout: time.Duration(*timeoutMillis) * time.Millisecond, DownloadBytes: *downloadBytes, UploadBytes: *uploadBytes, OnQueueWaiting: func(message string) { _, _ = fmt.Fprintln(os.Stderr, message) }, } if !*quiet && !*jsonOutput { options.OnSample = func(sample probe.Sample) { printSample(os.Stderr, sample) } } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() summary, err := appclient.Run(ctx, options) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "client failed: %v\n", err) os.Exit(1) } if *jsonOutput { _ = json.NewEncoder(os.Stdout).Encode(summary) return } printSummary(os.Stdout, summary) } func runIperf3Client(serverURL string, target string, durationSeconds int, timeoutMillis int, protocol string, reverse bool, bandwidthMbps float64, iperf3Path string, jsonOutput bool, quiet bool) { options := appclient.Iperf3Options{ ServerURL: serverURL, Target: target, Duration: time.Duration(durationSeconds) * time.Second, Timeout: time.Duration(timeoutMillis) * time.Millisecond, Protocol: protocol, Reverse: reverse, BandwidthMbps: bandwidthMbps, Iperf3Path: iperf3Path, } if !quiet && !jsonOutput { options.OnSample = func(sample probe.Sample) { printSample(os.Stderr, sample) } } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() summary, err := appclient.RunIperf3(ctx, options) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "iperf3 client failed: %v\n", err) os.Exit(1) } if jsonOutput { _ = json.NewEncoder(os.Stdout).Encode(summary) return } printSummary(os.Stdout, summary) } func printSample(out *os.File, sample probe.Sample) { status := "OK" if !sample.Success { status = "FAIL" } switch sample.Kind { case "latency": _, _ = fmt.Fprintf(out, "%s %s %.1f ms %s\n", sample.Kind, status, sample.LatencyMS, sample.Error) case "download", "upload": _, _ = fmt.Fprintf(out, "%s %s 瞬时 %.2f Mbps %d bytes %s\n", sample.Kind, status, sample.Mbps, sample.Bytes, sample.Error) default: _, _ = fmt.Fprintf(out, "%s %s %s\n", sample.Kind, status, sample.Error) } } func printSummary(out *os.File, summary probe.Summary) { _, _ = fmt.Fprintf(out, "目标: %s\n", summary.Target) _, _ = fmt.Fprintf(out, "评分: %.2f\n", summary.Score) _, _ = fmt.Fprintf(out, "下载: avg %.2f Mbps, max %.2f Mbps\n", summary.AvgDownloadMbps, summary.MaxDownloadMbps) _, _ = fmt.Fprintf(out, "上传: avg %.2f Mbps, max %.2f Mbps\n", summary.AvgUploadMbps, summary.MaxUploadMbps) _, _ = fmt.Fprintf(out, "延迟: avg %.2f ms, min %.2f ms, max %.2f ms, jitter %.2f ms\n", summary.AvgLatencyMS, summary.MinLatencyMS, summary.MaxLatencyMS, summary.JitterMS) _, _ = fmt.Fprintf(out, "样本: %d, 成功: %d, 失败: %d, 丢失率: %.2f%%\n", summary.Samples, summary.Successes, summary.Failures, summary.PacketLossPct) } func chainResolvers(resolvers ...appweb.ClientResolver) appweb.ClientResolver { return func(ctx context.Context, ip string) (recordstore.ClientInfo, error) { var firstErr error var best recordstore.ClientInfo for _, resolver := range resolvers { if resolver == nil { continue } info, err := resolver(ctx, ip) if err != nil { if firstErr == nil { firstErr = err } continue } if info.ISP != "" { return info, nil } if best.IP == "" { best = info } } if best.IP != "" { return best, nil } return recordstore.ClientInfo{}, firstErr } }