From: Josef Schlehofer Date: Mon, 17 Aug 2026 07:12:36 +0000 (+0200) Subject: librespeed-cli: update to 1.0.14 X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=77879deb5a4b5cc6bf3cc99dac8fd3037a1aa4a2;p=openwrt-packages.git librespeed-cli: update to 1.0.14 All twelve backported patches are part of this release, so dropping them. Release notes: https://github.com/librespeed/speedtest-cli/releases/tag/v1.0.14 Signed-off-by: Josef Schlehofer --- diff --git a/utils/librespeed-cli/Makefile b/utils/librespeed-cli/Makefile index ff1be9c66..ec0cae409 100644 --- a/utils/librespeed-cli/Makefile +++ b/utils/librespeed-cli/Makefile @@ -8,12 +8,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=librespeed-cli -PKG_VERSION:=1.0.13 +PKG_VERSION:=1.0.14 PKG_RELEASE:=1 PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/librespeed/speedtest-cli/tar.gz/v${PKG_VERSION}? -PKG_HASH:=5ad938b61e3edc0ca95e2ccff0c06e97a69383f3cbb0243bd47b21b9865f9f55 +PKG_HASH:=3031e0f7babd7f9c51a1c49b95026d12532668455e8cc459049160f1bd525bf3 PKG_MAINTAINER:=Michal Vasilek PKG_LICENSE:=LGPL-3.0-only diff --git a/utils/librespeed-cli/patches/0001-style-gofmt-helper.go-and-speedtest.go.patch b/utils/librespeed-cli/patches/0001-style-gofmt-helper.go-and-speedtest.go.patch deleted file mode 100644 index 3301a240e..000000000 --- a/utils/librespeed-cli/patches/0001-style-gofmt-helper.go-and-speedtest.go.patch +++ /dev/null @@ -1,61 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Fri, 7 Aug 2026 09:42:39 +0200 -Subject: [PATCH] style: gofmt helper.go and speedtest.go - -Import order, --simple block indentation and const alignment. No behaviour -change; split out to keep the next commit's diff readable. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - speedtest/helper.go | 16 ++++++++-------- - speedtest/speedtest.go | 4 ++-- - 2 files changed, 10 insertions(+), 10 deletions(-) - ---- a/speedtest/helper.go -+++ b/speedtest/helper.go -@@ -16,8 +16,8 @@ import ( - "github.com/briandowns/spinner" - "github.com/gocarina/gocsv" - "github.com/librespeed/speedtest-cli/defs" -- "github.com/librespeed/speedtest-cli/report" - "github.com/librespeed/speedtest-cli/output" -+ "github.com/librespeed/speedtest-cli/report" - "github.com/urfave/cli/v2" - ) - -@@ -113,14 +113,14 @@ func doSpeedTest(c *cli.Context, servers - } - - // print result if --simple is given -- if c.Bool(defs.OptionSimple) { -- if c.Bool(defs.OptionBytes) { -- useMebi := c.Bool(defs.OptionMebiBytes) -- output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%s\nUpload rate:\t%s\n", p, jitter, humanizeMbps(downloadValue, useMebi), humanizeMbps(uploadValue, useMebi)) -- } else { -- output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%.2f Mbps\nUpload rate:\t%.2f Mbps\n", p, jitter, downloadValue, uploadValue) -+ if c.Bool(defs.OptionSimple) { -+ if c.Bool(defs.OptionBytes) { -+ useMebi := c.Bool(defs.OptionMebiBytes) -+ output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%s\nUpload rate:\t%s\n", p, jitter, humanizeMbps(downloadValue, useMebi), humanizeMbps(uploadValue, useMebi)) -+ } else { -+ output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%.2f Mbps\nUpload rate:\t%.2f Mbps\n", p, jitter, downloadValue, uploadValue) -+ } - } -- } - - // print share link if --share is given - var shareLink string ---- a/speedtest/speedtest.go -+++ b/speedtest/speedtest.go -@@ -33,8 +33,8 @@ const ( - defaultTelemetryShare = "/results/" - - forceNothing = 0 -- forceHttps = 1 -- forceHttp = 2 -+ forceHttps = 1 -+ forceHttp = 2 - ) - - type PingJob struct { diff --git a/utils/librespeed-cli/patches/0002-fix-fix-data-race-goroutine-leak-non-TTY-output-and-.patch b/utils/librespeed-cli/patches/0002-fix-fix-data-race-goroutine-leak-non-TTY-output-and-.patch deleted file mode 100644 index 23ca77ae4..000000000 --- a/utils/librespeed-cli/patches/0002-fix-fix-data-race-goroutine-leak-non-TTY-output-and-.patch +++ /dev/null @@ -1,526 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Fri, 7 Aug 2026 09:42:39 +0200 -Subject: [PATCH] fix: fix data race, goroutine leak, non-TTY output and escape - injection - -- BytesCounter.total mixed atomic and plain access; now an atomic.Uint64. -- Download/Upload leaked in-flight goroutines past "break Loop"; the done - signal is now ctx-aware and both wait on a WaitGroup before reading the - counter. -- Results rode on spinner.FinalMSG, which never prints without a TTY, so - piping produced no output at all. Printed via output.WriteUI now, and the - spinner moved off stdout onto stderr. -- Server-supplied strings were printed raw, allowing ANSI escapes and a - forged --list entry. output.Sanitize strips C0/DEL/C1 at every display - site; url.Parse only rejects ASCII controls, so C1 still needs stripping. -- BytesCounter.Read and its reader/pos/lock fields became unreachable when - Upload started building a body reader per request; dropped. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - defs/bytes_counter.go | 47 ++++------------- - defs/server.go | 112 +++++++++++++++++++++++++++++------------ - output/output.go | 24 +++++++++ - speedtest/helper.go | 15 +++--- - speedtest/speedtest.go | 12 +++-- - 5 files changed, 129 insertions(+), 81 deletions(-) - ---- a/defs/bytes_counter.go -+++ b/defs/bytes_counter.go -@@ -1,55 +1,34 @@ - package defs - - import ( -- "bytes" - "fmt" - "io" - "math/rand/v2" -- "sync" - "sync/atomic" - "time" - ) - --// BytesCounter implements io.Reader and io.Writer interface, for counting bytes being read/written in HTTP requests -+// BytesCounter implements the io.Writer interface, for counting bytes being read/written in HTTP requests. -+// It is only ever plugged into an io.TeeReader, so the transfer itself is driven by the wrapped reader. - type BytesCounter struct { - start time.Time -- pos int -- total uint64 -+ total atomic.Uint64 - payload []byte -- reader io.ReadSeeker - mebi bool - uploadSize int -- -- lock *sync.Mutex - } - - func NewCounter() *BytesCounter { -- return &BytesCounter{ -- lock: &sync.Mutex{}, -- } -+ return &BytesCounter{} - } - - // Write implements io.Writer - func (c *BytesCounter) Write(p []byte) (int, error) { - n := len(p) -- atomic.AddUint64(&c.total, uint64(n)) -+ c.total.Add(uint64(n)) - return n, nil - } - --// Read implements io.Reader --func (c *BytesCounter) Read(p []byte) (int, error) { -- c.lock.Lock() -- n, err := c.reader.Read(p) -- c.total += uint64(n) -- c.pos += n -- if c.pos == c.uploadSize { -- c.resetReader() -- } -- c.lock.Unlock() -- -- return n, err --} -- - // SetBase sets the base for dividing bytes into megabyte or mebibyte - func (c *BytesCounter) SetMebi(mebi bool) { - c.mebi = mebi -@@ -62,7 +41,7 @@ func (c *BytesCounter) SetUploadSize(upl - - // AvgBytes returns the average bytes/second - func (c *BytesCounter) AvgBytes() float64 { -- return float64(c.total) / time.Since(c.start).Seconds() -+ return float64(c.total.Load()) / time.Since(c.start).Seconds() - } - - // AvgMbps returns the average mbits/second -@@ -99,17 +78,9 @@ func (c *BytesCounter) Payload() []byte - return c.payload - } - --// GenerateBlob generates a random byte array of `uploadSize` in the `payload` field, and sets the `reader` field to --// read from it -+// GenerateBlob generates a random byte array of `uploadSize` in the `payload` field - func (c *BytesCounter) GenerateBlob() { - c.payload = getRandomData(c.uploadSize) -- c.reader = bytes.NewReader(c.payload) --} -- --// resetReader resets the `reader` field to 0 position --func (c *BytesCounter) resetReader() (int64, error) { -- c.pos = 0 -- return c.reader.Seek(0, 0) - } - - // Start will set the `start` field to current time -@@ -119,12 +90,12 @@ func (c *BytesCounter) Start() { - - // Total returns the total bytes read/written - func (c *BytesCounter) Total() uint64 { -- return atomic.LoadUint64(&c.total) -+ return c.total.Load() - } - - // CurrentSpeed returns the current bytes/second - func (c *BytesCounter) CurrentSpeed() float64 { -- return float64(c.total) / time.Since(c.start).Seconds() -+ return float64(c.total.Load()) / time.Since(c.start).Seconds() - } - - // SeekWrapper is a wrapper around io.Reader to give it a noop io.Seeker interface ---- a/defs/server.go -+++ b/defs/server.go -@@ -11,8 +11,10 @@ import ( - "math" - "net/http" - "net/url" -+ "os" - "path" - "strconv" -+ "sync" - "time" - - "github.com/briandowns/spinner" -@@ -61,7 +63,7 @@ func (s *Server) IsUp() bool { - defer resp.Body.Close() - b, err := io.ReadAll(resp.Body) - if err != nil || len(b) > 0 { -- output.WriteDebug("Failed when parsing get IP result: %s\n", b) -+ output.WriteDebug("Failed when parsing get IP result: %s\n", output.Sanitize(string(b))) - return false - } - // only return online if the ping URL returns nothing and 200 -@@ -76,7 +78,7 @@ func (s *Server) ICMPPingAndJitter(count - }() - - if s.NoICMP { -- output.WriteDebug("Skipping ICMP for server %s, will use HTTP ping\n", s.Name) -+ output.WriteDebug("Skipping ICMP for server %s, will use HTTP ping\n", output.Sanitize(s.Name)) - return s.PingAndJitter(count + 2) - } - -@@ -126,7 +128,7 @@ func (s *Server) ICMPPingAndJitter(count - - if len(stats.Rtts) == 0 { - s.NoICMP = true -- output.WriteDebug("No ICMP pings returned for server %s (%s), trying TCP ping\n", s.Name, u.Hostname()) -+ output.WriteDebug("No ICMP pings returned for server %s (%s), trying TCP ping\n", output.Sanitize(s.Name), output.Sanitize(u.Hostname())) - return s.PingAndJitter(count + 2) - } - -@@ -226,29 +228,43 @@ func (s *Server) Download(silent bool, u - - downloadDone := make(chan struct{}, requests) - -+ var wg sync.WaitGroup -+ - doDownload := func() { -+ defer wg.Done() -+ - reqClone := req.Clone(ctx) - resp, err := http.DefaultClient.Do(reqClone) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - output.WriteDebug("Failed when making HTTP request: %s\n", err) - } -- } else { -- defer resp.Body.Close() -+ return -+ } -+ defer resp.Body.Close() - -- if _, err = io.Copy(io.Discard, io.TeeReader(resp.Body, counter)); err != nil { -- if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { -- output.WriteDebug("Failed when reading HTTP response: %s\n", err) -- } -+ if _, err = io.Copy(io.Discard, io.TeeReader(resp.Body, counter)); err != nil { -+ if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { -+ output.WriteDebug("Failed when reading HTTP response: %s\n", err) - } -+ } - -- downloadDone <- struct{}{} -+ // let the main loop start a replacement request, but never block on it -+ // once the test is over, otherwise this goroutine is leaked -+ select { -+ case downloadDone <- struct{}{}: -+ case <-ctx.Done(): - } - } - -+ spawnDownload := func() { -+ wg.Add(1) -+ go doDownload() -+ } -+ - counter.Start() - if !silent { -- pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond) -+ pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriterFile(os.Stderr)) - pb.Prefix = "Downloading... " - pb.PostUpdate = func(s *spinner.Spinner) { - if useBytes { -@@ -259,18 +275,21 @@ func (s *Server) Download(silent bool, u - } - - pb.Start() -+ // print the rate ourselves instead of via pb.FinalMSG: the spinner only -+ // prints it when it was actually running, which it isn't when stderr is -+ // not a terminal - defer func() { -+ pb.Stop() - if useBytes { -- pb.FinalMSG = fmt.Sprintf("Download rate:\t%s\n", counter.AvgHumanize()) -+ output.WriteUI("Download rate:\t%s\n", counter.AvgHumanize()) - } else { -- pb.FinalMSG = fmt.Sprintf("Download rate:\t%.2f Mbps\n", counter.AvgMbps()) -+ output.WriteUI("Download rate:\t%.2f Mbps\n", counter.AvgMbps()) - } -- pb.Stop() - }() - } - - for i := 0; i < requests; i++ { -- go doDownload() -+ spawnDownload() - time.Sleep(200 * time.Millisecond) - } - timeout := time.After(duration) -@@ -281,10 +300,14 @@ Loop: - cancel() - break Loop - case <-downloadDone: -- go doDownload() -+ spawnDownload() - } - } - -+ // let the cancelled requests unwind before reading the counter, so the -+ // result doesn't change under us while it's being reported -+ wg.Wait() -+ - return counter.AvgMbps(), counter.Total(), nil - } - -@@ -301,8 +324,9 @@ func (s *Server) Upload(noPrealloc, sile - - if noPrealloc { - output.WriteUI("Pre-allocation is disabled, performance might be lower!\n") -- counter.reader = &SeekWrapper{rand.Reader} - } else { -+ // each request reads from this shared payload; without it they stream -+ // straight from crypto/rand instead - counter.GenerateBlob() - } - -@@ -318,7 +342,11 @@ func (s *Server) Upload(noPrealloc, sile - - uploadDone := make(chan struct{}, requests) - -+ var wg sync.WaitGroup -+ - doUpload := func() { -+ defer wg.Done() -+ - var bodyReader io.Reader - if noPrealloc { - bodyReader = &SeekWrapper{rand.Reader} -@@ -336,21 +364,34 @@ func (s *Server) Upload(noPrealloc, sile - uploadReq.Header.Set("Accept-Encoding", "identity") - - resp, err := http.DefaultClient.Do(uploadReq) -- if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { -- output.WriteDebug("Failed when making HTTP request: %s\n", err) -- } else if err == nil { -- defer resp.Body.Close() -- if _, err := io.Copy(io.Discard, resp.Body); err != nil { -- output.WriteDebug("Failed when reading HTTP response: %s\n", err) -+ if err != nil { -+ if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { -+ output.WriteDebug("Failed when making HTTP request: %s\n", err) - } -+ return -+ } -+ defer resp.Body.Close() -+ -+ if _, err := io.Copy(io.Discard, resp.Body); err != nil { -+ output.WriteDebug("Failed when reading HTTP response: %s\n", err) -+ } - -- uploadDone <- struct{}{} -+ // let the main loop start a replacement request, but never block on it -+ // once the test is over, otherwise this goroutine is leaked -+ select { -+ case uploadDone <- struct{}{}: -+ case <-ctx.Done(): - } - } - -+ spawnUpload := func() { -+ wg.Add(1) -+ go doUpload() -+ } -+ - counter.Start() - if !silent { -- pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond) -+ pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriterFile(os.Stderr)) - pb.Prefix = "Uploading... " - pb.PostUpdate = func(s *spinner.Spinner) { - if useBytes { -@@ -361,18 +402,21 @@ func (s *Server) Upload(noPrealloc, sile - } - - pb.Start() -+ // print the rate ourselves instead of via pb.FinalMSG: the spinner only -+ // prints it when it was actually running, which it isn't when stderr is -+ // not a terminal - defer func() { -+ pb.Stop() - if useBytes { -- pb.FinalMSG = fmt.Sprintf("Upload rate:\t%s\n", counter.AvgHumanize()) -+ output.WriteUI("Upload rate:\t%s\n", counter.AvgHumanize()) - } else { -- pb.FinalMSG = fmt.Sprintf("Upload rate:\t%.2f Mbps\n", counter.AvgMbps()) -+ output.WriteUI("Upload rate:\t%.2f Mbps\n", counter.AvgMbps()) - } -- pb.Stop() - }() - } - - for i := 0; i < requests; i++ { -- go doUpload() -+ spawnUpload() - time.Sleep(200 * time.Millisecond) - } - timeout := time.After(duration) -@@ -383,10 +427,14 @@ Loop: - cancel() - break Loop - case <-uploadDone: -- go doUpload() -+ spawnUpload() - } - } - -+ // let the cancelled requests unwind before reading the counter, so the -+ // result doesn't change under us while it's being reported -+ wg.Wait() -+ - return counter.AvgMbps(), counter.Total(), nil - } - -@@ -432,7 +480,7 @@ func (s *Server) GetIPInfo(distanceUnit - if len(b) > 0 { - if err := json.Unmarshal(b, &ipInfo); err != nil { - output.WriteDebug("Failed when parsing get IP result: %s\n", err) -- output.WriteDebug("Received payload: %s\n", b) -+ output.WriteDebug("Received payload: %s\n", output.Sanitize(string(b))) - // try to extract processedString even if full parse fails - // (e.g. when rawIspInfo is "" instead of an object) - var partial struct { -@@ -473,7 +521,7 @@ func (s *Server) Sponsor() string { - if s.SponsorURL != "" { - su, err := url.Parse(s.SponsorURL) - if err != nil { -- output.WriteDebug("Sponsor URL is invalid: %s\n", s.SponsorURL) -+ output.WriteDebug("Sponsor URL is invalid: %s\n", output.Sanitize(s.SponsorURL)) - } else { - if su.Scheme == "" { - su.Scheme = "https" ---- a/output/output.go -+++ b/output/output.go -@@ -11,6 +11,7 @@ import ( - "fmt" - "io" - "os" -+ "strings" - ) - - // Default is the package-level output writer used by all package functions. -@@ -127,3 +128,26 @@ func (w *Writer) Fatalf(format string, a - fmt.Fprintln(w.ui) - os.Exit(1) - } -+ -+// --- Sanitize: make server-supplied strings safe to display --- -+ -+// Sanitize strips control characters from a string so it can be printed -+// without letting a remote party drive the terminal. -+// -+// Server names, sponsor strings and the getIP response all come off the wire -+// (over plain HTTP for schemeless servers), so they are attacker-influenced. -+// Left raw, an embedded ESC sequence can rewrite earlier lines, hide text or -+// recolour the output, and an embedded newline can forge an extra entry in -+// --list output that a script would then parse as real. -+// -+// Dropped: C0 controls (including ESC, CR, LF and TAB), DEL, and C1 controls -+// (0x80-0x9F, where 0x9B doubles as CSI on some terminals). Printable Unicode -+// is left alone. -+func Sanitize(s string) string { -+ return strings.Map(func(r rune) rune { -+ if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { -+ return -1 -+ } -+ return r -+ }, s) -+} ---- a/speedtest/helper.go -+++ b/speedtest/helper.go -@@ -46,10 +46,10 @@ func doSpeedTest(c *cli.Context, servers - return err - } - -- output.WriteUI("Selected server: %s [%s]\n", currentServer.Name, u.Hostname()) -+ output.WriteUI("Selected server: %s [%s]\n", output.Sanitize(currentServer.Name), output.Sanitize(u.Hostname())) - - if sponsorMsg := currentServer.Sponsor(); sponsorMsg != "" { -- output.WriteUI("Sponsored by: %s\n", sponsorMsg) -+ output.WriteUI("Sponsored by: %s\n", output.Sanitize(sponsorMsg)) - } - - if currentServer.IsUp() { -@@ -58,12 +58,12 @@ func doSpeedTest(c *cli.Context, servers - output.WriteError("Failed to get IP info: %s\n", err) - return err - } -- output.WriteUI("You're testing from: %s\n", ispInfo.ProcessedString) -+ output.WriteUI("You're testing from: %s\n", output.Sanitize(ispInfo.ProcessedString)) - - // get ping and jitter value - var pb *spinner.Spinner - if !silent { -- pb = spinner.New(spinner.CharSets[11], 100*time.Millisecond) -+ pb = spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriterFile(os.Stderr)) - pb.Prefix = "Pinging server... " - pb.Start() - } -@@ -78,8 +78,11 @@ func doSpeedTest(c *cli.Context, servers - } - - if pb != nil { -- pb.FinalMSG = fmt.Sprintf("Ping: %.2f ms\tJitter: %.2f ms\n", p, jitter) -+ // print the result ourselves instead of via pb.FinalMSG: the -+ // spinner only prints it when it was actually running, which it -+ // isn't when stderr is not a terminal - pb.Stop() -+ output.WriteUI("Ping: %.2f ms\tJitter: %.2f ms\n", p, jitter) - } - - // get download value -@@ -182,7 +185,7 @@ func doSpeedTest(c *cli.Context, servers - reps_json = append(reps_json, rep) - } - } else { -- output.WriteUI("Selected server %s (%s) is not responding at the moment, try again later\n", currentServer.Name, u.Hostname()) -+ output.WriteUI("Selected server %s (%s) is not responding at the moment, try again later\n", output.Sanitize(currentServer.Name), output.Sanitize(u.Hostname())) - } - - //add a new line after each test if testing multiple servers ---- a/speedtest/speedtest.go -+++ b/speedtest/speedtest.go -@@ -283,9 +283,11 @@ func SpeedTest(c *cli.Context) error { - for _, svr := range servers { - var sponsorMsg string - if svr.Sponsor() != "" { -- sponsorMsg = fmt.Sprintf(" [Sponsor: %s]", svr.Sponsor()) -+ sponsorMsg = fmt.Sprintf(" [Sponsor: %s]", output.Sanitize(svr.Sponsor())) - } -- output.WriteOut("%d: %s (%s) %s\n", svr.ID, svr.Name, svr.Server, sponsorMsg) -+ // --list goes to stdout, so a newline smuggled into a server name -+ // would forge an entry for anything parsing it -+ output.WriteOut("%d: %s (%s) %s\n", svr.ID, output.Sanitize(svr.Name), output.Sanitize(svr.Server), sponsorMsg) - } - return nil - } -@@ -360,7 +362,7 @@ func pingWorker(jobs <-chan PingJob, res - // get the URL of the speed test server from the JSON - u, err := server.GetURL() - if err != nil { -- output.WriteDebug("Server URL is invalid for %s (%s), skipping\n", server.Name, server.Server) -+ output.WriteDebug("Server URL is invalid for %s (%s), skipping\n", output.Sanitize(server.Name), output.Sanitize(server.Server)) - wg.Done() - continue - } -@@ -373,7 +375,7 @@ func pingWorker(jobs <-chan PingJob, res - // if server is up, get ping - ping, _, err := server.ICMPPingAndJitter(1, srcIp, network) - if err != nil { -- output.WriteDebug("Can't ping server %s (%s), skipping\n", server.Name, u.Hostname()) -+ output.WriteDebug("Can't ping server %s (%s), skipping\n", output.Sanitize(server.Name), output.Sanitize(u.Hostname())) - wg.Done() - continue - } -@@ -381,7 +383,7 @@ func pingWorker(jobs <-chan PingJob, res - results <- PingResult{Index: job.Index, Ping: ping} - wg.Done() - } else { -- output.WriteDebug("Server %s (%s) doesn't seem to be up, skipping\n", server.Name, u.Hostname()) -+ output.WriteDebug("Server %s (%s) doesn't seem to be up, skipping\n", output.Sanitize(server.Name), output.Sanitize(u.Hostname())) - wg.Done() - } - } diff --git a/utils/librespeed-cli/patches/0003-fix-align-upload-cancellation-handling-and-add-tests.patch b/utils/librespeed-cli/patches/0003-fix-align-upload-cancellation-handling-and-add-tests.patch deleted file mode 100644 index 46a86ee6b..000000000 --- a/utils/librespeed-cli/patches/0003-fix-align-upload-cancellation-handling-and-add-tests.patch +++ /dev/null @@ -1,321 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Fri, 7 Aug 2026 09:50:07 +0200 -Subject: [PATCH] fix: align upload cancellation handling and add tests - -- Upload's response-body copy now filters context.Canceled/DeadlineExceeded - the way the download path already did, so ending the test no longer logs a - spurious debug failure. -- The two raw-payload debug dumps use %q instead of Sanitize: newlines are - legitimate in a response body, and quoting escapes them without losing them. -- TestSanitize covers C0, DEL, C1 and the printable boundaries, exhaustively - rather than by example. -- TestBytesCounterConcurrentAccess reproduces the spinner-vs-transfer access - pattern. Verified: it reports DATA RACE against the pre-fix counter and - passes against the fixed one. -- Download and Upload are exercised against a server that never ends a - request, so a hanging wg.Wait() fails the test rather than a release. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - defs/bytes_counter_test.go | 74 +++++++++++++++++++++++ - defs/server.go | 12 +++- - defs/server_test.go | 120 +++++++++++++++++++++++++++++++++++++ - output/output_test.go | 54 +++++++++++++++++ - 4 files changed, 257 insertions(+), 3 deletions(-) - create mode 100644 defs/bytes_counter_test.go - create mode 100644 defs/server_test.go - create mode 100644 output/output_test.go - ---- /dev/null -+++ b/defs/bytes_counter_test.go -@@ -0,0 +1,74 @@ -+package defs -+ -+import ( -+ "sync" -+ "testing" -+) -+ -+// TestBytesCounterConcurrentAccess exercises the pattern the counter is used -+// in: several transfer goroutines writing while the progress spinner polls the -+// averages. It is the regression test for the mixed atomic/non-atomic access -+// that used to make this racy, so it is only meaningful under -race. -+func TestBytesCounterConcurrentAccess(t *testing.T) { -+ const ( -+ writers = 8 -+ writes = 2000 -+ chunkSize = 16 -+ pollers = 4 -+ wantTotal = uint64(writers * writes * chunkSize) -+ pollBudget = 1 << 20 -+ ) -+ -+ c := NewCounter() -+ c.Start() -+ -+ stop := make(chan struct{}) -+ -+ var polling sync.WaitGroup -+ for i := 0; i < pollers; i++ { -+ polling.Add(1) -+ go func() { -+ defer polling.Done() -+ for n := 0; n < pollBudget; n++ { -+ select { -+ case <-stop: -+ return -+ default: -+ } -+ _ = c.AvgBytes() -+ _ = c.AvgMbps() -+ _ = c.AvgHumanize() -+ _ = c.CurrentSpeed() -+ _ = c.Total() -+ } -+ }() -+ } -+ -+ var writing sync.WaitGroup -+ for i := 0; i < writers; i++ { -+ writing.Add(1) -+ go func() { -+ defer writing.Done() -+ chunk := make([]byte, chunkSize) -+ for j := 0; j < writes; j++ { -+ n, err := c.Write(chunk) -+ if err != nil { -+ t.Errorf("Write returned error: %v", err) -+ return -+ } -+ if n != chunkSize { -+ t.Errorf("Write returned %d, want %d", n, chunkSize) -+ return -+ } -+ } -+ }() -+ } -+ -+ writing.Wait() -+ close(stop) -+ polling.Wait() -+ -+ if got := c.Total(); got != wantTotal { -+ t.Errorf("Total() = %d, want %d (lost updates indicate a broken counter)", got, wantTotal) -+ } -+} ---- a/defs/server.go -+++ b/defs/server.go -@@ -63,7 +63,9 @@ func (s *Server) IsUp() bool { - defer resp.Body.Close() - b, err := io.ReadAll(resp.Body) - if err != nil || len(b) > 0 { -- output.WriteDebug("Failed when parsing get IP result: %s\n", output.Sanitize(string(b))) -+ // %q rather than Sanitize: this is a raw response body where newlines are -+ // legitimate, and quoting escapes control chars without losing them -+ output.WriteDebug("Failed when parsing get IP result: %q\n", b) - return false - } - // only return online if the ping URL returns nothing and 200 -@@ -373,7 +375,10 @@ func (s *Server) Upload(noPrealloc, sile - defer resp.Body.Close() - - if _, err := io.Copy(io.Discard, resp.Body); err != nil { -- output.WriteDebug("Failed when reading HTTP response: %s\n", err) -+ // cancellation is how the test ends, so it is not a failure -+ if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { -+ output.WriteDebug("Failed when reading HTTP response: %s\n", err) -+ } - } - - // let the main loop start a replacement request, but never block on it -@@ -480,7 +485,8 @@ func (s *Server) GetIPInfo(distanceUnit - if len(b) > 0 { - if err := json.Unmarshal(b, &ipInfo); err != nil { - output.WriteDebug("Failed when parsing get IP result: %s\n", err) -- output.WriteDebug("Received payload: %s\n", output.Sanitize(string(b))) -+ // %q rather than Sanitize: see IsUp -+ output.WriteDebug("Received payload: %q\n", b) - // try to extract processedString even if full parse fails - // (e.g. when rawIspInfo is "" instead of an object) - var partial struct { ---- /dev/null -+++ b/defs/server_test.go -@@ -0,0 +1,120 @@ -+package defs -+ -+import ( -+ "io" -+ "net/http" -+ "net/http/httptest" -+ "testing" -+ "time" -+) -+ -+// The transfer tests below cover the timing semantics introduced by wg.Wait(): -+// Download and Upload now return only once every in-flight request has -+// unwound. Against a server that never ends a request on its own, the only -+// thing that can end them is the context cancellation, so these fail (by -+// timing out) if wg.Wait() can hang. -+ -+const ( -+ testRequests = 2 -+ // long enough that the requests are still in flight when the test ends -+ testDuration = 500 * time.Millisecond -+ // The spawn loop sleeps 200ms per request before the duration timer even -+ // starts, so a healthy run is ~900ms; measured unwind after cancel() is -+ // 10-40ms, with or without -race. The rest is slack for a loaded runner -- -+ // kept tight so a hanging wg.Wait() fails fast instead of stalling CI. -+ returnBudget = testRequests*200*time.Millisecond + testDuration + 2*time.Second -+) -+ -+// hangingDownloadServer streams forever until the client goes away. -+func hangingDownloadServer(t *testing.T) *httptest.Server { -+ t.Helper() -+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -+ chunk := make([]byte, 32*1024) -+ for { -+ select { -+ case <-r.Context().Done(): -+ return -+ default: -+ } -+ if _, err := w.Write(chunk); err != nil { -+ return -+ } -+ if f, ok := w.(http.Flusher); ok { -+ f.Flush() -+ } -+ } -+ })) -+} -+ -+// hangingUploadServer drains the body, then holds the request open. -+func hangingUploadServer(t *testing.T) *httptest.Server { -+ t.Helper() -+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -+ io.Copy(io.Discard, r.Body) -+ <-r.Context().Done() -+ })) -+} -+ -+func runWithinBudget(t *testing.T, name string, fn func() error) { -+ t.Helper() -+ -+ done := make(chan error, 1) -+ start := time.Now() -+ go func() { done <- fn() }() -+ -+ select { -+ case err := <-done: -+ if err != nil { -+ t.Fatalf("%s returned error: %v", name, err) -+ } -+ if elapsed := time.Since(start); elapsed > returnBudget { -+ t.Errorf("%s took %s, budget was %s", name, elapsed, returnBudget) -+ } -+ case <-time.After(returnBudget): -+ t.Fatalf("%s did not return within %s: wg.Wait() is hanging on cancelled requests", name, returnBudget) -+ } -+} -+ -+func TestDownloadReturnsWhenServerNeverEndsTheResponse(t *testing.T) { -+ ts := hangingDownloadServer(t) -+ defer ts.Close() -+ -+ s := &Server{Server: ts.URL, DownloadURL: "/"} -+ -+ var total uint64 -+ runWithinBudget(t, "Download", func() error { -+ _, n, err := s.Download(true, false, false, testRequests, 100, testDuration) -+ total = n -+ return err -+ }) -+ -+ if total == 0 { -+ t.Error("Download reported 0 bytes, expected the counter to have seen traffic") -+ } -+} -+ -+func TestUploadReturnsWhenServerNeverResponds(t *testing.T) { -+ ts := hangingUploadServer(t) -+ defer ts.Close() -+ -+ s := &Server{Server: ts.URL, UploadURL: "/"} -+ -+ runWithinBudget(t, "Upload", func() error { -+ _, _, err := s.Upload(false, true, false, false, testRequests, 32, testDuration) -+ return err -+ }) -+} -+ -+// The no-prealloc path streams from crypto/rand, so the request body never -+// ends on its own either; only cancellation can stop it. -+func TestUploadNoPreallocReturnsWhenServerNeverResponds(t *testing.T) { -+ ts := hangingUploadServer(t) -+ defer ts.Close() -+ -+ s := &Server{Server: ts.URL, UploadURL: "/"} -+ -+ runWithinBudget(t, "Upload(noPrealloc)", func() error { -+ _, _, err := s.Upload(true, true, false, false, testRequests, 32, testDuration) -+ return err -+ }) -+} ---- /dev/null -+++ b/output/output_test.go -@@ -0,0 +1,54 @@ -+package output -+ -+import "testing" -+ -+func TestSanitize(t *testing.T) { -+ tests := []struct { -+ name string -+ in string -+ want string -+ }{ -+ {"empty", "", ""}, -+ {"plain text is untouched", "normal text", "normal text"}, -+ {"newline is dropped", "hello\nworld", "helloworld"}, -+ {"carriage return is dropped", "hello\rworld", "helloworld"}, -+ {"tab is dropped", "hello\tworld", "helloworld"}, -+ {"nul is dropped", "hello\x00world", "helloworld"}, -+ {"ANSI colour sequence loses its ESC", "\x1b[31mred\x1b[0m", "[31mred[0m"}, -+ {"OSC window title sequence loses ESC and BEL", "\x1b]0;pwned\x07", "]0;pwned"}, -+ {"DEL is dropped", "foo\x7fbar", "foobar"}, -+ {"C1 CSI is dropped", "foo\u009b31mbar", "foo31mbar"}, -+ {"C1 lower bound U+0080 is dropped", "foo\u0080bar", "foobar"}, -+ {"C1 upper bound U+009F is dropped", "foo\u009fbar", "foobar"}, -+ {"U+00A0 just past C1 is kept", "foo\u00a0bar", "foo\u00a0bar"}, -+ {"space just past C0 is kept", "foo bar", "foo bar"}, -+ {"non-ASCII text is kept", "Český server \U0001f1e8\U0001f1ff", "Český server \U0001f1e8\U0001f1ff"}, -+ {"forged --list entry is collapsed onto one line", "Server A\n999: Fake", "Server A999: Fake"}, -+ } -+ -+ for _, tt := range tests { -+ t.Run(tt.name, func(t *testing.T) { -+ if got := Sanitize(tt.in); got != tt.want { -+ t.Errorf("Sanitize(%q) = %q, want %q", tt.in, got, tt.want) -+ } -+ }) -+ } -+} -+ -+// TestSanitizeDropsEveryControlRune asserts the boundaries exhaustively rather -+// than by example, so a future rewrite cannot silently let one class through. -+func TestSanitizeDropsEveryControlRune(t *testing.T) { -+ for r := rune(0); r <= 0x9f; r++ { -+ if r >= 0x20 && r <= 0x7e { -+ continue // printable ASCII -+ } -+ if got := Sanitize(string(r)); got != "" { -+ t.Errorf("Sanitize(%U) = %q, want it to be dropped", r, got) -+ } -+ } -+ for _, r := range []rune{0x20, 0x7e, 0xa0, 0xa1, 0x2028, 0x1f600} { -+ if got := Sanitize(string(r)); got != string(r) { -+ t.Errorf("Sanitize(%U) = %q, want it kept", r, got) -+ } -+ } -+} diff --git a/utils/librespeed-cli/patches/0004-feat-report-test-progress-under-debug.patch b/utils/librespeed-cli/patches/0004-feat-report-test-progress-under-debug.patch deleted file mode 100644 index 559a442ed..000000000 --- a/utils/librespeed-cli/patches/0004-feat-report-test-progress-under-debug.patch +++ /dev/null @@ -1,130 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Sat, 8 Aug 2026 00:54:35 +0200 -Subject: [PATCH] feat: report test progress under --debug -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - ---json, --csv and --simple suppress the spinner, so a run prints nothing -between launch and the final result. --debug did not help: every existing -debug call sits on an error path, so a run that succeeds stays quiet. - -Report each phase instead — server, IP info, ping, download, upload — with -the timings, byte counts and stream settings the spinner cannot carry. - -Rates follow the run's own reporting mode, Mbps normally and MB/s under ---bytes, so a debug line cannot contradict the result printed beside it. - -Nothing changes unless --debug is given. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - speedtest/helper.go | 34 ++++++++++++++++++++++++++++++++++ - 1 file changed, 34 insertions(+) - ---- a/speedtest/helper.go -+++ b/speedtest/helper.go -@@ -47,18 +47,21 @@ func doSpeedTest(c *cli.Context, servers - } - - output.WriteUI("Selected server: %s [%s]\n", output.Sanitize(currentServer.Name), output.Sanitize(u.Hostname())) -+ output.WriteDebug("Testing against %s (%s)\n", output.Sanitize(currentServer.Name), output.Sanitize(u.String())) - - if sponsorMsg := currentServer.Sponsor(); sponsorMsg != "" { - output.WriteUI("Sponsored by: %s\n", output.Sanitize(sponsorMsg)) - } - - if currentServer.IsUp() { -+ output.WriteDebug("Fetching IP info\n") - ispInfo, err := currentServer.GetIPInfo(c.String(defs.OptionDistance)) - if err != nil { - output.WriteError("Failed to get IP info: %s\n", err) - return err - } - output.WriteUI("You're testing from: %s\n", output.Sanitize(ispInfo.ProcessedString)) -+ output.WriteDebug("IP info: %s\n", output.Sanitize(ispInfo.ProcessedString)) - - // get ping and jitter value - var pb *spinner.Spinner -@@ -71,12 +74,21 @@ func doSpeedTest(c *cli.Context, servers - // skip ICMP if option given - currentServer.NoICMP = noICMP - -+ // The spinner is the only sign of progress, and it is not started -+ // in silent mode, so --json, --csv and --simple runs otherwise show -+ // nothing at all until they finish. Report each phase under --debug -+ // instead, with the timings and counts the spinner cannot carry. -+ output.WriteDebug("Ping test starting: %d pings, ICMP: %t\n", pingCount, !noICMP) -+ pingStart := time.Now() -+ - p, jitter, err := currentServer.ICMPPingAndJitter(pingCount, c.String(defs.OptionSource), network) - if err != nil { - output.WriteError("Failed to get ping and jitter: %s\n", err) - return err - } - -+ output.WriteDebug("Ping test finished in %s: ping %.2f ms, jitter %.2f ms\n", time.Since(pingStart).Round(time.Millisecond), p, jitter) -+ - if pb != nil { - // print the result ourselves instead of via pb.FinalMSG: the - // spinner only prints it when it was actually running, which it -@@ -90,7 +102,11 @@ func doSpeedTest(c *cli.Context, servers - var bytesRead uint64 - if c.Bool(defs.OptionNoDownload) { - output.WriteUI("Download test is disabled\n") -+ output.WriteDebug("Download test skipped\n") - } else { -+ output.WriteDebug("Download test starting: %d stream(s), %d chunk(s), up to %ds\n", c.Int(defs.OptionConcurrent), c.Int(defs.OptionChunks), c.Int(defs.OptionDuration)) -+ downloadStart := time.Now() -+ - download, br, err := currentServer.Download(silent, c.Bool(defs.OptionBytes), c.Bool(defs.OptionMebiBytes), c.Int(defs.OptionConcurrent), c.Int(defs.OptionChunks), time.Duration(c.Int(defs.OptionDuration))*time.Second) - if err != nil { - output.WriteError("Failed to get download speed: %s\n", err) -@@ -98,6 +114,8 @@ func doSpeedTest(c *cli.Context, servers - } - downloadValue = download - bytesRead = br -+ -+ output.WriteDebug("Download test finished in %s: %s, %d byte(s) received\n", time.Since(downloadStart).Round(time.Millisecond), humanizeRate(download, c), br) - } - - // get upload value -@@ -105,7 +123,11 @@ func doSpeedTest(c *cli.Context, servers - var bytesWritten uint64 - if c.Bool(defs.OptionNoUpload) { - output.WriteUI("Upload test is disabled\n") -+ output.WriteDebug("Upload test skipped\n") - } else { -+ output.WriteDebug("Upload test starting: %d stream(s), %d KiB per request, up to %ds\n", c.Int(defs.OptionConcurrent), c.Int(defs.OptionUploadSize), c.Int(defs.OptionDuration)) -+ uploadStart := time.Now() -+ - upload, bw, err := currentServer.Upload(c.Bool(defs.OptionNoPreAllocate), silent, c.Bool(defs.OptionBytes), c.Bool(defs.OptionMebiBytes), c.Int(defs.OptionConcurrent), c.Int(defs.OptionUploadSize), time.Duration(c.Int(defs.OptionDuration))*time.Second) - if err != nil { - output.WriteError("Failed to get upload speed: %s\n", err) -@@ -113,6 +135,8 @@ func doSpeedTest(c *cli.Context, servers - } - uploadValue = upload - bytesWritten = bw -+ -+ output.WriteDebug("Upload test finished in %s: %s, %d byte(s) sent\n", time.Since(uploadStart).Round(time.Millisecond), humanizeRate(upload, c), bw) - } - - // print result if --simple is given -@@ -323,6 +347,16 @@ func sendTelemetry(telemetryServer defs. - } - } - -+// humanizeRate formats a rate the same way the run's own result will be -+// reported, so a debug line cannot appear to contradict the JSON, CSV or -+// --simple output sitting next to it. -+func humanizeRate(mbps float64, c *cli.Context) string { -+ if c.Bool(defs.OptionBytes) { -+ return humanizeMbps(mbps, c.Bool(defs.OptionMebiBytes)) -+ } -+ return fmt.Sprintf("%.2f Mbps", mbps) -+} -+ - func humanizeMbps(mbps float64, useMebi bool) string { - val := mbps / 8 - var base float64 = 1000 diff --git a/utils/librespeed-cli/patches/0005-docs-describe-what-secure-and-insecure-actually-do.patch b/utils/librespeed-cli/patches/0005-docs-describe-what-secure-and-insecure-actually-do.patch deleted file mode 100644 index 73617883a..000000000 --- a/utils/librespeed-cli/patches/0005-docs-describe-what-secure-and-insecure-actually-do.patch +++ /dev/null @@ -1,39 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Sat, 8 Aug 2026 00:57:36 +0200 -Subject: [PATCH] docs: describe what --secure and --insecure actually do - -Both said they applied "when communicating with LibreSpeed.org operated -servers". They apply to every server in the list, and they do not affect -fetching the list itself. - -Help text only. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - main.go | 11 +++++++---- - 1 file changed, 7 insertions(+), 4 deletions(-) - ---- a/main.go -+++ b/main.go -@@ -150,13 +150,16 @@ func main() { - }, - &cli.BoolFlag{ - Name: defs.OptionSecure, -- Usage: "Use HTTPS instead of HTTP when communicating with\n" + -- "\tLibreSpeed.org operated servers", -+ Usage: "Force HTTPS for every test server, whichever scheme the\n" + -+ "\tserver list gives. Does not affect how the server list\n" + -+ "\titself is fetched", - }, - &cli.BoolFlag{ - Name: defs.OptionInsecure, -- Usage: "Use HTTP instead of HTTPS when communicating with\n" + -- "\tLibreSpeed.org operated servers", -+ Usage: "Force HTTP for every test server, whichever scheme the\n" + -+ "\tserver list gives. Does not affect how the server list\n" + -+ "\titself is fetched; use --" + defs.OptionServerJSON + " with an\n" + -+ "\thttp:// URL for that", - }, - &cli.StringFlag{ - Name: defs.OptionCACert, diff --git a/utils/librespeed-cli/patches/0006-fix-terminate-json-and-csv-output-with-a-newline.patch b/utils/librespeed-cli/patches/0006-fix-terminate-json-and-csv-output-with-a-newline.patch deleted file mode 100644 index 750466c83..000000000 --- a/utils/librespeed-cli/patches/0006-fix-terminate-json-and-csv-output-with-a-newline.patch +++ /dev/null @@ -1,33 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Sun, 9 Aug 2026 10:11:55 +0200 -Subject: [PATCH] fix: terminate --json and --csv output with a newline - -Neither ended with one: --json wrote the marshalled bytes as-is and --csv -explicitly trimmed the trailing newline. The shell prompt lands on the same -line as the result, and --csv-header already ends with a newline, so the two -CSV modes could not be concatenated into a valid file. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - speedtest/helper.go | 3 ++- - 1 file changed, 2 insertions(+), 1 deletion(-) - ---- a/speedtest/helper.go -+++ b/speedtest/helper.go -@@ -224,13 +224,14 @@ func doSpeedTest(c *cli.Context, servers - if err := gocsv.MarshalWithoutHeaders(&reps_csv, &buf); err != nil { - output.WriteError("Error generating CSV report: %s\n", err) - } else { -- os.Stdout.WriteString(strings.TrimRight(buf.String(), "\n\r")) -+ os.Stdout.WriteString(strings.TrimRight(buf.String(), "\n\r") + "\n") - } - } else if c.Bool(defs.OptionJSON) { - if b, err := json.Marshal(&reps_json); err != nil { - output.WriteError("Error generating JSON report: %s\n", err) - } else { - os.Stdout.Write(b[:]) -+ os.Stdout.WriteString("\n") - } - } - diff --git a/utils/librespeed-cli/patches/0007-fix-drop-the-empty-copyright-line-from-version.patch b/utils/librespeed-cli/patches/0007-fix-drop-the-empty-copyright-line-from-version.patch deleted file mode 100644 index 9347c28cd..000000000 --- a/utils/librespeed-cli/patches/0007-fix-drop-the-empty-copyright-line-from-version.patch +++ /dev/null @@ -1,23 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Sun, 9 Aug 2026 10:11:55 +0200 -Subject: [PATCH] fix: drop the empty copyright line from --version - -"librespeed.org Copyright (C)" has named no holder and no year since the -first commit. It says nothing that the two lines above it do not. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - speedtest/speedtest.go | 1 - - 1 file changed, 1 deletion(-) - ---- a/speedtest/speedtest.go -+++ b/speedtest/speedtest.go -@@ -73,7 +73,6 @@ func SpeedTest(c *cli.Context) error { - output.WriteOut("Licensed under GNU Lesser General Public License v3.0\n") - output.WriteOut("LibreSpeed\tCopyright (C) 2016-2020 Federico Dossena\n") - output.WriteOut("librespeed-cli\tCopyright (C) 2020 Maddie Zhan\n") -- output.WriteOut("librespeed.org\tCopyright (C)\n") - return nil - } - diff --git a/utils/librespeed-cli/patches/0008-feat-report-the-negotiated-TLS-version-and-cipher-un.patch b/utils/librespeed-cli/patches/0008-feat-report-the-negotiated-TLS-version-and-cipher-un.patch deleted file mode 100644 index b950b595f..000000000 --- a/utils/librespeed-cli/patches/0008-feat-report-the-negotiated-TLS-version-and-cipher-un.patch +++ /dev/null @@ -1,54 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Sun, 9 Aug 2026 23:27:01 +0200 -Subject: [PATCH] feat: report the negotiated TLS version and cipher under - --debug - -On hardware without AES acceleration the cipher, not the link, is what -bounds an HTTPS result, and the server chooses it. Two runs can differ -several-fold for a reason the numbers alone do not show. - -Measured on a CZ.NIC Turris 1.x, whose e500v2 core has no crypto -instructions: AES-256-GCM tops out around 150 Mbps while ChaCha20-Poly1305 -reaches about 390 Mbps on the same core. A server that picks the first will -look like a slow link next to one that picks the second. - -Print it once, from the backend check that already runs before every test. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - defs/server.go | 16 ++++++++++++++++ - 1 file changed, 16 insertions(+) - ---- a/defs/server.go -+++ b/defs/server.go -@@ -4,6 +4,7 @@ import ( - "bytes" - "context" - "crypto/rand" -+ "crypto/tls" - "encoding/json" - "errors" - "fmt" -@@ -61,6 +62,21 @@ func (s *Server) IsUp() bool { - return false - } - defer resp.Body.Close() -+ -+ // Report what the connection actually negotiated. On hardware without AES -+ // acceleration the cipher, not the link, is what bounds the result, and -+ // under TLS 1.3 the server picks it: the client offers a set and has no say -+ // in the choice, and Go does not allow that set to be configured at all. -+ // Two runs can therefore differ several-fold for a reason the numbers alone -+ // do not show, which is what this line is for. -+ if resp.TLS != nil { -+ output.WriteDebug("Negotiated %s with %s\n", -+ tls.VersionName(resp.TLS.Version), -+ tls.CipherSuiteName(resp.TLS.CipherSuite)) -+ } else { -+ output.WriteDebug("Connection is not encrypted\n") -+ } -+ - b, err := io.ReadAll(resp.Body) - if err != nil || len(b) > 0 { - // %q rather than Sanitize: this is a raw response body where newlines are diff --git a/utils/librespeed-cli/patches/0009-feat-report-which-address-each-ping-test-used-under-.patch b/utils/librespeed-cli/patches/0009-feat-report-which-address-each-ping-test-used-under-.patch deleted file mode 100644 index 20d7d28a0..000000000 --- a/utils/librespeed-cli/patches/0009-feat-report-which-address-each-ping-test-used-under-.patch +++ /dev/null @@ -1,120 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Mon, 10 Aug 2026 01:03:30 +0200 -Subject: [PATCH] feat: report which address each ping test used under --debug - -IPv4 and IPv6 can take different paths through the network, so a result is -not fully described by the hostname it was measured against. Nothing in the -output says which was used: --json carries no client address, and the -human-readable line only shows it by accident, in the ISP string. - -It is not academic. Against speedtest.cesnet.cz the ICMP ping goes over -IPv4 while the transfers go over IPv6, so the reported latency and the -reported throughput describe two different paths. - -The ICMP path reports the address the pinger resolved and probed; the TCP -path reports every distinct peer the sockets connected to, taken from -httptrace. The requests usually share one connection, but a reconnect can -land on a different address, and reporting only the first would describe a -connection the later samples did not use. - -Both paths are covered because --no-icmp is common enough that doing only -the ICMP one would leave most users with nothing. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - defs/server.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ - 1 file changed, 51 insertions(+) - ---- a/defs/server.go -+++ b/defs/server.go -@@ -10,10 +10,13 @@ import ( - "fmt" - "io" - "math" -+ "net" - "net/http" -+ "net/http/httptrace" - "net/url" - "os" - "path" -+ "slices" - "strconv" - "sync" - "time" -@@ -129,6 +132,15 @@ func (s *Server) ICMPPingAndJitter(count - - stats := p.Statistics() - -+ // Say which address the test actually reached. IPv4 and IPv6 can take -+ // different paths through the network, so a result is not fully described -+ // by the hostname it was measured against, and --json carries no client -+ // address to infer it from. -+ if stats.IPAddr != nil { -+ output.WriteDebug("Pinging %s over ICMP (%s)\n", -+ stats.IPAddr.String(), addressFamily(stats.IPAddr.String())) -+ } -+ - var lastPing, jitter float64 - for idx, rtt := range stats.Rtts { - if idx != 0 { -@@ -153,6 +165,24 @@ func (s *Server) ICMPPingAndJitter(count - return float64(stats.AvgRtt.Milliseconds()), jitter, nil - } - -+// addressFamily names the IP version of an address, for reporting which path a -+// measurement took. Accepts either a bare address or one with a port. -+func addressFamily(addr string) string { -+ host := addr -+ if h, _, err := net.SplitHostPort(addr); err == nil { -+ host = h -+ } -+ ip := net.ParseIP(host) -+ switch { -+ case ip == nil: -+ return "unknown" -+ case ip.To4() != nil: -+ return "IPv4" -+ default: -+ return "IPv6" -+ } -+} -+ - // PingAndJitter pings the server via accessing ping URL and calculate the average ping and jitter - func (s *Server) PingAndJitter(count int) (float64, float64, error) { - t := time.Now() -@@ -176,6 +206,23 @@ func (s *Server) PingAndJitter(count int - } - req.Header.Set("User-Agent", UserAgent) - -+ // Collect every distinct peer, not just the first. The requests usually -+ // share one connection, but a reconnect can land on a different address -- -+ // a different family, even -- and reporting only the first would then -+ // describe a connection the later samples did not use. -+ var remotes []string -+ req = req.WithContext(httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{ -+ GotConn: func(info httptrace.GotConnInfo) { -+ if info.Conn == nil { -+ return -+ } -+ addr := info.Conn.RemoteAddr().String() -+ if !slices.Contains(remotes, addr) { -+ remotes = append(remotes, addr) -+ } -+ }, -+ })) -+ - for i := 0; i < count; i++ { - start := time.Now() - resp, err := http.DefaultClient.Do(req) -@@ -190,6 +237,10 @@ func (s *Server) PingAndJitter(count int - pings = append(pings, float64(end.Sub(start).Milliseconds())) - } - -+ for _, addr := range remotes { -+ output.WriteDebug("Pinging %s over TCP (%s)\n", addr, addressFamily(addr)) -+ } -+ - // discard first result due to handshake overhead - if len(pings) > 1 { - pings = pings[1:] diff --git a/utils/librespeed-cli/patches/0010-fix-keep-sub-millisecond-precision-in-ping-and-jitte.patch b/utils/librespeed-cli/patches/0010-fix-keep-sub-millisecond-precision-in-ping-and-jitte.patch deleted file mode 100644 index 68e7b4b50..000000000 --- a/utils/librespeed-cli/patches/0010-fix-keep-sub-millisecond-precision-in-ping-and-jitte.patch +++ /dev/null @@ -1,81 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Mon, 10 Aug 2026 01:35:20 +0200 -Subject: [PATCH] fix: keep sub-millisecond precision in ping and jitter - -Duration.Milliseconds truncates to whole milliseconds, and every round-trip -sample went through it -- in the ICMP path and in the TCP fallback that ---no-icmp selects. On a link faster than a millisecond that leaves nothing. - -Against a server on loopback, reporting only ping and jitter: - - before ping 0 jitter 0 - after ping 0.11 jitter 0.01 - -Over a wider link the reported ping still looks fractional, because it is an -average of the samples, but its resolution is a whole millisecond and jitter -is a difference of those samples. Three consecutive ICMP runs against the -same server: - - before ping 5, 6, 5 jitter 0.59, 4.40, 0.02 - after ping 5.48, 5.68, 5.45 jitter 0.27, 0.32, 0.12 - -Divide by time.Millisecond instead, which keeps the fraction. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - defs/server.go | 15 +++++++++++---- - 1 file changed, 11 insertions(+), 4 deletions(-) - ---- a/defs/server.go -+++ b/defs/server.go -@@ -91,6 +91,13 @@ func (s *Server) IsUp() bool { - return resp.StatusCode == http.StatusOK - } - -+// rttMillis converts a round-trip time to milliseconds without discarding the -+// fraction. Duration.Milliseconds truncates to whole milliseconds, which on a -+// link faster than that leaves nothing to report. -+func rttMillis(d time.Duration) float64 { -+ return float64(d) / float64(time.Millisecond) -+} -+ - // ICMPPingAndJitter pings the server via ICMP echos and calculate the average ping and jitter - func (s *Server) ICMPPingAndJitter(count int, srcIp, network string) (float64, float64, error) { - t := time.Now() -@@ -144,7 +151,7 @@ func (s *Server) ICMPPingAndJitter(count - var lastPing, jitter float64 - for idx, rtt := range stats.Rtts { - if idx != 0 { -- instJitter := math.Abs(lastPing - float64(rtt.Milliseconds())) -+ instJitter := math.Abs(lastPing - rttMillis(rtt)) - if idx > 1 { - if jitter > instJitter { - jitter = jitter*0.7 + instJitter*0.3 -@@ -153,7 +160,7 @@ func (s *Server) ICMPPingAndJitter(count - } - } - } -- lastPing = float64(rtt.Milliseconds()) -+ lastPing = rttMillis(rtt) - } - - if len(stats.Rtts) == 0 { -@@ -162,7 +169,7 @@ func (s *Server) ICMPPingAndJitter(count - return s.PingAndJitter(count + 2) - } - -- return float64(stats.AvgRtt.Milliseconds()), jitter, nil -+ return rttMillis(stats.AvgRtt), jitter, nil - } - - // addressFamily names the IP version of an address, for reporting which path a -@@ -234,7 +241,7 @@ func (s *Server) PingAndJitter(count int - resp.Body.Close() - end := time.Now() - -- pings = append(pings, float64(end.Sub(start).Milliseconds())) -+ pings = append(pings, rttMillis(end.Sub(start))) - } - - for _, addr := range remotes { diff --git a/utils/librespeed-cli/patches/0011-feat-report-the-ping-distribution-under-debug.patch b/utils/librespeed-cli/patches/0011-feat-report-the-ping-distribution-under-debug.patch deleted file mode 100644 index 119b5c799..000000000 --- a/utils/librespeed-cli/patches/0011-feat-report-the-ping-distribution-under-debug.patch +++ /dev/null @@ -1,45 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Mon, 10 Aug 2026 02:11:41 +0200 -Subject: [PATCH] feat: report the ping distribution under --debug - -The test reports one average, which says nothing about how the samples were -spread. A link averaging 5 ms because every probe took 5 ms and one averaging -5 ms because probes ranged from 1 to 30 ms are not the same link, and only the -second explains a connection that feels unsteady. - -pro-bing already works min, max and standard deviation out in Statistics(), -so this prints what is on hand. - -Replies are shown as a raw count rather than a loss percentage. Ten probes are -too few to express as a rate, and ICMP is frequently policed separately from -the data path, so a percentage would describe the server's ICMP handling more -than the network. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - defs/server.go | 13 +++++++++++++ - 1 file changed, 13 insertions(+) - ---- a/defs/server.go -+++ b/defs/server.go -@@ -148,6 +148,19 @@ func (s *Server) ICMPPingAndJitter(count - stats.IPAddr.String(), addressFamily(stats.IPAddr.String())) - } - -+ // A single figure hides how the samples were spread. The pinger already -+ // works these out, and the spread is what says whether a link is steady or -+ // merely fast on average. Raw counts rather than a loss percentage: ten -+ // probes are too few for a rate, and ICMP is often policed independently of -+ // the data path, so a percentage would say more about the server's ICMP -+ // handling than about the network. -+ output.WriteDebug("Ping over ICMP: min %.2f ms, avg %.2f ms, max %.2f ms, stddev %.2f ms, %d/%d replies\n", -+ rttMillis(stats.MinRtt), -+ rttMillis(stats.AvgRtt), -+ rttMillis(stats.MaxRtt), -+ rttMillis(stats.StdDevRtt), -+ stats.PacketsRecv, stats.PacketsSent) -+ - var lastPing, jitter float64 - for idx, rtt := range stats.Rtts { - if idx != 0 { diff --git a/utils/librespeed-cli/patches/0012-feat-name-the-platform-in-the-User-Agent.patch b/utils/librespeed-cli/patches/0012-feat-name-the-platform-in-the-User-Agent.patch deleted file mode 100644 index ba3ec999f..000000000 --- a/utils/librespeed-cli/patches/0012-feat-name-the-platform-in-the-User-Agent.patch +++ /dev/null @@ -1,45 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Josef Schlehofer -Date: Mon, 10 Aug 2026 08:36:47 +0200 -Subject: [PATCH] feat: name the platform in the User-Agent - -A server's telemetry stores the User-Agent header already, but the client only -identifies itself by name and version, so an operator cannot tell which kinds -of machine are measuring against them. Browsers have always reported this. - -Append the operating system and architecture, which runtime.GOOS and -runtime.GOARCH give as compile-time constants at no cost: - - librespeed-cli/1.0.12 (linux; arm64) - -It stays coarse on purpose. The kernel version or the hostname would identify -the machine rather than describe it, and nothing here needs that. -Signed-off-by: Josef Schlehofer -Upstream-Status: Submitted ---- - defs/defs.go | 10 +++++++++- - 1 file changed, 9 insertions(+), 1 deletion(-) - ---- a/defs/defs.go -+++ b/defs/defs.go -@@ -1,11 +1,19 @@ - package defs - -+import "runtime" -+ - var ( - // values to be filled in by build script - BuildDate string - ProgName string - ProgVersion string -- UserAgent = ProgName + "/" + ProgVersion -+ // UserAgent names the platform as well as the program, the way a browser -+ // does. A server's telemetry already stores this header, so reporting the -+ // operating system and architecture here is what lets an operator tell -+ // which kinds of machine are measuring against them without any change to -+ // what the client sends. It stays coarse deliberately: the kernel version -+ // or the hostname would identify the machine rather than describe it. -+ UserAgent = ProgName + "/" + ProgVersion + " (" + runtime.GOOS + "; " + runtime.GOARCH + ")" - ) - - // GetIPResults represents the returned JSON from backend server's getIP.php endpoint