feat: serve cli download packages
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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`。直连公网时不要开启;放在可信反向代理后面时开启。
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
```bash
|
||||
tar -xzf netstable-<version>-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://<server-ip>: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://<server-ip>:18080/downloads/netstable-linux-'"$arch"'.tar.gz' | tar -xz -C "$tmp" && chmod +x "$tmp/netstable" && "$tmp/netstable" client -server 'http://<server-ip>: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 或数据库。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("<html>embedded</html>")},
|
||||
|
||||
+6
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -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() {
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</form>
|
||||
<div class="command-panel" aria-label="CLI 一键测试命令">
|
||||
<div class="command-title">
|
||||
<span>CLI 一键测试命令</span>
|
||||
<span>CLI 一键下载测试命令</span>
|
||||
<button id="copyCommandButton" class="secondary-button" type="button">复制</button>
|
||||
</div>
|
||||
<code id="cliCommand" class="command-text"></code>
|
||||
|
||||
Reference in New Issue
Block a user