295 lines
8.8 KiB
Go
295 lines
8.8 KiB
Go
package linetest
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// A minimal iperf3 CLIENT for one TCP upload test (client → server), speaking the iperf3 control protocol (3.x) to the
|
|
// stock `iperf3 --server` on ep0 (operator ruling 2026-10-24: the agent speaks iperf3 itself, nothing is installed on
|
|
// the host). The number that counts is the SERVER's: the bytes it received, from its results JSON — bytes the client
|
|
// handed to its socket include what still sat in the send buffer when the timer ended.
|
|
//
|
|
// The protocol, as the server drives it (one state byte at a time on the control connection):
|
|
//
|
|
// client → 37-byte cookie
|
|
// server → PARAM_EXCHANGE(9) client → params JSON (4-byte big-endian length + bytes)
|
|
// server → CREATE_STREAMS(10) client opens N data connections, each starts with the same cookie
|
|
// server → TEST_START(1), TEST_RUNNING(2) client sends for the test time, then → TEST_END(4)
|
|
// server → EXCHANGE_RESULTS(13) client → its results JSON; server → its results JSON
|
|
// server → DISPLAY_RESULTS(14) client → IPERF_DONE(16)
|
|
//
|
|
// A server already running a test answers ACCESS_DENIED(-1) and closes: that is ErrBusy (another box is testing; try
|
|
// again later the same day). SERVER_ERROR(-3) / SERVER_TERMINATE(-2) end the test with an error.
|
|
|
|
const (
|
|
stParamExchange = 9
|
|
stCreateStreams = 10
|
|
stTestStart = 1
|
|
stTestRunning = 2
|
|
stTestEnd = 4
|
|
stExchangeResults = 13
|
|
stDisplayResults = 14
|
|
stIperfDone = 16
|
|
stAccessDenied = -1
|
|
stServerTerminate = -2
|
|
stServerError = -3
|
|
|
|
cookieSize = 37 // 36 characters + NUL
|
|
blockSize = 128 * 1024
|
|
maxJSONBytes = 1 << 20
|
|
)
|
|
|
|
// ErrBusy: the server is running another client's test.
|
|
var ErrBusy = errors.New("iperf3 server busy")
|
|
|
|
// Result is one upload test.
|
|
type Result struct {
|
|
Mbps float64 // the server's received bits per second / 1e6
|
|
ServerBytes int64 // bytes the server received, all streams
|
|
ClientBytes int64 // bytes the client wrote (diagnostics only)
|
|
ServerSeconds float64 // the server's measured duration
|
|
}
|
|
|
|
// Dialer opens a TCP connection (net.Dialer in production; a seam for tests).
|
|
type Dialer interface {
|
|
DialContext(ctx context.Context, network, address string) (net.Conn, error)
|
|
}
|
|
|
|
// Client runs one test. The whole run is bounded by ctx; Run sets every connection's deadline from it.
|
|
type Client struct {
|
|
Dialer Dialer
|
|
Streams int // parallel streams (-P)
|
|
Time time.Duration // test time (-t)
|
|
}
|
|
|
|
func (c Client) dialer() Dialer {
|
|
if c.Dialer != nil {
|
|
return c.Dialer
|
|
}
|
|
return &net.Dialer{Timeout: 5 * time.Second}
|
|
}
|
|
|
|
func newCookie() ([]byte, error) {
|
|
const alphabet = "abcdefghijklmnopqrstuvwxyz234567"
|
|
b := make([]byte, cookieSize)
|
|
if _, err := rand.Read(b[:cookieSize-1]); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := 0; i < cookieSize-1; i++ {
|
|
b[i] = alphabet[int(b[i])%len(alphabet)]
|
|
}
|
|
b[cookieSize-1] = 0
|
|
return b, nil
|
|
}
|
|
|
|
func writeJSON(w io.Writer, v any) error {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var hdr [4]byte
|
|
binary.BigEndian.PutUint32(hdr[:], uint32(len(data)))
|
|
if _, err := w.Write(append(hdr[:], data...)); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readJSON(r io.Reader, v any) error {
|
|
var hdr [4]byte
|
|
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
|
return err
|
|
}
|
|
n := binary.BigEndian.Uint32(hdr[:])
|
|
if n == 0 || n > maxJSONBytes {
|
|
return fmt.Errorf("iperf3: results JSON of %d bytes refused", n)
|
|
}
|
|
buf := make([]byte, n)
|
|
if _, err := io.ReadFull(r, buf); err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(buf, v)
|
|
}
|
|
|
|
type streamResult struct {
|
|
ID int `json:"id"`
|
|
Bytes int64 `json:"bytes"`
|
|
Retransmits int64 `json:"retransmits"`
|
|
Jitter float64 `json:"jitter"`
|
|
Errors int64 `json:"errors"`
|
|
OmittedErrors int64 `json:"omitted_errors"`
|
|
Packets int64 `json:"packets"`
|
|
OmittedPackets int64 `json:"omitted_packets"`
|
|
StartTime float64 `json:"start_time"`
|
|
EndTime float64 `json:"end_time"`
|
|
}
|
|
|
|
type results struct {
|
|
CPUTotal float64 `json:"cpu_util_total"`
|
|
CPUUser float64 `json:"cpu_util_user"`
|
|
CPUSystem float64 `json:"cpu_util_system"`
|
|
SenderHasRetransmits int `json:"sender_has_retransmits"`
|
|
CongestionUsed string `json:"congestion_used,omitempty"`
|
|
Streams []streamResult `json:"streams"`
|
|
}
|
|
|
|
// streamIDs are the ids iperf3 gives its streams: the first is 1, every later one is (streams so far) + 2 — so 1, 3,
|
|
// 4, 5 … (iperf_add_stream). The server matches our results by these ids.
|
|
func streamIDs(n int) []int {
|
|
ids := make([]int, n)
|
|
for i := range ids {
|
|
if i == 0 {
|
|
ids[i] = 1
|
|
} else {
|
|
ids[i] = i + 2
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// Run performs one upload test to addr ("10.77.0.1:5201").
|
|
func (c Client) Run(ctx context.Context, addr string) (Result, error) {
|
|
var res Result
|
|
streams, dur := c.Streams, c.Time
|
|
if streams < 1 {
|
|
streams = 1
|
|
}
|
|
if dur <= 0 {
|
|
dur = 10 * time.Second
|
|
}
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok {
|
|
return res, errors.New("iperf3: Run needs a context with a deadline")
|
|
}
|
|
cookie, err := newCookie()
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
ctrl, err := c.dialer().DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return res, fmt.Errorf("iperf3: connect: %w", err)
|
|
}
|
|
defer ctrl.Close()
|
|
ctrl.SetDeadline(deadline)
|
|
if _, err := ctrl.Write(cookie); err != nil {
|
|
return res, fmt.Errorf("iperf3: cookie: %w", err)
|
|
}
|
|
|
|
var data []net.Conn
|
|
defer func() {
|
|
for _, d := range data {
|
|
d.Close()
|
|
}
|
|
}()
|
|
var sent atomic.Int64
|
|
var server results
|
|
var wg sync.WaitGroup
|
|
for {
|
|
var st [1]byte
|
|
if _, err := io.ReadFull(ctrl, st[:]); err != nil {
|
|
return res, fmt.Errorf("iperf3: control read: %w", err)
|
|
}
|
|
switch int8(st[0]) {
|
|
case stParamExchange:
|
|
params := map[string]any{"tcp": true, "omit": 0, "time": int(dur / time.Second), "num": 0, "blockcount": 0,
|
|
"parallel": streams, "len": blockSize, "pacing_timer": 1000, "client_version": "3.18"}
|
|
if err := writeJSON(ctrl, params); err != nil {
|
|
return res, fmt.Errorf("iperf3: params: %w", err)
|
|
}
|
|
case stCreateStreams:
|
|
for i := 0; i < streams; i++ {
|
|
d, err := c.dialer().DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return res, fmt.Errorf("iperf3: data stream %d: %w", i+1, err)
|
|
}
|
|
d.SetDeadline(deadline)
|
|
data = append(data, d)
|
|
if _, err := d.Write(cookie); err != nil {
|
|
return res, fmt.Errorf("iperf3: data stream %d cookie: %w", i+1, err)
|
|
}
|
|
}
|
|
case stTestStart:
|
|
case stTestRunning:
|
|
stopAt := time.Now().Add(dur)
|
|
if stopAt.After(deadline) {
|
|
return res, errors.New("iperf3: no time left for the test inside the deadline")
|
|
}
|
|
buf := make([]byte, blockSize)
|
|
for _, d := range data {
|
|
d.SetWriteDeadline(stopAt)
|
|
wg.Add(1)
|
|
go func(d net.Conn) {
|
|
defer wg.Done()
|
|
for {
|
|
n, err := d.Write(buf)
|
|
sent.Add(int64(n))
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}(d)
|
|
}
|
|
wg.Wait()
|
|
if _, err := ctrl.Write([]byte{stTestEnd}); err != nil {
|
|
return res, fmt.Errorf("iperf3: test end: %w", err)
|
|
}
|
|
case stExchangeResults:
|
|
ids := streamIDs(len(data))
|
|
mine := results{SenderHasRetransmits: -1, CongestionUsed: "cubic"}
|
|
per := sent.Load() / int64(max(len(data), 1))
|
|
for _, id := range ids {
|
|
mine.Streams = append(mine.Streams, streamResult{ID: id, Bytes: per, Retransmits: -1, EndTime: dur.Seconds()})
|
|
}
|
|
if err := writeJSON(ctrl, mine); err != nil {
|
|
return res, fmt.Errorf("iperf3: send results: %w", err)
|
|
}
|
|
if err := readJSON(ctrl, &server); err != nil {
|
|
return res, fmt.Errorf("iperf3: read server results: %w", err)
|
|
}
|
|
case stDisplayResults:
|
|
ctrl.Write([]byte{stIperfDone})
|
|
return summarize(server, sent.Load(), dur)
|
|
case stAccessDenied:
|
|
return res, ErrBusy
|
|
case stServerTerminate, stServerError:
|
|
var codes [8]byte
|
|
io.ReadFull(ctrl, codes[:])
|
|
return res, fmt.Errorf("iperf3: server error (state %d, iperf errno %d, errno %d)", int8(st[0]),
|
|
int32(binary.BigEndian.Uint32(codes[:4])), int32(binary.BigEndian.Uint32(codes[4:])))
|
|
default:
|
|
return res, fmt.Errorf("iperf3: unexpected state %s", strconv.Itoa(int(int8(st[0]))))
|
|
}
|
|
}
|
|
}
|
|
|
|
// summarize turns the server's results into a Result. No stream bytes is an error, never a zero line.
|
|
func summarize(server results, clientBytes int64, dur time.Duration) (Result, error) {
|
|
res := Result{ClientBytes: clientBytes}
|
|
for _, s := range server.Streams {
|
|
res.ServerBytes += s.Bytes
|
|
if s.EndTime > res.ServerSeconds {
|
|
res.ServerSeconds = s.EndTime
|
|
}
|
|
}
|
|
if res.ServerBytes <= 0 {
|
|
return res, errors.New("iperf3: the server reported no received bytes")
|
|
}
|
|
secs := res.ServerSeconds
|
|
if secs <= 0 {
|
|
secs = dur.Seconds()
|
|
}
|
|
res.Mbps = float64(res.ServerBytes) * 8 / secs / 1e6
|
|
return res, nil
|
|
}
|