Files
2026-06-10 16:51:18 +08:00

294 lines
8.7 KiB
Go

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(),
}
completeCtx, cancel := context.WithTimeout(context.Background(), cancelRequestTimeout(options.Timeout))
defer cancel()
if _, completeErr := completeIperf3Session(completeCtx, &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"), ".")
}