From c14cea9e729c9b0f3b77ee097b096d999c879869 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 6 Jun 2026 03:22:01 +0800 Subject: [PATCH] feat: serve cli download packages --- Makefile | 4 +-- README.md | 12 ++++++-- cmd/netstable/main.go | 2 ++ deploy/netstable.service | 2 +- docs/release-template.md | 9 +++++- internal/web/server.go | 61 +++++++++++++++++++++++++++++++++++++ internal/web/server_test.go | 35 +++++++++++++++++++++ web/assets_test.go | 8 +++-- web/static/app.js | 14 +++++++-- web/static/index.html | 2 +- 10 files changed, 138 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 2e72189..49929bb 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ package: cp $(DIST)/$(APP)-linux-amd64 $(DIST)/$(APP)-$(VERSION)-linux-amd64/$(APP) cp README.md THIRD_PARTY.md deploy/netstable.service $(DIST)/$(APP)-$(VERSION)-linux-amd64/ cp -R LICENSES $(DIST)/$(APP)-$(VERSION)-linux-amd64/ - COPYFILE_DISABLE=1 tar -czf $(DIST)/$(APP)-$(VERSION)-linux-amd64.tar.gz -C $(DIST)/$(APP)-$(VERSION)-linux-amd64 . + COPYFILE_DISABLE=1 tar --no-xattrs -czf $(DIST)/$(APP)-$(VERSION)-linux-amd64.tar.gz -C $(DIST)/$(APP)-$(VERSION)-linux-amd64 . release: clean test mkdir -p $(DIST) @@ -32,7 +32,7 @@ release: clean test CGO_ENABLED=0 GOOS=$${os} GOARCH=$${arch} go build -trimpath -ldflags "-s -w" -o "$(DIST)/$${name}/$(APP)" ./cmd/netstable; \ cp README.md THIRD_PARTY.md deploy/netstable.service "$(DIST)/$${name}/"; \ cp -R LICENSES "$(DIST)/$${name}/"; \ - COPYFILE_DISABLE=1 tar -czf "$(DIST)/$${name}.tar.gz" -C "$(DIST)/$${name}" .; \ + COPYFILE_DISABLE=1 tar --no-xattrs -czf "$(DIST)/$${name}.tar.gz" -C "$(DIST)/$${name}" .; \ done cd $(DIST) && shasum -a 256 *.tar.gz > checksums.txt diff --git a/README.md b/README.md index cbd952e..d43661f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ NetStable 是一个独立的 Go Web 测速项目,用于测试“打开网页 - 历史时序图展示不同时段的平均下载速度变化。 - 页面通过 SSE 心跳维持一条辅助长连接,用于显示连接状态和服务配置。 - CLI 支持 HTTP 模式和 `iperf3` 模式;`iperf3` 模式由服务端在线随机生成一次性高端口,并复用同一个全局测速锁。 +- 页面可复制一键下载测试命令:用户服务器会从当前网站下载 CLI 二进制,然后立即发起测试。 - 内置 GitHub 开源 `ip2region_v4.xdb` IPv4 数据库,优先离线解析地区和 ISP,避免外部 API 限流。 - 静态页面嵌入二进制,部署时只需要上传一个可执行文件。 @@ -68,7 +69,7 @@ make release VERSION=v0.1.0 ```bash tar -xzf netstable-v0.1.0-linux-amd64.tar.gz chmod +x netstable -./netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -bandwidth-limit-mbps 30 +./netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -downloads-dir /var/lib/netstable/downloads -bandwidth-limit-mbps 30 ``` ## Linux 部署 @@ -81,7 +82,7 @@ ssh root@SERVER 'mkdir -p /var/lib/netstable && chmod +x /usr/local/bin/netstabl 可直接启动: ```bash -/usr/local/bin/netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -bandwidth-limit-mbps 30 +/usr/local/bin/netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -downloads-dir /var/lib/netstable/downloads -bandwidth-limit-mbps 30 ``` 也可以使用 `deploy/netstable.service`: @@ -101,6 +102,12 @@ HTTP 模式不依赖外部程序: ./netstable client -server http://103.46.93.38:18080 -duration 30 ``` +网页复制的一键命令会先下载 CLI 再测试,适合用户服务器没有 `netstable` 的情况: + +```bash +arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; *) echo "unsupported arch: $arch"; exit 1;; esac; tmp=$(mktemp -d); curl -fsSL 'http://103.46.93.38:18080/downloads/netstable-linux-'"$arch"'.tar.gz' | tar -xz -C "$tmp" && chmod +x "$tmp/netstable" && "$tmp/netstable" client -server 'http://103.46.93.38:18080' -duration 30 +``` + `iperf3` 模式需要客户端和服务端都安装 `iperf3`。CLI 会先请求服务端创建一次性会话,服务端随机开放一个临时高端口,测试结束后回写记录并释放锁: ```bash @@ -145,6 +152,7 @@ UDP 示例: - `-listen`: HTTP 监听地址,默认 `:8080`;部署示例使用高端口 `18080`。 - `-data`: JSONL 记录文件,默认 `data/records.jsonl`。 - `-static`: 可选静态资源目录;为空时使用嵌入资源。 +- `-downloads-dir`: 可选二进制下载目录。目录中可放 `netstable-linux-amd64.tar.gz` 和 `netstable-linux-arm64.tar.gz`,通过 `/downloads/` 提供给网页一键命令下载。 - `-geo-base`: 在线 IP 归属地 API fallback 地址,默认 `https://ipapi.co`,并在默认源限流时自动尝试 `https://ipinfo.io`;内置 `ip2region` 会优先查询,设为空可关闭在线 fallback。 - `-geo-timeout`: IP 归属地查询超时,默认 `2s`。 - `-trust-proxy-headers`: 是否信任 `CF-Connecting-IP`、`X-Real-IP`、`X-Forwarded-For`。直连公网时不要开启;放在可信反向代理后面时开启。 diff --git a/cmd/netstable/main.go b/cmd/netstable/main.go index 7b32691..538e7dc 100644 --- a/cmd/netstable/main.go +++ b/cmd/netstable/main.go @@ -40,6 +40,7 @@ func runServer(args []string) { 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") @@ -75,6 +76,7 @@ func runServer(args []string) { TrustProxyHeaders: *trustProxyHeaders, BandwidthLimitMbps: *bandwidthLimitMbps, Iperf3Path: *iperf3Path, + DownloadsDir: *downloadsDir, StaticDir: *staticDir, } if *staticDir == "" { diff --git a/deploy/netstable.service b/deploy/netstable.service index 0a9e70e..6354942 100644 --- a/deploy/netstable.service +++ b/deploy/netstable.service @@ -6,7 +6,7 @@ Wants=network-online.target [Service] Type=simple User=root -ExecStart=/usr/local/bin/netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -bandwidth-limit-mbps 30 +ExecStart=/usr/local/bin/netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -downloads-dir /var/lib/netstable/downloads -bandwidth-limit-mbps 30 Restart=always RestartSec=3 NoNewPrivileges=true diff --git a/docs/release-template.md b/docs/release-template.md index 7c9aa03..e7b36ec 100644 --- a/docs/release-template.md +++ b/docs/release-template.md @@ -11,7 +11,7 @@ ```bash tar -xzf netstable--linux-amd64.tar.gz chmod +x netstable -./netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -bandwidth-limit-mbps 30 +./netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -downloads-dir /var/lib/netstable/downloads -bandwidth-limit-mbps 30 ``` CLI 客户端测试: @@ -20,6 +20,12 @@ CLI 客户端测试: ./netstable client -server http://:18080 -duration 30 ``` +一键下载并测试: + +```bash +arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; *) echo "unsupported arch: $arch"; exit 1;; esac; tmp=$(mktemp -d); curl -fsSL 'http://:18080/downloads/netstable-linux-'"$arch"'.tar.gz' | tar -xz -C "$tmp" && chmod +x "$tmp/netstable" && "$tmp/netstable" client -server 'http://:18080' -duration 30 +``` + CLI `iperf3` 测试,要求客户端和服务端都安装 `iperf3`: ```bash @@ -39,5 +45,6 @@ shasum -a 256 -c checksums.txt - 保存脱敏后的用户 IP、地区、运营商、测试摘要和完整样本曲线。 - SSE 心跳辅助连接用于显示连接状态。 - CLI `iperf3` 模式通过服务端随机一次性高端口测速,并复用单用户测速锁。 +- 网页可复制一键下载测试命令,用户服务器无需预先安装 `netstable`。 - 内置 ip2region IPv4 数据库,优先离线解析地区和运营商。 - 嵌入式 Web 页面,无需 Node、PHP 或数据库。 diff --git a/internal/web/server.go b/internal/web/server.go index 139b6a1..dce9dc3 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -12,7 +12,10 @@ import ( "net" "net/http" "net/url" + "os" "os/exec" + "path/filepath" + "runtime" "strconv" "strings" "sync" @@ -38,6 +41,7 @@ type Options struct { TrustProxyHeaders bool BandwidthLimitMbps float64 Iperf3Path string + DownloadsDir string StaticFS http.FileSystem StaticDir string } @@ -49,6 +53,7 @@ type Server struct { trustProxyHeaders bool bandwidthLimiter limit.Limiter iperf3Path string + downloadsDir string staticFS http.FileSystem staticDir string @@ -181,6 +186,7 @@ func New(options Options) *Server { trustProxyHeaders: options.TrustProxyHeaders, bandwidthLimiter: limit.New(options.BandwidthLimitMbps), iperf3Path: iperf3Path, + downloadsDir: strings.TrimSpace(options.DownloadsDir), staticFS: options.StaticFS, staticDir: options.StaticDir, sessions: make(map[string]TestRequest), @@ -199,6 +205,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/records", s.handleRecords) mux.HandleFunc("/api/tests", s.handleCreateTest) mux.HandleFunc("/api/tests/", s.handleTestRoute) + mux.HandleFunc("/downloads/", s.handleBinaryDownload) if s.staticFS != nil { mux.Handle("/", http.FileServer(s.staticFS)) } else if s.staticDir != "" { @@ -391,6 +398,38 @@ func (s *Server) handleHealthEvents(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) handleBinaryDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"}) + return + } + name := strings.Trim(strings.TrimPrefix(r.URL.Path, "/downloads/"), "/") + if !isDownloadBinaryName(name) { + writeJSON(w, http.StatusNotFound, errorResponse{Error: "download not found"}) + return + } + + if s.downloadsDir != "" { + path := filepath.Join(s.downloadsDir, name) + if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() { + serveBinaryFile(w, r, name, path) + return + } + } + + if name == currentLinuxBinaryName() { + path, err := os.Executable() + if err == nil { + if info, statErr := os.Stat(path); statErr == nil && info.Mode().IsRegular() { + serveBinaryFile(w, r, name, path) + return + } + } + } + + writeJSON(w, http.StatusNotFound, errorResponse{Error: "download not found"}) +} + func (s *Server) handleCreateIperf3Session(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"}) @@ -1050,6 +1089,28 @@ func iperfHostFromRequest(r *http.Request) string { return host } +func isDownloadBinaryName(name string) bool { + switch name { + case "netstable-linux-amd64", "netstable-linux-arm64", "netstable-linux-amd64.tar.gz", "netstable-linux-arm64.tar.gz": + return true + default: + return false + } +} + +func currentLinuxBinaryName() string { + if runtime.GOOS != "linux" { + return "" + } + return "netstable-linux-" + runtime.GOARCH +} + +func serveBinaryFile(w http.ResponseWriter, r *http.Request, name string, path string) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name)) + http.ServeFile(w, r, path) +} + func allocateHighPort() (int, error) { listener, err := net.Listen("tcp", "0.0.0.0:0") if err != nil { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index f648b80..7eefccb 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -600,6 +600,41 @@ func TestHandlerServesStaticFilesFromFS(t *testing.T) { } } +func TestDownloadsEndpointServesPackagedLinuxBinary(t *testing.T) { + downloadsDir := t.TempDir() + packagePath := filepath.Join(downloadsDir, "netstable-linux-amd64.tar.gz") + if err := os.WriteFile(packagePath, []byte("fake-package"), 0644); err != nil { + t.Fatalf("write fake package: %v", err) + } + server := New(Options{Store: &memoryStore{}, Runner: noopRunner, DownloadsDir: downloadsDir}) + + req := httptest.NewRequest(http.MethodGet, "/downloads/netstable-linux-amd64.tar.gz", nil) + res := httptest.NewRecorder() + server.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String()) + } + if res.Body.String() != "fake-package" { + t.Fatalf("body = %q, want fake package", res.Body.String()) + } + if !strings.Contains(res.Header().Get("Content-Disposition"), "netstable-linux-amd64.tar.gz") { + t.Fatalf("Content-Disposition = %q, want filename", res.Header().Get("Content-Disposition")) + } +} + +func TestDownloadsEndpointRejectsUnknownBinaryName(t *testing.T) { + server := New(Options{Store: &memoryStore{}, Runner: noopRunner, DownloadsDir: t.TempDir()}) + + req := httptest.NewRequest(http.MethodGet, "/downloads/netstable-linux-riscv64", nil) + res := httptest.NewRecorder() + server.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", res.Code) + } +} + func TestSubFSHelperReturnsStaticRoot(t *testing.T) { staticFS := fstest.MapFS{ "static/index.html": &fstest.MapFile{Data: []byte("embedded")}, diff --git a/web/assets_test.go b/web/assets_test.go index 950e82f..e4f2ecb 100644 --- a/web/assets_test.go +++ b/web/assets_test.go @@ -61,8 +61,12 @@ func TestStaticAssetsIncludeCopyableClientCommand(t *testing.T) { } if !contains(app, "updateClientCommand") || !contains(app, "navigator.clipboard.writeText") || - !contains(app, "./netstable client -server") { - t.Fatalf("app.js missing generated copyable CLI command behavior") + !contains(app, "curl -fsSL") || + !contains(app, "/downloads/netstable-linux-") || + !contains(app, ".tar.gz") || + !contains(app, "tar -xz -C") || + !contains(app, "uname -m") { + t.Fatalf("app.js missing generated one-click download and test command behavior") } } diff --git a/web/static/app.js b/web/static/app.js index 3270f85..9f299da 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -62,8 +62,18 @@ function updateClientCommand() { const serverURL = window.location.origin || `http://${window.location.host}`; const duration = numberValue("durationSeconds") || 30; const serverArg = shellQuote(serverURL); - state.clientCommand = `./netstable client -server ${serverArg} -duration ${duration}`; - els.cliCommand.textContent = `./netstable client \\\n -server ${serverArg} \\\n -duration ${duration}`; + const downloadPrefixArg = shellQuote(`${serverURL}/downloads/netstable-linux-`); + state.clientCommand = `arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; *) echo "unsupported arch: $arch"; exit 1;; esac; tmp=$(mktemp -d); curl -fsSL ${downloadPrefixArg}"$arch".tar.gz | tar -xz -C "$tmp" && chmod +x "$tmp/netstable" && "$tmp/netstable" client -server ${serverArg} -duration ${duration}`; + els.cliCommand.textContent = `arch=$(uname -m) +case "$arch" in + x86_64) arch=amd64 ;; + aarch64|arm64) arch=arm64 ;; + *) echo "unsupported arch: $arch"; exit 1 ;; +esac +tmp=$(mktemp -d) +curl -fsSL ${downloadPrefixArg}"$arch".tar.gz | tar -xz -C "$tmp" +chmod +x "$tmp/netstable" +"$tmp/netstable" client -server ${serverArg} -duration ${duration}`; } async function copyClientCommand() { diff --git a/web/static/index.html b/web/static/index.html index 063318b..46b325b 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -50,7 +50,7 @@
- CLI 一键测试命令 + CLI 一键下载测试命令