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"), ".") }