From 3b3b4428f2e062b9b7c2806dea24b4a362f7c2b1 Mon Sep 17 00:00:00 2001 From: Josef Schlehofer Date: Mon, 24 Aug 2026 22:57:31 +0200 Subject: [PATCH] feat: report the negotiated TLS version and cipher The debug log already reports the negotiated TLS parameters, but they are missing from the JSON result. The cipher can significantly affect results on hardware without AES acceleration, and TLS 1.3 lets the server select it. Add the negotiated TLS version and cipher to JSON and --json-stream. Omitted for plain HTTP; CSV is unchanged. --- defs/server.go | 20 ++++++++++++++++-- defs/tls_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++ report/json.go | 13 ++++++++++++ speedtest/helper.go | 7 +++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 defs/tls_test.go diff --git a/defs/server.go b/defs/server.go index b7fc44b..697f725 100644 --- a/defs/server.go +++ b/defs/server.go @@ -40,6 +40,16 @@ type Server struct { NoICMP bool `json:"-"` TLog TelemetryLog `json:"-"` + + // What the connection to this server actually negotiated; nil over + // plain HTTP. Filled by IsUp, which runs before every measurement. + NegotiatedTLS *TLSInfo `json:"-"` +} + +// TLSInfo names the negotiated TLS parameters of a connection. +type TLSInfo struct { + Version string + Cipher string } // IsUp checks the speed test backend is up by accessing the ping URL @@ -73,9 +83,15 @@ func (s *Server) IsUp() bool { // 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 { + // Kept for the report as well as logged: the cipher explains the + // number next to it, and a debug line is gone when the history + // entry it would explain is read. + s.NegotiatedTLS = &TLSInfo{ + Version: tls.VersionName(resp.TLS.Version), + Cipher: tls.CipherSuiteName(resp.TLS.CipherSuite), + } output.WriteDebug("Negotiated %s with %s\n", - tls.VersionName(resp.TLS.Version), - tls.CipherSuiteName(resp.TLS.CipherSuite)) + s.NegotiatedTLS.Version, s.NegotiatedTLS.Cipher) } else { output.WriteDebug("Connection is not encrypted\n") } diff --git a/defs/tls_test.go b/defs/tls_test.go new file mode 100644 index 0000000..c7edcc1 --- /dev/null +++ b/defs/tls_test.go @@ -0,0 +1,49 @@ +package defs + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// The negotiated parameters must reach the Server for the report, and stay +// absent over plain HTTP. +func TestIsUpCapturesNegotiatedTLS(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + tlsSrv := httptest.NewTLSServer(handler) + defer tlsSrv.Close() + + // IsUp uses http.DefaultClient; trust the test server's certificate + // for the duration of the test. + orig := http.DefaultClient + http.DefaultClient = tlsSrv.Client() + defer func() { http.DefaultClient = orig }() + + s := Server{Name: "tls", Server: tlsSrv.URL, PingURL: "/empty"} + + if !s.IsUp() { + t.Fatal("TLS test server reported down") + } + if s.NegotiatedTLS == nil { + t.Fatal("NegotiatedTLS not captured over HTTPS") + } + if !strings.HasPrefix(s.NegotiatedTLS.Version, "TLS") || s.NegotiatedTLS.Cipher == "" { + t.Fatalf("implausible negotiation: %+v", s.NegotiatedTLS) + } + + plainSrv := httptest.NewServer(handler) + defer plainSrv.Close() + + p := Server{Name: "plain", Server: plainSrv.URL, PingURL: "/empty"} + + if !p.IsUp() { + t.Fatal("plain test server reported down") + } + if p.NegotiatedTLS != nil { + t.Fatalf("NegotiatedTLS set over plain HTTP: %+v", p.NegotiatedTLS) + } +} diff --git a/report/json.go b/report/json.go index 1cce819..c3b8cc2 100644 --- a/report/json.go +++ b/report/json.go @@ -19,6 +19,19 @@ type JSONReport struct { Upload float64 `json:"upload"` Download float64 `json:"download"` Share string `json:"share"` + + // TLS is what the connection to the server negotiated, absent over + // plain HTTP. On hardware without AES acceleration the cipher, not + // the link, can bound the result, and under TLS 1.3 the server picks + // it -- so two otherwise identical runs can differ several-fold for a + // reason the numbers alone do not show. + TLS *TLSReport `json:"tls,omitempty"` +} + +// TLSReport names the negotiated TLS parameters a measurement ran over. +type TLSReport struct { + Version string `json:"version"` + Cipher string `json:"cipher"` } // Server represents the speed test server's information diff --git a/speedtest/helper.go b/speedtest/helper.go index c5bbd26..48d3ef2 100644 --- a/speedtest/helper.go +++ b/speedtest/helper.go @@ -207,6 +207,13 @@ output.WriteDebug("IP info: %s\n", output.Sanitize(ispInfo.ProcessedString)) rep.Server.Name = currentServer.Name rep.Server.URL = u.String() + if currentServer.NegotiatedTLS != nil { + rep.TLS = &report.TLSReport{ + Version: currentServer.NegotiatedTLS.Version, + Cipher: currentServer.NegotiatedTLS.Cipher, + } + } + rep.Client = report.NewClient(ispInfo.RawISPInfo) rep.Client.Readme = "" // IP() falls back to processedString, so the report carries an