feat: add one-off iperf3 cli mode

This commit is contained in:
Test User
2026-06-06 01:52:19 +08:00
parent 569beea406
commit ccea9af3af
8 changed files with 873 additions and 8 deletions
+28
View File
@@ -13,6 +13,7 @@ NetStable 是一个独立的 Go Web 测速项目,用于测试“打开网页
- 所有历史测试用户数据通过网页和 `/api/records` 提供。
- 历史时序图展示不同时段的平均下载速度变化。
- 页面通过 SSE 心跳维持一条辅助长连接,用于显示连接状态和服务配置。
- CLI 支持 HTTP 模式和 `iperf3` 模式;`iperf3` 模式由服务端在线随机生成一次性高端口,并复用同一个全局测速锁。
- 内置 GitHub 开源 `ip2region_v4.xdb` IPv4 数据库,优先离线解析地区和 ISP,避免外部 API 限流。
- 静态页面嵌入二进制,部署时只需要上传一个可执行文件。
@@ -94,10 +95,30 @@ ssh root@SERVER 'systemctl daemon-reload && systemctl enable --now netstable'
同一个二进制也可以作为客户端,在其他服务器上直接测试到部署节点的上传、下载和延迟。CLI 会复用 Web API,所以仍然遵守“同一时间只允许一个用户测速”的锁。
HTTP 模式不依赖外部程序:
```bash
./netstable client -server http://103.46.93.38:18080 -duration 30
```
`iperf3` 模式需要客户端和服务端都安装 `iperf3`。CLI 会先请求服务端创建一次性会话,服务端随机开放一个临时高端口,测试结束后回写记录并释放锁:
```bash
./netstable client -mode iperf3 -server http://103.46.93.38:18080 -duration 30 -protocol tcp
```
UDP 示例:
```bash
./netstable client -mode iperf3 -server http://103.46.93.38:18080 -duration 30 -protocol udp -bandwidth-mbps 30
```
反向下载测试,即服务端发送到 CLI 客户端:
```bash
./netstable client -mode iperf3 -server http://103.46.93.38:18080 -duration 30 -reverse
```
输出 JSON
```bash
@@ -107,10 +128,16 @@ ssh root@SERVER 'systemctl daemon-reload && systemctl enable --now netstable'
常用参数:
- `-server`: NetStable 服务端地址。
- `-mode`: `http``iperf3`,默认 `http`
- `-duration`: 每阶段时长,先下载同样秒数,再上传同样秒数。
- `-timeout`: HTTP 请求超时,单位毫秒;`iperf3` 模式中也作为测试时长之外的额外等待时间,默认 `3000`
- `-target`: 保存到记录里的目标标签,默认使用 `-server` 的 host。
- `-download-bytes`: 每次下载请求大小,默认 4 MiB。
- `-upload-bytes`: 每次上传请求大小,默认 2 MiB。
- `-protocol`: `iperf3` 模式使用 `tcp``udp`,默认 `tcp`
- `-reverse`: `iperf3` 反向模式,服务端向 CLI 客户端发送流量。
- `-bandwidth-mbps`: `iperf3` 目标带宽,默认跟随服务端 `-bandwidth-limit-mbps`
- `-iperf3-path`: `iperf3` 可执行文件路径,默认 `iperf3`
- `-quiet`: 不输出每个样本,只输出最终摘要。
## 参数
@@ -122,6 +149,7 @@ ssh root@SERVER 'systemctl daemon-reload && systemctl enable --now netstable'
- `-geo-timeout`: IP 归属地查询超时,默认 `2s`
- `-trust-proxy-headers`: 是否信任 `CF-Connecting-IP``X-Real-IP``X-Forwarded-For`。直连公网时不要开启;放在可信反向代理后面时开启。
- `-bandwidth-limit-mbps`: 程序理论最高带宽限制,单位 Mbps。示例:服务器 50 Mbps,希望测速程序最多使用 30 Mbps,则设置为 `30`;默认 `0` 表示不限速。
- `-iperf3-path`: 服务端启动一次性 `iperf3` 会话时使用的可执行文件路径,默认 `iperf3`
## Gitea
+45
View File
@@ -8,6 +8,7 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
ipdata "speedtest/data"
@@ -43,6 +44,7 @@ func runServer(args []string) {
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)
@@ -72,6 +74,7 @@ func runServer(args []string) {
ClientResolver: resolver,
TrustProxyHeaders: *trustProxyHeaders,
BandwidthLimitMbps: *bandwidthLimitMbps,
Iperf3Path: *iperf3Path,
StaticDir: *staticDir,
}
if *staticDir == "" {
@@ -100,11 +103,16 @@ func runServer(args []string) {
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)
@@ -115,6 +123,15 @@ func runClient(args []string) {
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,
@@ -141,6 +158,34 @@ func runClient(args []string) {
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)
}
}
summary, err := appclient.RunIperf3(context.Background(), 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 {
+7
View File
@@ -20,6 +20,12 @@ CLI 客户端测试:
./netstable client -server http://<server-ip>:18080 -duration 30
```
CLI `iperf3` 测试,要求客户端和服务端都安装 `iperf3`
```bash
./netstable client -mode iperf3 -server http://<server-ip>:18080 -duration 30 -protocol tcp
```
## 校验
```bash
@@ -32,5 +38,6 @@ shasum -a 256 -c checksums.txt
- 理论最高带宽限制和页面展示。
- 保存脱敏后的用户 IP、地区、运营商、测试摘要和完整样本曲线。
- SSE 心跳辅助连接用于显示连接状态。
- CLI `iperf3` 模式通过服务端随机一次性高端口测速,并复用单用户测速锁。
- 内置 ip2region IPv4 数据库,优先离线解析地区和运营商。
- 嵌入式 Web 页面,无需 Node、PHP 或数据库。
@@ -2,11 +2,11 @@
## Goal
Build a self-contained Go web service for testing upload, download, and latency between a browser user and the deployed server. A user opens the page, starts a live test, watches browser-measured speed and latency update in real time, sees the configured theoretical bandwidth limit, and reviews all historical test records from the same page. The same binary also provides a CLI client mode so other servers can run direct HTTP upload/download tests against the deployed node.
Build a self-contained Go web service for testing upload, download, and latency between a browser user and the deployed server. A user opens the page, starts a live test, watches browser-measured speed and latency update in real time, sees the configured theoretical bandwidth limit, and reviews all historical test records from the same page. The same binary also provides CLI client modes so other servers can run direct HTTP upload/download tests or one-off `iperf3` tests against the deployed node.
## Selected Approach
Use a single Go binary with no external runtime dependencies. The server exposes HTTP endpoints for browser download/upload traffic, serves an embedded static browser UI, embeds the ip2region IPv4 database for offline client region and ISP lookup, throttles generated test traffic when a startup Mbps limit is configured, and persists completed browser results as JSON Lines on disk.
Use a single Go binary for the web service and HTTP client path. The server exposes HTTP endpoints for browser download/upload traffic, serves an embedded static browser UI, embeds the ip2region IPv4 database for offline client region and ISP lookup, throttles generated test traffic when a startup Mbps limit is configured, creates random one-off high-port `iperf3` sessions for CLI users when `iperf3` is installed, and persists completed results as JSON Lines on disk.
This is preferred over PHP/LibreSpeed because it is easier to deploy on a weak network: upload one binary, start one service, and keep all behavior under our control.
@@ -30,10 +30,10 @@ The application is split into small Go packages:
- `internal/probe`: target parsing, sample types, summary types, and legacy probe helpers.
- `internal/limit`: Mbps-to-byte-rate conversion and throttling delay calculations.
- `internal/store`: append-only JSONL persistence and record loading for complete records.
- `internal/web`: HTTP routes, global active-test lock, browser download/upload endpoints, completion persistence, static asset serving, API validation.
- `internal/web`: HTTP routes, global active-test lock, browser download/upload endpoints, one-off `iperf3` session endpoints, completion persistence, static asset serving, API validation.
- `internal/geo`: offline ip2region lookup first, then best-effort online IP metadata fallback through ipapi/ipinfo-compatible JSON.
- `cmd/netstable`: command-line entrypoint for server startup and CLI client mode.
- `internal/client`: CLI-compatible client runner that uses the same `/api/tests`, `/api/download`, `/api/upload`, and completion endpoints as the browser.
- `internal/client`: CLI-compatible client runners that use the same `/api/tests`, `/api/download`, `/api/upload`, completion endpoints, and `/api/iperf3/sessions` endpoints as the server.
- `web`: embedded static asset package.
The frontend lives in `web/static` and uses plain HTML, CSS, and JavaScript. It starts tests through `/api/tests`, continuously downloads bytes from `/api/download` for the configured phase duration, continuously uploads bytes to `/api/upload` for the same duration, submits results through `/api/tests/{id}/complete`, keeps an auxiliary SSE heartbeat open through `/api/health/events`, loads configuration from `/api/config`, loads records from `/api/records`, draws a live speed curve, and draws a historical time-series chart.
@@ -49,7 +49,9 @@ The frontend lives in `web/static` and uses plain HTML, CSS, and JavaScript. It
7. The server releases the active-test lock.
8. The browser refreshes the record table and displays the latest summary.
The CLI client follows the same server API and lock behavior, but measures from the machine where the CLI is running instead of a browser tab.
The HTTP CLI client follows the same server API and lock behavior, but measures from the machine where the CLI is running instead of a browser tab.
The `iperf3` CLI client posts to `/api/iperf3/sessions`. The server reserves the global active-test lock, allocates a random high port, starts `iperf3 -s -1`, returns the temporary host and port, receives the final parsed `iperf3` sample through the completion endpoint, persists the record, kills or lets the one-off process exit, and releases the lock. If another web, HTTP CLI, or `iperf3` CLI test is active, the server returns the same busy response.
## Error Handling
+76
View File
@@ -4,6 +4,8 @@ import (
"context"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
@@ -75,6 +77,49 @@ func TestRunReturnsBusyError(t *testing.T) {
}
}
func TestRunIperf3CompletesOneOffServerSession(t *testing.T) {
store := &memoryStore{}
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner, Iperf3Path: fakeIperf3(t)})
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
summary, err := RunIperf3(context.Background(), Iperf3Options{
ServerURL: httpServer.URL,
Duration: time.Second,
Protocol: "tcp",
Reverse: true,
Iperf3Path: fakeIperf3(t),
})
if err != nil {
t.Fatalf("RunIperf3 returned error: %v", err)
}
if summary.AvgDownloadMbps != 87 || !strings.Contains(summary.Target, "iperf3/tcp") {
t.Fatalf("summary = %#v, want parsed iperf3 download result", summary)
}
records := store.snapshot()
if len(records) != 1 || records[0].Summary.ID != summary.ID {
t.Fatalf("records = %#v, want stored iperf3 summary", records)
}
}
func TestRunIperf3FailedCommandReleasesOneOffServerSession(t *testing.T) {
server := appweb.New(appweb.Options{Store: &memoryStore{}, Runner: noopRunner, Iperf3Path: fakeIperf3(t)})
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
_, err := RunIperf3(context.Background(), Iperf3Options{
ServerURL: httpServer.URL,
Duration: time.Second,
Protocol: "tcp",
Iperf3Path: fakeFailingIperf3Client(t),
})
if err == nil || !strings.Contains(err.Error(), "iperf3 failed") {
t.Fatalf("RunIperf3 error = %v, want iperf3 failure", err)
}
createBusySession(t, httpServer.URL)
}
func createBusySession(t *testing.T, serverURL string) {
t.Helper()
server, err := url.Parse(serverURL)
@@ -137,3 +182,34 @@ func (s *memoryStore) snapshot() []recordstore.Record {
func noopRunner(ctx context.Context, request appweb.TestRequest, emit func(appweb.Event) error) (probe.Summary, error) {
return probe.Summary{Target: request.Target.Address, StartedAt: time.Now(), FinishedAt: time.Now()}, nil
}
func fakeIperf3(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "iperf3")
script := `#!/bin/sh
if echo "$@" | grep -q -- "-s"; then
sleep 10
exit 0
fi
cat <<'JSON'
{"end":{"sum_sent":{"bytes":1048576,"bits_per_second":88000000},"sum_received":{"bytes":1048576,"bits_per_second":87000000}}}
JSON
`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
t.Fatalf("write fake iperf3: %v", err)
}
return path
}
func fakeFailingIperf3Client(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "iperf3")
script := `#!/bin/sh
echo "simulated iperf3 failure" >&2
exit 1
`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
t.Fatalf("write fake failing iperf3: %v", err)
}
return path
}
+291
View File
@@ -0,0 +1,291 @@
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"net/url"
"os/exec"
"strconv"
"strings"
"time"
"speedtest/internal/probe"
)
type Iperf3Options struct {
ServerURL string
Target string
Duration time.Duration
Timeout time.Duration
Protocol string
Reverse bool
BandwidthMbps float64
Iperf3Path string
OnSample func(probe.Sample)
}
type createIperf3Request struct {
DurationSeconds int `json:"durationSeconds"`
Protocol string `json:"protocol"`
Reverse bool `json:"reverse"`
BandwidthMbps float64 `json:"bandwidthMbps,omitempty"`
}
type createIperf3Response struct {
ID string `json:"id"`
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
Reverse bool `json:"reverse"`
DurationSeconds int `json:"durationSeconds"`
BandwidthMbps float64 `json:"bandwidthMbps"`
CompleteURL string `json:"completeUrl"`
}
func RunIperf3(ctx context.Context, options Iperf3Options) (probe.Summary, error) {
options, server, err := normalizeIperf3Options(options)
if err != nil {
return probe.Summary{}, err
}
httpClient := http.Client{Timeout: transferTimeout(options.Timeout)}
session, err := createIperf3Session(ctx, &httpClient, server, options)
if err != nil {
return probe.Summary{}, err
}
startedAt := time.Now().UTC()
raw, err := runIperf3Command(ctx, options, session, server)
finishedAt := time.Now().UTC()
if err != nil {
failedSample := probe.Sample{
At: finishedAt,
Kind: iperf3SampleKind(session.Reverse),
Success: false,
Error: err.Error(),
}
if _, completeErr := completeIperf3Session(ctx, &httpClient, server, session.CompleteURL, startedAt, finishedAt, []probe.Sample{failedSample}); completeErr != nil {
return probe.Summary{}, fmt.Errorf("%w; also failed to complete iperf3 session: %v", err, completeErr)
}
return probe.Summary{}, err
}
sample, err := sampleFromIperf3JSON(raw, session.Reverse)
if err != nil {
return probe.Summary{}, err
}
sample.At = finishedAt
if options.OnSample != nil {
options.OnSample(sample)
}
return completeIperf3Session(ctx, &httpClient, server, session.CompleteURL, startedAt, finishedAt, []probe.Sample{sample})
}
func normalizeIperf3Options(options Iperf3Options) (Iperf3Options, *url.URL, error) {
if strings.TrimSpace(options.ServerURL) == "" {
return Iperf3Options{}, nil, errors.New("server URL is required")
}
server, err := url.Parse(strings.TrimRight(options.ServerURL, "/"))
if err != nil || server.Scheme == "" || server.Host == "" || (server.Scheme != "http" && server.Scheme != "https") {
return Iperf3Options{}, nil, errors.New("server URL must be a valid http or https URL")
}
if strings.TrimSpace(options.Target) == "" {
options.Target = server.Host
}
if options.Duration <= 0 {
options.Duration = 30 * time.Second
}
if options.Timeout <= 0 {
options.Timeout = 3 * time.Second
}
protocol := strings.ToLower(strings.TrimSpace(options.Protocol))
if protocol == "" {
protocol = "tcp"
}
if protocol != "tcp" && protocol != "udp" {
return Iperf3Options{}, nil, errors.New("protocol must be tcp or udp")
}
options.Protocol = protocol
if strings.TrimSpace(options.Iperf3Path) == "" {
options.Iperf3Path = "iperf3"
}
return options, server, nil
}
func createIperf3Session(ctx context.Context, httpClient *http.Client, server *url.URL, options Iperf3Options) (createIperf3Response, error) {
payload := createIperf3Request{
DurationSeconds: int(math.Max(1, math.Ceil(options.Duration.Seconds()))),
Protocol: options.Protocol,
Reverse: options.Reverse,
BandwidthMbps: options.BandwidthMbps,
}
body, err := json.Marshal(payload)
if err != nil {
return createIperf3Response{}, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, resolve(server, "/api/iperf3/sessions"), bytes.NewReader(body))
if err != nil {
return createIperf3Response{}, err
}
request.Header.Set("Content-Type", "application/json")
response, err := httpClient.Do(request)
if err != nil {
return createIperf3Response{}, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
message := decodeError(response.Body)
if message == "" {
message = response.Status
}
return createIperf3Response{}, fmt.Errorf("create iperf3 session: %s", message)
}
var session createIperf3Response
if err := json.NewDecoder(response.Body).Decode(&session); err != nil {
return createIperf3Response{}, err
}
return session, nil
}
func runIperf3Command(ctx context.Context, options Iperf3Options, session createIperf3Response, server *url.URL) ([]byte, error) {
timeout := options.Duration + options.Timeout
commandCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
host := session.Host
if host == "" {
host = server.Hostname()
}
args := []string{"-c", host, "-p", strconv.Itoa(session.Port), "-t", strconv.Itoa(session.DurationSeconds), "-J"}
if session.Protocol == "udp" {
args = append(args, "-u")
}
if session.Reverse {
args = append(args, "-R")
}
if session.BandwidthMbps > 0 {
args = append(args, "-b", formatMbps(session.BandwidthMbps)+"M")
}
cmd := exec.CommandContext(commandCtx, options.Iperf3Path, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
message := strings.TrimSpace(stderr.String())
if message == "" {
message = strings.TrimSpace(string(output))
}
if message == "" {
message = err.Error()
}
return nil, fmt.Errorf("iperf3 failed: %s", message)
}
return output, nil
}
func iperf3SampleKind(reverse bool) string {
if reverse {
return "download"
}
return "upload"
}
func sampleFromIperf3JSON(raw []byte, reverse bool) (probe.Sample, error) {
type sum struct {
Bytes int64 `json:"bytes"`
BitsPerSec float64 `json:"bits_per_second"`
LostPercent float64 `json:"lost_percent"`
JitterMs float64 `json:"jitter_ms"`
Seconds float64 `json:"seconds"`
Retransmits int64 `json:"retransmits"`
Omitted bool `json:"omitted"`
Sender bool `json:"sender"`
Receiver bool `json:"receiver"`
LostPackets int64 `json:"lost_packets"`
TotalPackets int64 `json:"packets"`
Datagrams int64 `json:"datagrams"`
OutOfOrder int64 `json:"out_of_order"`
PacketLatency float64 `json:"mean_rtt"`
}
type iperfJSON struct {
End struct {
SumSent sum `json:"sum_sent"`
SumReceived sum `json:"sum_received"`
Sum sum `json:"sum"`
} `json:"end"`
}
var payload iperfJSON
if err := json.Unmarshal(raw, &payload); err != nil {
return probe.Sample{}, err
}
selected := payload.End.SumSent
kind := "upload"
if reverse {
selected = payload.End.SumReceived
kind = "download"
}
if selected.BitsPerSec == 0 {
selected = payload.End.Sum
}
if selected.BitsPerSec == 0 && payload.End.SumReceived.BitsPerSec > 0 {
selected = payload.End.SumReceived
}
if selected.BitsPerSec == 0 {
return probe.Sample{}, errors.New("iperf3 JSON does not include throughput")
}
return probe.Sample{
Kind: kind,
Success: true,
Bytes: selected.Bytes,
Mbps: round2(selected.BitsPerSec / 1000000),
}, nil
}
func completeIperf3Session(ctx context.Context, httpClient *http.Client, server *url.URL, path string, startedAt time.Time, finishedAt time.Time, samples []probe.Sample) (probe.Summary, error) {
payload := map[string]any{
"startedAt": startedAt,
"finishedAt": finishedAt,
"samples": samples,
}
body, err := json.Marshal(payload)
if err != nil {
return probe.Summary{}, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, resolve(server, path), bytes.NewReader(body))
if err != nil {
return probe.Summary{}, err
}
request.Header.Set("Content-Type", "application/json")
response, err := httpClient.Do(request)
if err != nil {
return probe.Summary{}, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
message := decodeError(response.Body)
if message == "" {
message = response.Status
}
return probe.Summary{}, fmt.Errorf("complete iperf3 session: %s", message)
}
var completed completeResponse
if err := json.NewDecoder(response.Body).Decode(&completed); err != nil {
return probe.Summary{}, err
}
return completed.Summary, nil
}
func formatMbps(mbps float64) string {
if mbps == math.Trunc(mbps) {
return strconv.FormatInt(int64(mbps), 10)
}
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", mbps), "0"), ".")
}
+324 -3
View File
@@ -12,6 +12,7 @@ import (
"net"
"net/http"
"net/url"
"os/exec"
"strconv"
"strings"
"sync"
@@ -36,6 +37,7 @@ type Options struct {
ClientResolver ClientResolver
TrustProxyHeaders bool
BandwidthLimitMbps float64
Iperf3Path string
StaticFS http.FileSystem
StaticDir string
}
@@ -46,12 +48,14 @@ type Server struct {
clientResolver ClientResolver
trustProxyHeaders bool
bandwidthLimiter limit.Limiter
iperf3Path string
staticFS http.FileSystem
staticDir string
mu sync.Mutex
sessions map[string]TestRequest
activeID string
mu sync.Mutex
sessions map[string]TestRequest
iperfSessions map[string]*iperfSession
activeID string
}
type TestRequest struct {
@@ -91,6 +95,30 @@ type heartbeatResponse struct {
ActiveTest bool `json:"activeTest"`
}
type createIperf3Request struct {
DurationSeconds int `json:"durationSeconds"`
Protocol string `json:"protocol"`
Reverse bool `json:"reverse"`
BandwidthMbps float64 `json:"bandwidthMbps"`
}
type createIperf3Response struct {
ID string `json:"id"`
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
Reverse bool `json:"reverse"`
DurationSeconds int `json:"durationSeconds"`
BandwidthMbps float64 `json:"bandwidthMbps"`
CompleteURL string `json:"completeUrl"`
}
type completeIperf3Request struct {
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
Samples []probe.Sample `json:"samples"`
}
type createTestResponse struct {
ID string `json:"id"`
EventsURL string `json:"eventsUrl"`
@@ -131,16 +159,32 @@ type rawTestRequest struct {
TimeoutMillis int `json:"timeoutMillis"`
}
type iperfSession struct {
ID string
Request TestRequest
Process *exec.Cmd
Protocol string
Reverse bool
Port int
BandwidthMbps float64
}
func New(options Options) *Server {
iperf3Path := strings.TrimSpace(options.Iperf3Path)
if iperf3Path == "" {
iperf3Path = "iperf3"
}
return &Server{
store: options.Store,
runner: options.Runner,
clientResolver: options.ClientResolver,
trustProxyHeaders: options.TrustProxyHeaders,
bandwidthLimiter: limit.New(options.BandwidthLimitMbps),
iperf3Path: iperf3Path,
staticFS: options.StaticFS,
staticDir: options.StaticDir,
sessions: make(map[string]TestRequest),
iperfSessions: make(map[string]*iperfSession),
}
}
@@ -150,6 +194,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/api/health/events", s.handleHealthEvents)
mux.HandleFunc("/api/download", s.handleDownload)
mux.HandleFunc("/api/upload", s.handleUpload)
mux.HandleFunc("/api/iperf3/sessions", s.handleCreateIperf3Session)
mux.HandleFunc("/api/iperf3/sessions/", s.handleIperf3Route)
mux.HandleFunc("/api/records", s.handleRecords)
mux.HandleFunc("/api/tests", s.handleCreateTest)
mux.HandleFunc("/api/tests/", s.handleTestRoute)
@@ -345,6 +391,191 @@ func (s *Server) handleHealthEvents(w http.ResponseWriter, r *http.Request) {
}
}
func (s *Server) handleCreateIperf3Session(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
return
}
var raw createIperf3Request
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&raw); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid JSON request"})
return
}
request, protocol, reverse, bandwidthMbps, err := s.normalizeIperf3Request(raw, r)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
return
}
request.ID = newID()
request.CreatedAt = time.Now().UTC()
request.Client = s.resolveClient(r.Context(), clientIPFromRequest(r, s.trustProxyHeaders))
s.mu.Lock()
s.expireActiveLocked(time.Now().UTC())
if s.activeID != "" {
activeID := s.activeID
s.mu.Unlock()
writeJSON(w, http.StatusConflict, busyResponse{
Error: "有其他用户正在测速,请稍等。",
ActiveTestID: activeID,
})
return
}
s.sessions[request.ID] = request
s.activeID = request.ID
s.mu.Unlock()
port, err := allocateHighPort()
if err != nil {
s.takeSession(request.ID)
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "failed to allocate iperf3 port"})
return
}
cmd := s.iperf3ServerCommand(port, protocol, bandwidthMbps)
if err := cmd.Start(); err != nil {
s.takeSession(request.ID)
writeJSON(w, http.StatusServiceUnavailable, errorResponse{Error: "failed to start iperf3 server: " + err.Error()})
return
}
go func() { _ = cmd.Wait() }()
session := &iperfSession{
ID: request.ID,
Request: request,
Process: cmd,
Protocol: protocol,
Reverse: reverse,
Port: port,
BandwidthMbps: bandwidthMbps,
}
s.mu.Lock()
s.iperfSessions[request.ID] = session
s.mu.Unlock()
go s.expireIperf3Session(request.ID, request.Duration+60*time.Second)
writeJSON(w, http.StatusAccepted, createIperf3Response{
ID: request.ID,
Host: iperfHostFromRequest(r),
Port: port,
Protocol: protocol,
Reverse: reverse,
DurationSeconds: request.DurationSeconds,
BandwidthMbps: bandwidthMbps,
CompleteURL: "/api/iperf3/sessions/" + request.ID + "/complete",
})
}
func (s *Server) normalizeIperf3Request(raw createIperf3Request, r *http.Request) (TestRequest, string, bool, float64, error) {
durationSeconds := raw.DurationSeconds
if durationSeconds == 0 {
durationSeconds = 30
}
if durationSeconds < 1 || durationSeconds > 600 {
return TestRequest{}, "", false, 0, errors.New("durationSeconds must be between 1 and 600")
}
protocol := strings.ToLower(strings.TrimSpace(raw.Protocol))
if protocol == "" {
protocol = "tcp"
}
if protocol != "tcp" && protocol != "udp" {
return TestRequest{}, "", false, 0, errors.New("protocol must be tcp or udp")
}
bandwidthMbps := raw.BandwidthMbps
if bandwidthMbps == 0 {
bandwidthMbps = s.bandwidthLimiter.Mbps()
}
if bandwidthMbps < 0 || bandwidthMbps > 100000 {
return TestRequest{}, "", false, 0, errors.New("bandwidthMbps must be between 0 and 100000")
}
host := iperfHostFromRequest(r)
target, err := probe.ParseTarget(net.JoinHostPort(host, "1"))
if err != nil {
return TestRequest{}, "", false, 0, err
}
target.Port = 0
target.Address = fmt.Sprintf("iperf3/%s/%s", protocol, host)
target.Raw = target.Address
return TestRequest{
Target: target,
Duration: time.Duration(durationSeconds) * time.Second,
Timeout: 3 * time.Second,
DurationSeconds: durationSeconds,
TimeoutMillis: 3000,
}, protocol, raw.Reverse, bandwidthMbps, nil
}
func (s *Server) iperf3ServerCommand(port int, protocol string, bandwidthMbps float64) *exec.Cmd {
args := []string{"-s", "-1", "-p", strconv.Itoa(port)}
if bandwidthMbps > 0 {
args = append(args, "--server-bitrate-limit", formatMbps(bandwidthMbps)+"M")
}
return exec.Command(s.iperf3Path, args...)
}
func (s *Server) handleIperf3Route(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/complete"):
s.handleCompleteIperf3Session(w, r)
default:
writeJSON(w, http.StatusNotFound, errorResponse{Error: "not found"})
}
}
func (s *Server) handleCompleteIperf3Session(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
return
}
id, ok := completeIperf3SessionID(r.URL.Path)
if !ok {
writeJSON(w, http.StatusNotFound, errorResponse{Error: "not found"})
return
}
var raw completeIperf3Request
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2*1024*1024))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&raw); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid JSON request"})
return
}
session, exists := s.takeIperf3Session(id)
if !exists {
writeJSON(w, http.StatusNotFound, errorResponse{Error: "iperf3 session not found"})
return
}
stopIperf3Process(session)
summary := summarizeBrowserSamples(session.Request, completeTestRequest{
StartedAt: raw.StartedAt,
FinishedAt: raw.FinishedAt,
Samples: raw.Samples,
})
if s.store != nil {
if err := s.store.Append(r.Context(), recordstore.Record{
ID: session.Request.ID,
Client: session.Request.Client,
Summary: summary,
Samples: raw.Samples,
CreatedAt: session.Request.CreatedAt,
}); err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "failed to persist iperf3 record"})
return
}
}
writeJSON(w, http.StatusOK, completeTestResponse{Summary: summary})
}
func parseDownloadSize(value string) (int64, error) {
if value == "" {
return 10 * 1024 * 1024, nil
@@ -612,12 +843,50 @@ func (s *Server) takeSession(id string) (TestRequest, bool) {
if exists {
delete(s.sessions, id)
}
if session, exists := s.iperfSessions[id]; exists {
delete(s.iperfSessions, id)
stopIperf3Process(session)
}
if s.activeID == id {
s.activeID = ""
}
return request, exists
}
func (s *Server) takeIperf3Session(id string) (*iperfSession, bool) {
s.mu.Lock()
defer s.mu.Unlock()
session, exists := s.iperfSessions[id]
if exists {
delete(s.iperfSessions, id)
delete(s.sessions, id)
}
if s.activeID == id {
s.activeID = ""
}
return session, exists
}
func (s *Server) expireIperf3Session(id string, ttl time.Duration) {
timer := time.NewTimer(ttl)
defer timer.Stop()
<-timer.C
s.mu.Lock()
session, exists := s.iperfSessions[id]
if exists {
delete(s.iperfSessions, id)
delete(s.sessions, id)
}
if s.activeID == id {
s.activeID = ""
}
s.mu.Unlock()
if exists {
stopIperf3Process(session)
}
}
func (s *Server) hasActiveTest() bool {
s.mu.Lock()
defer s.mu.Unlock()
@@ -635,6 +904,10 @@ func (s *Server) expireActiveLocked(now time.Time) {
return
}
if now.Sub(request.CreatedAt) > request.Duration*2+60*time.Second {
if session, exists := s.iperfSessions[s.activeID]; exists {
stopIperf3Process(session)
delete(s.iperfSessions, s.activeID)
}
delete(s.sessions, s.activeID)
s.activeID = ""
}
@@ -756,6 +1029,54 @@ func completeSessionID(path string) (string, bool) {
return id, id != ""
}
func completeIperf3SessionID(path string) (string, bool) {
if !strings.HasPrefix(path, "/api/iperf3/sessions/") || !strings.HasSuffix(path, "/complete") {
return "", false
}
id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/iperf3/sessions/"), "/complete")
id = strings.Trim(id, "/")
return id, id != ""
}
func iperfHostFromRequest(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if splitHost, _, err := net.SplitHostPort(host); err == nil {
host = splitHost
}
host = strings.Trim(host, "[]")
if host == "" {
return "127.0.0.1"
}
return host
}
func allocateHighPort() (int, error) {
listener, err := net.Listen("tcp", "0.0.0.0:0")
if err != nil {
return 0, err
}
defer listener.Close()
_, portText, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
return 0, err
}
port, err := strconv.Atoi(portText)
if err != nil {
return 0, err
}
if port < 20000 {
return allocateHighPort()
}
return port, nil
}
func stopIperf3Process(session *iperfSession) {
if session == nil || session.Process == nil || session.Process.Process == nil {
return
}
_ = session.Process.Process.Kill()
}
func summarizeBrowserSamples(request TestRequest, raw completeTestRequest) probe.Summary {
startedAt := raw.StartedAt
if startedAt.IsZero() {
+95
View File
@@ -8,6 +8,8 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
@@ -312,6 +314,81 @@ func TestCreateTestReturnsBusyWhenAnotherSessionIsActive(t *testing.T) {
}
}
func TestCreateIperf3SessionStartsOneOffServerAndUsesGlobalLock(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, Iperf3Path: fakeIperf3(t)})
req := httptest.NewRequest(http.MethodPost, "/api/iperf3/sessions", jsonBody(`{"durationSeconds":1,"protocol":"tcp"}`))
req.RemoteAddr = "203.0.113.20:54321"
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202; body=%s", res.Code, res.Body.String())
}
var payload createIperf3Response
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.ID == "" || payload.Port < 20000 || payload.CompleteURL != "/api/iperf3/sessions/"+payload.ID+"/complete" {
t.Fatalf("payload = %#v, want id, random high port, and complete URL", payload)
}
secondReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
secondRes := httptest.NewRecorder()
server.Handler().ServeHTTP(secondRes, secondReq)
if secondRes.Code != http.StatusConflict {
t.Fatalf("second status = %d, want 409 while iperf3 session is active; body=%s", secondRes.Code, secondRes.Body.String())
}
}
func TestCompleteIperf3SessionPersistsRecordAndReleasesLock(t *testing.T) {
store := &memoryStore{}
server := New(Options{Store: store, Runner: noopRunner, Iperf3Path: fakeIperf3(t), ClientResolver: staticResolver})
createReq := httptest.NewRequest(http.MethodPost, "/api/iperf3/sessions", jsonBody(`{"durationSeconds":1,"protocol":"tcp","reverse":true}`))
createReq.RemoteAddr = "203.0.113.20:54321"
createRes := httptest.NewRecorder()
server.Handler().ServeHTTP(createRes, createReq)
if createRes.Code != http.StatusAccepted {
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
}
var created createIperf3Response
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create response: %v", err)
}
body := `{
"startedAt":"2026-06-06T01:00:00Z",
"finishedAt":"2026-06-06T01:00:01Z",
"samples":[{"at":"2026-06-06T01:00:01Z","kind":"download","success":true,"bytes":1048576,"mbps":88.5}]
}`
completeReq := httptest.NewRequest(http.MethodPost, created.CompleteURL, jsonBody(body))
completeRes := httptest.NewRecorder()
server.Handler().ServeHTTP(completeRes, completeReq)
if completeRes.Code != http.StatusOK {
t.Fatalf("complete status = %d, want 200; body=%s", completeRes.Code, completeRes.Body.String())
}
var completed completeTestResponse
if err := json.Unmarshal(completeRes.Body.Bytes(), &completed); err != nil {
t.Fatalf("decode complete response: %v", err)
}
if completed.Summary.AvgDownloadMbps != 88.5 || !strings.Contains(completed.Summary.Target, "iperf3/tcp") {
t.Fatalf("summary = %#v, want iperf3 download summary", completed.Summary)
}
records := store.snapshot()
if len(records) != 1 || records[0].Client.IP != "203.0.113.*" || len(records[0].Samples) != 1 {
t.Fatalf("records = %#v, want one persisted iperf3 record with anonymized client", records)
}
nextReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
nextRes := httptest.NewRecorder()
server.Handler().ServeHTTP(nextRes, nextReq)
if nextRes.Code != http.StatusAccepted {
t.Fatalf("next status = %d, want 202 after iperf3 completion releases lock; body=%s", nextRes.Code, nextRes.Body.String())
}
}
func TestBrowserSessionExpiresAfterDownloadAndUploadWindows(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
@@ -632,3 +709,21 @@ func staticResolver(ctx context.Context, ip string) (recordstore.ClientInfo, err
Source: "test",
}, nil
}
func fakeIperf3(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "iperf3")
script := `#!/bin/sh
if echo "$@" | grep -q -- "-s"; then
sleep 10
exit 0
fi
cat <<'JSON'
{"end":{"sum_sent":{"bytes":1048576,"bits_per_second":88000000},"sum_received":{"bytes":1048576,"bits_per_second":87000000}}}
JSON
`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
t.Fatalf("write fake iperf3: %v", err)
}
return path
}