From f2de5e5c594d56bba4e98d53bc00fbe68ff451d5 Mon Sep 17 00:00:00 2001 From: Harshavardhana Date: Wed, 19 Aug 2026 00:13:02 -0700 Subject: [PATCH 1/7] fix IPv6 support in server bind, client dial and table output Fiber v2 defaults to NetworkTCP4, so `--address '[]:9010'` failed with "listen tcp4: ... no suitable address found" and `[::]:9010` silently bound 0.0.0.0 only. Set Network to tcp for dual-stack listeners. The client built its websocket URL by concatenating host, ":" and port, which produced "too many colons in address" for IPv6 hosts. Use net.JoinHostPort. The table stripped the port with strings.Split(addr, ":")[0], which cut IPv6 addresses at their first colon and rendered every host as "2601". Strip the port with net.SplitHostPort and grow the Local/Remote columns to fit the widest address, leaving IPv4 output unchanged. --- client/client.go | 8 ++++++-- client/table.go | 31 +++++++++++++++++++++++++++---- server/server.go | 1 + 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/client/client.go b/client/client.go index 745636f..c7180a7 100644 --- a/client/client.go +++ b/client/client.go @@ -26,6 +26,7 @@ import ( "errors" "fmt" "math" + "net" "net/http" "os" "reflect" @@ -180,10 +181,11 @@ func handleWSConnection(ctx context.Context, c *shared.Config, host string, id i shared.DEBUG(WarningStyle.Render("Connecting to ", host, ":", c.Port)) - connectString := "wss://" + host + ":" + c.Port + "/ws/" + host + scheme := "wss" if c.Insecure { - connectString = "ws://" + host + ":" + c.Port + "/ws/" + host + scheme = "ws" } + connectString := scheme + "://" + net.JoinHostPort(host, c.Port) + "/ws/" + host con, _, dialErr := dialer.DialContext( ctx, @@ -844,6 +846,8 @@ func printSliceOfDataPoints(dps []shared.DP, c shared.Config) { data = dps } + growHostColumns(data) + for i := range data { if i%20 == 0 { printDataPointHeaders(data[0].Type) diff --git a/client/table.go b/client/table.go index a7b45d1..1e871e3 100644 --- a/client/table.go +++ b/client/table.go @@ -19,6 +19,7 @@ package client import ( "fmt" + "net" "strconv" "strings" "time" @@ -94,6 +95,27 @@ func initHeaders() { headerSlice[HumanTime] = header{"Time", 30} } +func hostColumnValue(addr string) string { + if host, _, err := net.SplitHostPort(addr); err == nil { + return host + } + return strings.Trim(addr, "[]") +} + +func growHostColumns(dps []shared.DP) { + if headerSlice[0].width == 0 { + initHeaders() + } + for i := range dps { + if w := len(hostColumnValue(dps[i].Local)); w > headerSlice[Local].width { + headerSlice[Local].width = w + } + if w := len(hostColumnValue(dps[i].Remote)); w > headerSlice[Remote].width { + headerSlice[Remote].width = w + } + } +} + func GenerateFormatString(columnCount int) (fs string) { for i := 0; i < columnCount; i++ { fs += "%-*s " @@ -256,8 +278,8 @@ func printTableRow(style lipgloss.Style, entry *shared.DP, t shared.TestType) { PrintColumns( style, column{entry.Created.Format("15:04:05"), headerSlice[Created].width}, - column{strings.Split(entry.Local, ":")[0], headerSlice[Local].width}, - column{strings.Split(entry.Remote, ":")[0], headerSlice[Remote].width}, + column{hostColumnValue(entry.Local), headerSlice[Local].width}, + column{hostColumnValue(entry.Remote), headerSlice[Remote].width}, column{shared.BWToString(entry.TX), headerSlice[TX].width}, column{formatInt(int64(entry.ErrCount)), headerSlice[ErrCount].width}, column{formatInt(int64(entry.DroppedPackets)), headerSlice[DroppedPackets].width}, @@ -269,8 +291,8 @@ func printTableRow(style lipgloss.Style, entry *shared.DP, t shared.TestType) { PrintColumns( style, column{entry.Created.Format("15:04:05"), headerSlice[Created].width}, - column{strings.Split(entry.Local, ":")[0], headerSlice[Local].width}, - column{strings.Split(entry.Remote, ":")[0], headerSlice[Remote].width}, + column{hostColumnValue(entry.Local), headerSlice[Local].width}, + column{hostColumnValue(entry.Remote), headerSlice[Remote].width}, column{formatInt(entry.RMSH), headerSlice[RMSH].width}, column{formatInt(entry.RMSL), headerSlice[RMSL].width}, column{formatInt(entry.TTFBH), headerSlice[TTFBH].width}, @@ -312,6 +334,7 @@ func praseDataPoint(r *shared.DataReponseToClient, c *shared.Config) { if len(r.DPS) > 0 { c.TestType = r.DPS[0].Type } + growHostColumns(r.DPS) if len(responseDPS) > 0 { if len(responseDPS)%10 == 0 { printDataPointHeaders(c.TestType) diff --git a/server/server.go b/server/server.go index 5d8e7e8..59f95aa 100644 --- a/server/server.go +++ b/server/server.go @@ -47,6 +47,7 @@ import ( var ( httpServer = fiber.New(fiber.Config{ + Network: fiber.NetworkTCP, StreamRequestBody: true, ServerHeader: "hperf", AppName: "hperf", From c2a9a7c0d933db73935a7883b1610d8508efc207 Mon Sep 17 00:00:00 2001 From: Harshavardhana Date: Wed, 19 Aug 2026 00:19:21 -0700 Subject: [PATCH 2/7] move to go1.26 and update deps to clear govulncheck findings govulncheck reported 15 called vulnerabilities against go1.24 and fiber v2.52.5, failing CI. Set the go directive to 1.26 with a go1.26.6 toolchain, bump the CI matrix to 1.26.x and update the affected modules: gofiber/fiber/v2 v2.52.5 -> v2.52.15 (GO-2026-4543) golang.org/x/net v0.29.0 -> v0.58.0 (GO-2026-5026, GO-2026-4918) valyala/fasthttp v1.55.0 -> v1.73.0 (GO-2026-4950) klauspost/compress v1.17.9 -> v1.19.2 (GO-2026-5841) `govulncheck ./...` now reports no vulnerabilities at symbol, package or module level. --- .github/workflows/vulncheck.yml | 2 +- CLAUDE.md | 2 +- go.mod | 17 +++++++++-------- go.sum | 28 ++++++++++++++-------------- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/.github/workflows/vulncheck.yml b/.github/workflows/vulncheck.yml index 9fa0459..e3f11d5 100644 --- a/.github/workflows/vulncheck.yml +++ b/.github/workflows/vulncheck.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go-version: [ 1.24.x ] + go-version: [ 1.26.x ] steps: - name: Check out code into the Go module directory uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md index c53f2a8..6376c09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ docker build -t hperf:latest . ## Development Notes -- Go version: 1.24 (per go.mod) +- Go version: 1.26 (per go.mod) - Uses Fiber v2 for HTTP/WebSocket server - WebSocket library: gofiber/contrib/websocket (server) and fasthttp/websocket (client) - System metrics: shirou/gopsutil for CPU/memory stats diff --git a/go.mod b/go.mod index 4ee527f..1eb0b37 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,27 @@ module github.com/minio/hperf -go 1.24 +go 1.26 + +toolchain go1.26.6 require ( github.com/charmbracelet/lipgloss v0.13.0 github.com/fasthttp/websocket v1.5.10 github.com/gofiber/contrib/websocket v1.3.2 - github.com/gofiber/fiber/v2 v2.52.5 + github.com/gofiber/fiber/v2 v2.52.15 github.com/google/uuid v1.6.0 github.com/minio/cli v1.24.2 github.com/minio/pkg/v3 v3.0.20 github.com/shirou/gopsutil v3.21.11+incompatible - golang.org/x/sys v0.26.0 + golang.org/x/sys v0.47.0 ) require ( - github.com/andybalholm/brotli v1.1.0 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/ansi v0.1.4 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/compress v1.19.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -30,8 +32,7 @@ require ( github.com/tklauser/go-sysconf v0.3.14 // indirect github.com/tklauser/numcpus v0.8.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.55.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect + github.com/valyala/fasthttp v1.73.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/net v0.29.0 // indirect + golang.org/x/net v0.58.0 // indirect ) diff --git a/go.sum b/go.sum index e433394..1d9206a 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= @@ -16,12 +16,12 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/gofiber/contrib/websocket v1.3.2 h1:AUq5PYeKwK50s0nQrnluuINYeep1c4nRCJ0NWsV3cvg= github.com/gofiber/contrib/websocket v1.3.2/go.mod h1:07u6QGMsvX+sx7iGNCl5xhzuUVArWwLQ3tBIH24i+S8= -github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= -github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/gofiber/fiber/v2 v2.52.15 h1:Cov1uKeVPyu9q0jSrN60W+A8XNX+/WK8J7cy5osHLIk= +github.com/gofiber/fiber/v2 v2.52.15/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -54,20 +54,20 @@ github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYg github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= -github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s= +github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/urfave/cli.v1 v1.20.0/go.mod h1:vuBzUtMdQeixQj8LVd+/98pzhxNGQoyuPBlsXHOQNO0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From 7ff8d286365b84f22a2bd2104ff7df01b7ae582e Mon Sep 17 00:00:00 2001 From: Harshavardhana Date: Wed, 19 Aug 2026 00:26:11 -0700 Subject: [PATCH 3/7] handle scoped IPv6 zones in URLs and refresh grown table headers Applies two review findings from PR #28. A scoped IPv6 address such as fe80::1%eth0 made url.Parse fail with `invalid URL escape "%et"`, so both the client websocket dial and the server's inter-node requests were rejected before reaching the network. shared.URLHostPort percent-encodes the zone delimiter as RFC 6874 requires, and both callers use it. The server keeps the unencoded address for stats and error messages. growHostColumns now reports whether it widened a column, so the live table reprints its header instead of emitting rows wider than the header above them. Adds tests for URLHostPort, hostColumnValue and growHostColumns. --- client/client.go | 4 +-- client/table.go | 9 ++++-- client/table_test.go | 67 ++++++++++++++++++++++++++++++++++++++++++++ server/server.go | 4 ++- shared/shared.go | 7 +++++ shared/url_test.go | 54 +++++++++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 client/table_test.go create mode 100644 shared/url_test.go diff --git a/client/client.go b/client/client.go index c7180a7..5b5ac9b 100644 --- a/client/client.go +++ b/client/client.go @@ -26,8 +26,8 @@ import ( "errors" "fmt" "math" - "net" "net/http" + "net/url" "os" "reflect" "runtime/debug" @@ -185,7 +185,7 @@ func handleWSConnection(ctx context.Context, c *shared.Config, host string, id i if c.Insecure { scheme = "ws" } - connectString := scheme + "://" + net.JoinHostPort(host, c.Port) + "/ws/" + host + connectString := scheme + "://" + shared.URLHostPort(host, c.Port) + "/ws/" + url.PathEscape(host) con, _, dialErr := dialer.DialContext( ctx, diff --git a/client/table.go b/client/table.go index 1e871e3..62c6537 100644 --- a/client/table.go +++ b/client/table.go @@ -102,18 +102,21 @@ func hostColumnValue(addr string) string { return strings.Trim(addr, "[]") } -func growHostColumns(dps []shared.DP) { +func growHostColumns(dps []shared.DP) (grew bool) { if headerSlice[0].width == 0 { initHeaders() } for i := range dps { if w := len(hostColumnValue(dps[i].Local)); w > headerSlice[Local].width { headerSlice[Local].width = w + grew = true } if w := len(hostColumnValue(dps[i].Remote)); w > headerSlice[Remote].width { headerSlice[Remote].width = w + grew = true } } + return } func GenerateFormatString(columnCount int) (fs string) { @@ -334,9 +337,9 @@ func praseDataPoint(r *shared.DataReponseToClient, c *shared.Config) { if len(r.DPS) > 0 { c.TestType = r.DPS[0].Type } - growHostColumns(r.DPS) + grew := growHostColumns(r.DPS) if len(responseDPS) > 0 { - if len(responseDPS)%10 == 0 { + if grew || len(responseDPS)%10 == 0 { printDataPointHeaders(c.TestType) } } else { diff --git a/client/table_test.go b/client/table_test.go new file mode 100644 index 0000000..a778d3a --- /dev/null +++ b/client/table_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2015-2024 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package client + +import ( + "testing" + + "github.com/minio/hperf/shared" +) + +func TestHostColumnValue(t *testing.T) { + cases := []struct { + addr string + expected string + }{ + {"10.10.1.2", "10.10.1.2"}, + {"10.10.1.2:9010", "10.10.1.2"}, + {"2607:6bc0:8107:432::1", "2607:6bc0:8107:432::1"}, + {"[2607:6bc0:8107:432::1]:9010", "2607:6bc0:8107:432::1"}, + {"[fe80::1%eth0]:9010", "fe80::1%eth0"}, + {"node1.example.com:9010", "node1.example.com"}, + } + + for _, c := range cases { + if got := hostColumnValue(c.addr); got != c.expected { + t.Errorf("hostColumnValue(%q) = %q, expected %q", c.addr, got, c.expected) + } + } +} + +func TestGrowHostColumns(t *testing.T) { + initHeaders() + + if grew := growHostColumns([]shared.DP{{Local: "10.10.1.2", Remote: "10.10.1.3:9010"}}); grew { + t.Error("IPv4 addresses should fit the default column width") + } + if headerSlice[Local].width != 15 || headerSlice[Remote].width != 15 { + t.Errorf("widths changed for IPv4: local=%d remote=%d", headerSlice[Local].width, headerSlice[Remote].width) + } + + v6 := "2607:6bc0:8107:432:8e91:3aff:fec5:79ee" + if grew := growHostColumns([]shared.DP{{Local: v6, Remote: "[" + v6 + "]:9010"}}); !grew { + t.Error("IPv6 addresses should widen the host columns") + } + if headerSlice[Local].width != len(v6) || headerSlice[Remote].width != len(v6) { + t.Errorf("widths not grown to %d: local=%d remote=%d", len(v6), headerSlice[Local].width, headerSlice[Remote].width) + } + + if grew := growHostColumns([]shared.DP{{Local: "10.10.1.2", Remote: "10.10.1.3:9010"}}); grew { + t.Error("columns should not shrink or report a change for narrower addresses") + } +} diff --git a/server/server.go b/server/server.go index 59f95aa..b553271 100644 --- a/server/server.go +++ b/server/server.go @@ -414,6 +414,7 @@ type netPerfReader struct { buf []byte addr string + url string ip string client *http.Client @@ -691,6 +692,7 @@ func newPerformanceReaderForASingleHost(c shared.Config, host string, port strin r = new(netPerfReader) r.lastDataPointTime = time.Now() r.addr = net.JoinHostPort(host, port) + r.url = shared.URLHostPort(host, port) r.ip = host r.buf = make([]byte, c.PayloadSize) r.TTFBL = math.MaxInt64 @@ -772,7 +774,7 @@ func sendRequestToHost(t *test, r *netPerfReader, cid int) { req, err = http.NewRequestWithContext( t.ctx, method, - proto+r.addr+route, + proto+r.url+route, body, ) if err != nil { diff --git a/shared/shared.go b/shared/shared.go index 1101201..8c73974 100644 --- a/shared/shared.go +++ b/shared/shared.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "net" + "net/url" "os" "strconv" "strings" @@ -33,6 +34,12 @@ import ( var DebugEnabled = false +// URLHostPort joins host and port for use inside a URL, percent-encoding the +// zone delimiter of a scoped IPv6 address as RFC 6874 requires. +func URLHostPort(host string, port string) string { + return net.JoinHostPort(url.PathEscape(host), port) +} + type WebsocketSignal struct { SType SignalType Code SignalCode diff --git a/shared/url_test.go b/shared/url_test.go new file mode 100644 index 0000000..cea3c02 --- /dev/null +++ b/shared/url_test.go @@ -0,0 +1,54 @@ +// Copyright (c) 2015-2024 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package shared + +import ( + "net/url" + "testing" +) + +func TestURLHostPort(t *testing.T) { + cases := []struct { + host string + port string + expected string + hostname string + }{ + {"10.10.1.2", "9010", "10.10.1.2:9010", "10.10.1.2"}, + {"node1.example.com", "9010", "node1.example.com:9010", "node1.example.com"}, + {"2607:6bc0:8107:432::1", "9010", "[2607:6bc0:8107:432::1]:9010", "2607:6bc0:8107:432::1"}, + {"::1", "9010", "[::1]:9010", "::1"}, + {"fe80::1%eth0", "9010", "[fe80::1%25eth0]:9010", "fe80::1%eth0"}, + } + + for _, c := range cases { + got := URLHostPort(c.host, c.port) + if got != c.expected { + t.Errorf("URLHostPort(%q, %q) = %q, expected %q", c.host, c.port, got, c.expected) + } + + u, err := url.Parse("http://" + got + "/stream") + if err != nil { + t.Errorf("url.Parse of %q failed: %v", got, err) + continue + } + if u.Hostname() != c.hostname { + t.Errorf("url.Parse(%q).Hostname() = %q, expected %q", got, u.Hostname(), c.hostname) + } + } +} From af98c7544dfe81270f47815ed7716f038a7b8118 Mon Sep 17 00:00:00 2001 From: zveinn Date: Wed, 19 Aug 2026 09:37:05 +0000 Subject: [PATCH 4/7] normalize host entries and compare addresses instead of substrings Host entries reached the wire in whatever spelling the operator typed, and every comparison on them was a substring match. ParseHosts now pushes each entry through shared.NormalizeHost, which drops the brackets around an IPv6 literal and canonicalizes IP literals, so [2001:db8::1], 2001:db8::1 and 2001:0db8:0000:0000:0000:0000:0000:0001 are one host from there on. Bracketed hosts used to work for the websocket only commands and started failing with `invalid URL escape "%5B"` once URLHostPort percent encoded them; they work again, in every command. shared.SameHost replaces the substring comparisons. --host-filter 10.0.0.1 no longer also returns 10.0.0.11, and filtering on fd00::1 no longer returns fd00::10. shared.HostOnly replaces the local port stripping in the table. --dns-server was only logged, never used: hostnames were resolved through the system resolver and the first address won. It now builds a resolver that queries the given server, and the new --ip-family (auto, 4 or 6) selects the family, which is what an IPv6 only cluster addressed by name needs. --- client/table.go | 23 ++---- client/table_test.go | 20 ----- cmd/hperf/analyze.go | 1 + cmd/hperf/bandwidth.go | 1 + cmd/hperf/delete.go | 1 + cmd/hperf/download.go | 1 + cmd/hperf/latency.go | 1 + cmd/hperf/list.go | 1 + cmd/hperf/listen.go | 1 + cmd/hperf/main.go | 8 ++ cmd/hperf/requests.go | 1 + cmd/hperf/stop.go | 1 + cmd/hperf/stream.go | 1 + shared/host.go | 81 ++++++++++++++++++ shared/host_test.go | 184 +++++++++++++++++++++++++++++++++++++++++ shared/shared.go | 90 +++++++++++++++++--- shared/sorting.go | 8 +- shared/url_test.go | 54 ------------ 18 files changed, 372 insertions(+), 106 deletions(-) create mode 100644 shared/host.go create mode 100644 shared/host_test.go delete mode 100644 shared/url_test.go diff --git a/client/table.go b/client/table.go index 62c6537..7e2c087 100644 --- a/client/table.go +++ b/client/table.go @@ -19,9 +19,7 @@ package client import ( "fmt" - "net" "strconv" - "strings" "time" "github.com/charmbracelet/lipgloss" @@ -95,23 +93,16 @@ func initHeaders() { headerSlice[HumanTime] = header{"Time", 30} } -func hostColumnValue(addr string) string { - if host, _, err := net.SplitHostPort(addr); err == nil { - return host - } - return strings.Trim(addr, "[]") -} - func growHostColumns(dps []shared.DP) (grew bool) { if headerSlice[0].width == 0 { initHeaders() } for i := range dps { - if w := len(hostColumnValue(dps[i].Local)); w > headerSlice[Local].width { + if w := len(shared.HostOnly(dps[i].Local)); w > headerSlice[Local].width { headerSlice[Local].width = w grew = true } - if w := len(hostColumnValue(dps[i].Remote)); w > headerSlice[Remote].width { + if w := len(shared.HostOnly(dps[i].Remote)); w > headerSlice[Remote].width { headerSlice[Remote].width = w grew = true } @@ -281,8 +272,8 @@ func printTableRow(style lipgloss.Style, entry *shared.DP, t shared.TestType) { PrintColumns( style, column{entry.Created.Format("15:04:05"), headerSlice[Created].width}, - column{hostColumnValue(entry.Local), headerSlice[Local].width}, - column{hostColumnValue(entry.Remote), headerSlice[Remote].width}, + column{shared.HostOnly(entry.Local), headerSlice[Local].width}, + column{shared.HostOnly(entry.Remote), headerSlice[Remote].width}, column{shared.BWToString(entry.TX), headerSlice[TX].width}, column{formatInt(int64(entry.ErrCount)), headerSlice[ErrCount].width}, column{formatInt(int64(entry.DroppedPackets)), headerSlice[DroppedPackets].width}, @@ -294,8 +285,8 @@ func printTableRow(style lipgloss.Style, entry *shared.DP, t shared.TestType) { PrintColumns( style, column{entry.Created.Format("15:04:05"), headerSlice[Created].width}, - column{hostColumnValue(entry.Local), headerSlice[Local].width}, - column{hostColumnValue(entry.Remote), headerSlice[Remote].width}, + column{shared.HostOnly(entry.Local), headerSlice[Local].width}, + column{shared.HostOnly(entry.Remote), headerSlice[Remote].width}, column{formatInt(entry.RMSH), headerSlice[RMSH].width}, column{formatInt(entry.RMSL), headerSlice[RMSL].width}, column{formatInt(entry.TTFBH), headerSlice[TTFBH].width}, @@ -324,7 +315,7 @@ func collectDataPointv2(r *shared.DataReponseToClient) { responseERR = append(responseERR, r.Errors...) } -func praseDataPoint(r *shared.DataReponseToClient, c *shared.Config) { +func printAndCollectDataPoints(r *shared.DataReponseToClient, c *shared.Config) { if r == nil { return } diff --git a/client/table_test.go b/client/table_test.go index a778d3a..13dfc32 100644 --- a/client/table_test.go +++ b/client/table_test.go @@ -23,26 +23,6 @@ import ( "github.com/minio/hperf/shared" ) -func TestHostColumnValue(t *testing.T) { - cases := []struct { - addr string - expected string - }{ - {"10.10.1.2", "10.10.1.2"}, - {"10.10.1.2:9010", "10.10.1.2"}, - {"2607:6bc0:8107:432::1", "2607:6bc0:8107:432::1"}, - {"[2607:6bc0:8107:432::1]:9010", "2607:6bc0:8107:432::1"}, - {"[fe80::1%eth0]:9010", "fe80::1%eth0"}, - {"node1.example.com:9010", "node1.example.com"}, - } - - for _, c := range cases { - if got := hostColumnValue(c.addr); got != c.expected { - t.Errorf("hostColumnValue(%q) = %q, expected %q", c.addr, got, c.expected) - } - } -} - func TestGrowHostColumns(t *testing.T) { initHeaders() diff --git a/cmd/hperf/analyze.go b/cmd/hperf/analyze.go index db5aae5..caa85a5 100644 --- a/cmd/hperf/analyze.go +++ b/cmd/hperf/analyze.go @@ -28,6 +28,7 @@ var analyzeCMD = cli.Command{ Action: runAnalyze, Flags: []cli.Flag{ dnsServerFlag, + ipFamilyFlag, hostsFlag, portFlag, fileFlag, diff --git a/cmd/hperf/bandwidth.go b/cmd/hperf/bandwidth.go index 8fc29c9..bb92208 100644 --- a/cmd/hperf/bandwidth.go +++ b/cmd/hperf/bandwidth.go @@ -37,6 +37,7 @@ var bandwidthCMD = cli.Command{ testIDFlag, concurrencyFlag, dnsServerFlag, + ipFamilyFlag, microSecondsFlag, printAllFlag, }, diff --git a/cmd/hperf/delete.go b/cmd/hperf/delete.go index 52009ca..237fe94 100644 --- a/cmd/hperf/delete.go +++ b/cmd/hperf/delete.go @@ -28,6 +28,7 @@ var deleteCMD = cli.Command{ Action: runDelete, Flags: []cli.Flag{ dnsServerFlag, + ipFamilyFlag, hostsFlag, portFlag, testIDFlag, diff --git a/cmd/hperf/download.go b/cmd/hperf/download.go index c9767f3..96bb753 100644 --- a/cmd/hperf/download.go +++ b/cmd/hperf/download.go @@ -28,6 +28,7 @@ var statDownloadCMD = cli.Command{ Action: runDownload, Flags: []cli.Flag{ dnsServerFlag, + ipFamilyFlag, hostsFlag, portFlag, testIDFlag, diff --git a/cmd/hperf/latency.go b/cmd/hperf/latency.go index 197765c..4490689 100644 --- a/cmd/hperf/latency.go +++ b/cmd/hperf/latency.go @@ -36,6 +36,7 @@ var latency = cli.Command{ testIDFlag, saveTestFlag, dnsServerFlag, + ipFamilyFlag, microSecondsFlag, printAllFlag, }, diff --git a/cmd/hperf/list.go b/cmd/hperf/list.go index baab8e4..7f413ae 100644 --- a/cmd/hperf/list.go +++ b/cmd/hperf/list.go @@ -28,6 +28,7 @@ var listTestsCMD = cli.Command{ Action: runList, Flags: []cli.Flag{ dnsServerFlag, + ipFamilyFlag, hostsFlag, portFlag, testIDFlag, diff --git a/cmd/hperf/listen.go b/cmd/hperf/listen.go index 08a30ff..6643a7f 100644 --- a/cmd/hperf/listen.go +++ b/cmd/hperf/listen.go @@ -28,6 +28,7 @@ var listenCMD = cli.Command{ Action: runListen, Flags: []cli.Flag{ dnsServerFlag, + ipFamilyFlag, hostsFlag, portFlag, testIDFlag, diff --git a/cmd/hperf/main.go b/cmd/hperf/main.go index 27b7669..e98fd04 100644 --- a/cmd/hperf/main.go +++ b/cmd/hperf/main.go @@ -76,6 +76,7 @@ var ( testIDFlag, saveTestFlag, dnsServerFlag, + ipFamilyFlag, } hostsFlag = cli.StringFlag{ Name: "hosts", @@ -151,6 +152,12 @@ var ( EnvVar: "HPERF_DNS_SERVER", Usage: "use a custom DNS server to resolve hosts", } + ipFamilyFlag = cli.StringFlag{ + Name: "ip-family", + EnvVar: "HPERF_IP_FAMILY", + Value: shared.IPFamilyAuto, + Usage: "address family used when resolving hostnames: auto, 4 or 6", + } printStatsFlag = cli.BoolFlag{ Name: "print-stats", Usage: "Print data points", @@ -255,6 +262,7 @@ func parseConfig(ctx *cli.Context) (*shared.Config, error) { hosts, err := shared.ParseHosts( ctx.String(hostsFlag.Name), ctx.String(dnsServerFlag.Name), + ctx.String(ipFamilyFlag.Name), ) if err != nil { goto Error diff --git a/cmd/hperf/requests.go b/cmd/hperf/requests.go index f698c6a..10d6faf 100644 --- a/cmd/hperf/requests.go +++ b/cmd/hperf/requests.go @@ -39,6 +39,7 @@ var requestsCMD = cli.Command{ testIDFlag, saveTestFlag, dnsServerFlag, + ipFamilyFlag, microSecondsFlag, }, CustomHelpTemplate: `NAME: diff --git a/cmd/hperf/stop.go b/cmd/hperf/stop.go index c1bfdd1..65f326d 100644 --- a/cmd/hperf/stop.go +++ b/cmd/hperf/stop.go @@ -28,6 +28,7 @@ var stopCMD = cli.Command{ Action: runStop, Flags: []cli.Flag{ dnsServerFlag, + ipFamilyFlag, hostsFlag, portFlag, testIDFlag, diff --git a/cmd/hperf/stream.go b/cmd/hperf/stream.go index 50c2d47..d024b05 100644 --- a/cmd/hperf/stream.go +++ b/cmd/hperf/stream.go @@ -37,6 +37,7 @@ var streamCMD = cli.Command{ payloadSizeFlag, restartOnErrorFlag, dnsServerFlag, + ipFamilyFlag, saveTestFlag, }, CustomHelpTemplate: `NAME: diff --git a/shared/host.go b/shared/host.go new file mode 100644 index 0000000..129fb4a --- /dev/null +++ b/shared/host.go @@ -0,0 +1,81 @@ +// Copyright (c) 2015-2024 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package shared + +import ( + "net" + "net/netip" + "net/url" + "strings" +) + +// NormalizeHost canonicalizes a single host entry. Brackets around an IPv6 +// literal are removed and IP literals are rewritten to their canonical form, +// so that the same address always reaches the wire, the self filters and the +// output in one spelling. Hostnames are returned unchanged apart from +// surrounding whitespace. +func NormalizeHost(host string) string { + h := strings.TrimSpace(host) + if len(h) > 1 && h[0] == '[' && h[len(h)-1] == ']' { + h = h[1 : len(h)-1] + } + if addr, err := netip.ParseAddr(h); err == nil { + return addr.String() + } + return h +} + +// HostOnly returns the host part of an address, dropping the port and the +// brackets of an IPv6 literal. Values without a port are returned as they are, +// which is why this cannot use net.SplitHostPort alone: an unbracketed IPv6 +// literal has more colons than SplitHostPort accepts. +func HostOnly(addr string) string { + if host, _, err := net.SplitHostPort(addr); err == nil { + return host + } + return strings.Trim(addr, "[]") +} + +// SameHost reports whether two host entries point at the same machine. IP +// literals are compared as addresses instead of strings, so 10.0.0.1 does not +// match 10.0.0.10 and 2001:db8::1 does match 2001:0db8:0:0:0:0:0:1. Hostnames +// are compared case insensitively. +func SameHost(a string, b string) bool { + na, nb := NormalizeHost(a), NormalizeHost(b) + aa, aerr := netip.ParseAddr(na) + ba, berr := netip.ParseAddr(nb) + if aerr == nil || berr == nil { + if aerr != nil || berr != nil { + return false + } + return aa.Unmap() == ba.Unmap() + } + return strings.EqualFold(na, nb) +} + +// URLHostPort joins host and port for use inside a URL, percent-encoding the +// zone delimiter of a scoped IPv6 address as RFC 6874 requires. +func URLHostPort(host string, port string) string { + h := NormalizeHost(host) + if zone := strings.IndexByte(h, '%'); zone >= 0 { + h = url.PathEscape(h[:zone]) + "%25" + url.PathEscape(h[zone+1:]) + } else { + h = url.PathEscape(h) + } + return net.JoinHostPort(h, port) +} diff --git a/shared/host_test.go b/shared/host_test.go new file mode 100644 index 0000000..ae51f20 --- /dev/null +++ b/shared/host_test.go @@ -0,0 +1,184 @@ +// Copyright (c) 2015-2024 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package shared + +import ( + "net/url" + "slices" + "testing" +) + +func TestNormalizeHost(t *testing.T) { + cases := []struct { + host string + expected string + }{ + {"10.10.1.2", "10.10.1.2"}, + {"[10.10.1.2]", "10.10.1.2"}, + {"2607:6bc0:8107:432::1", "2607:6bc0:8107:432::1"}, + {"[2607:6bc0:8107:432::1]", "2607:6bc0:8107:432::1"}, + {"2607:6BC0:8107:0432:0000:0000:0000:0001", "2607:6bc0:8107:432::1"}, + {"[fe80::1%eth0]", "fe80::1%eth0"}, + {" 10.10.1.2 ", "10.10.1.2"}, + {"node1.example.com", "node1.example.com"}, + } + + for _, c := range cases { + if got := NormalizeHost(c.host); got != c.expected { + t.Errorf("NormalizeHost(%q) = %q, expected %q", c.host, got, c.expected) + } + } +} + +func TestHostOnly(t *testing.T) { + cases := []struct { + addr string + expected string + }{ + {"10.10.1.2", "10.10.1.2"}, + {"10.10.1.2:9010", "10.10.1.2"}, + {"2607:6bc0:8107:432::1", "2607:6bc0:8107:432::1"}, + {"[2607:6bc0:8107:432::1]:9010", "2607:6bc0:8107:432::1"}, + {"[fe80::1%eth0]:9010", "fe80::1%eth0"}, + {"node1.example.com:9010", "node1.example.com"}, + } + + for _, c := range cases { + if got := HostOnly(c.addr); got != c.expected { + t.Errorf("HostOnly(%q) = %q, expected %q", c.addr, got, c.expected) + } + } +} + +func TestSameHost(t *testing.T) { + cases := []struct { + a string + b string + expected bool + }{ + // The bug this replaces: a substring match dropped legitimate peers. + {"10.0.0.10", "10.0.0.1", false}, + {"fd00::10", "fd00::1", false}, + {"10.0.0.1", "10.0.0.1", true}, + // Same address, different spelling. + {"fd00::1", "fd00:0000:0000:0000:0000:0000:0000:0001", true}, + {"[fd00::1]", "fd00::1", true}, + {"::ffff:10.0.0.1", "10.0.0.1", true}, + // Zones belong to the identity of a link local address. + {"fe80::1%eth0", "fe80::1%eth1", false}, + {"fe80::1%eth0", "fe80::1%eth0", true}, + // Hostnames. + {"node1.example.com", "NODE1.example.com", true}, + {"node1.example.com", "node10.example.com", false}, + {"node1.example.com", "10.0.0.1", false}, + } + + for _, c := range cases { + if got := SameHost(c.a, c.b); got != c.expected { + t.Errorf("SameHost(%q, %q) = %v, expected %v", c.a, c.b, got, c.expected) + } + } +} + +func TestURLHostPort(t *testing.T) { + cases := []struct { + host string + port string + expected string + hostname string + }{ + {"10.10.1.2", "9010", "10.10.1.2:9010", "10.10.1.2"}, + {"node1.example.com", "9010", "node1.example.com:9010", "node1.example.com"}, + {"2607:6bc0:8107:432::1", "9010", "[2607:6bc0:8107:432::1]:9010", "2607:6bc0:8107:432::1"}, + {"::1", "9010", "[::1]:9010", "::1"}, + // An already bracketed host has to survive, operators copy them from + // documentation and from Helm values. + {"[::1]", "9010", "[::1]:9010", "::1"}, + {"[2607:6bc0:8107:432::1]", "9010", "[2607:6bc0:8107:432::1]:9010", "2607:6bc0:8107:432::1"}, + // RFC 6874: the zone delimiter is percent encoded inside a URL. + {"fe80::1%eth0", "9010", "[fe80::1%25eth0]:9010", "fe80::1%eth0"}, + {"[fe80::1%eth0]", "9010", "[fe80::1%25eth0]:9010", "fe80::1%eth0"}, + } + + for _, c := range cases { + got := URLHostPort(c.host, c.port) + if got != c.expected { + t.Errorf("URLHostPort(%q, %q) = %q, expected %q", c.host, c.port, got, c.expected) + } + + u, err := url.Parse("http://" + got + "/stream") + if err != nil { + t.Errorf("url.Parse of %q failed: %v", got, err) + continue + } + if u.Hostname() != c.hostname { + t.Errorf("url.Parse(%q).Hostname() = %q, expected %q", got, u.Hostname(), c.hostname) + } + } +} + +func TestParseHostsNormalizes(t *testing.T) { + cases := []struct { + hosts string + expected []string + }{ + {"[2607:6bc0:8107:432::1],[2607:6bc0:8107:432::2]", []string{"2607:6bc0:8107:432::1", "2607:6bc0:8107:432::2"}}, + {"2607:6BC0:8107:0432:0000:0000:0000:0001", []string{"2607:6bc0:8107:432::1"}}, + {"2607:6bc0:8107:432::{1...3}", []string{"2607:6bc0:8107:432::1", "2607:6bc0:8107:432::2", "2607:6bc0:8107:432::3"}}, + {"10.10.1.{2...4}", []string{"10.10.1.2", "10.10.1.3", "10.10.1.4"}}, + {"fe80::1%eth0", []string{"fe80::1%eth0"}}, + {"node1.example.com,node2.example.com", []string{"node1.example.com", "node2.example.com"}}, + } + + for _, c := range cases { + got, err := ParseHosts(c.hosts, "", IPFamilyAuto) + if err != nil { + t.Errorf("ParseHosts(%q) returned an error: %v", c.hosts, err) + continue + } + if !slices.Equal(got, c.expected) { + t.Errorf("ParseHosts(%q) = %v, expected %v", c.hosts, got, c.expected) + } + } +} + +func TestParseHostsRejectsUnknownFamily(t *testing.T) { + if _, err := ParseHosts("10.10.1.2", "", "ipv5"); err == nil { + t.Error("ParseHosts should reject an unknown ip family") + } +} + +func TestHostFilterDoesNotPrefixMatch(t *testing.T) { + dps := []DP{ + {Local: "10.0.0.1", Remote: "10.0.0.11:9010"}, + {Local: "10.0.0.11", Remote: "10.0.0.1:9010"}, + {Local: "fd00::1", Remote: "[fd00::10]:9010"}, + {Local: "fd00::10", Remote: "[fd00::1]:9010"}, + } + + if got := len(HostFilter("10.0.0.11", dps)); got != 2 { + t.Errorf("HostFilter(10.0.0.11) returned %d data points, expected 2", got) + } + if got := len(HostFilter("fd00::1", dps)); got != 2 { + t.Errorf("HostFilter(fd00::1) returned %d data points, expected 2", got) + } + // Expanded spelling of the same address still matches. + if got := len(HostFilter("fd00:0000:0000:0000:0000:0000:0000:0010", dps)); got != 2 { + t.Errorf("HostFilter(expanded fd00::10) returned %d data points, expected 2", got) + } +} diff --git a/shared/shared.go b/shared/shared.go index 8c73974..5cfcd01 100644 --- a/shared/shared.go +++ b/shared/shared.go @@ -19,11 +19,12 @@ package shared import ( "bytes" + "context" "encoding/json" "errors" "fmt" "net" - "net/url" + "net/netip" "os" "strconv" "strings" @@ -34,12 +35,6 @@ import ( var DebugEnabled = false -// URLHostPort joins host and port for use inside a URL, percent-encoding the -// zone delimiter of a scoped IPv6 address as RFC 6874 requires. -func URLHostPort(host string, port string) string { - return net.JoinHostPort(url.PathEscape(host), port) -} - type WebsocketSignal struct { SType SignalType Code SignalCode @@ -169,6 +164,7 @@ type Config struct { // Client Only ResolveHosts string `json:"-"` + PrintLive bool `json:"-"` PrintStats bool `json:"-"` PrintAll bool `json:"-"` PrintErrors bool `json:"-"` @@ -229,9 +225,53 @@ func BWToString(b uint64) string { return "???" } -func ParseHosts(hosts string, dnsServer string) (list []string, err error) { +// Address families accepted by the --ip-family flag. +const ( + IPFamilyAuto = "auto" + IPFamilyV4 = "4" + IPFamilyV6 = "6" +) + +// lookupNetwork maps an --ip-family value to a net.Resolver network. +func lookupNetwork(family string) (string, error) { + switch family { + case "", IPFamilyAuto: + return "ip", nil + case IPFamilyV4, "ipv4", "v4": + return "ip4", nil + case IPFamilyV6, "ipv6", "v6": + return "ip6", nil + default: + return "", fmt.Errorf("Unknown ip family (%s), use one of: auto, 4, 6", family) + } +} + +// hostResolver returns a resolver that queries dnsServer, or the system +// resolver when dnsServer is empty. +func hostResolver(dnsServer string) *net.Resolver { + if dnsServer == "" { + return net.DefaultResolver + } + if _, _, err := net.SplitHostPort(dnsServer); err != nil { + dnsServer = net.JoinHostPort(NormalizeHost(dnsServer), "53") + } + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network string, _ string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, network, dnsServer) + }, + } +} + +func ParseHosts(hosts string, dnsServer string, family string) (list []string, err error) { list = make([]string, 0) + network, err := lookupNetwork(family) + if err != nil { + return nil, err + } + if dnsServer != "" { DEBUG("Using DNS server: ", dnsServer) } @@ -302,10 +342,34 @@ func ParseHosts(hosts string, dnsServer string) (list []string, err error) { } - for i, host := range list { - if net.ParseIP(host) == nil && dnsServer != "" { + // Normalize before anything else looks at the entries: brackets around an + // IPv6 literal are dropped and addresses are canonicalized, so the client + // URL, the inter-server URLs and the self filters all see one spelling. + normalized := make([]string, 0, len(list)) + for _, host := range list { + host = NormalizeHost(host) + if host == "" { + continue + } + normalized = append(normalized, host) + } + list = normalized + + // Hostnames are only resolved up front when the caller asked for a + // specific DNS server or address family, otherwise they are handed to the + // dialer as they are. + if dnsServer != "" || network != "ip" { + resolver := hostResolver(dnsServer) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + for i, host := range list { + if _, addrErr := netip.ParseAddr(host); addrErr == nil { + continue + } + var ips []net.IP - ips, err = net.LookupIP(host) + ips, err = resolver.LookupIP(ctx, network, host) if err != nil { return } @@ -314,8 +378,8 @@ func ParseHosts(hosts string, dnsServer string) (list []string, err error) { return } - list[i] = ips[0].String() - continue + list[i] = NormalizeHost(ips[0].String()) + DEBUG("Resolved ", host, " to ", list[i]) } } diff --git a/shared/sorting.go b/shared/sorting.go index df975fa..c9e6655 100644 --- a/shared/sorting.go +++ b/shared/sorting.go @@ -2,7 +2,6 @@ package shared import ( "slices" - "strings" ) type SortType string @@ -13,12 +12,15 @@ const ( SortTTFBH SortType = "TTFBH" ) +// HostFilter keeps the data points where host is either end of the measurement. +// The comparison is per address and not a substring match, so filtering on +// 10.0.0.1 does not also return 10.0.0.10. func HostFilter(host string, dps []DP) (filtered []DP) { filtered = make([]DP, 0) for _, v := range dps { - if strings.Contains(v.Local, host) { + if SameHost(HostOnly(v.Local), host) { filtered = append(filtered, v) - } else if strings.Contains(v.Remote, host) { + } else if SameHost(HostOnly(v.Remote), host) { filtered = append(filtered, v) } } diff --git a/shared/url_test.go b/shared/url_test.go deleted file mode 100644 index cea3c02..0000000 --- a/shared/url_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2015-2024 MinIO, Inc. -// -// This file is part of MinIO Object Storage stack -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package shared - -import ( - "net/url" - "testing" -) - -func TestURLHostPort(t *testing.T) { - cases := []struct { - host string - port string - expected string - hostname string - }{ - {"10.10.1.2", "9010", "10.10.1.2:9010", "10.10.1.2"}, - {"node1.example.com", "9010", "node1.example.com:9010", "node1.example.com"}, - {"2607:6bc0:8107:432::1", "9010", "[2607:6bc0:8107:432::1]:9010", "2607:6bc0:8107:432::1"}, - {"::1", "9010", "[::1]:9010", "::1"}, - {"fe80::1%eth0", "9010", "[fe80::1%25eth0]:9010", "fe80::1%eth0"}, - } - - for _, c := range cases { - got := URLHostPort(c.host, c.port) - if got != c.expected { - t.Errorf("URLHostPort(%q, %q) = %q, expected %q", c.host, c.port, got, c.expected) - } - - u, err := url.Parse("http://" + got + "/stream") - if err != nil { - t.Errorf("url.Parse of %q failed: %v", got, err) - continue - } - if u.Hostname() != c.hostname { - t.Errorf("url.Parse(%q).Hostname() = %q, expected %q", got, u.Hostname(), c.hostname) - } - } -} From d7a034a791abeae4bb8ad26a34db6c92146ab845 Mon Sep 17 00:00:00 2001 From: zveinn Date: Wed, 19 Aug 2026 09:37:20 +0000 Subject: [PATCH 5/7] compare addresses when filtering self and exit when the listener fails The self filter matched --real-ip as a substring of the peer address, so a server dropped every peer whose address extended its own. On a four node mesh with 10.89.7.2, 10.89.7.20, 10.89.7.21 and 10.89.7.200 that means the .2 node tested nothing and .20 skipped .200: 8 of 12 directed pairs measured, one line of output about it, exit status 0. IPv6 collides the same way, fd00::1 against fd00::10. isSelfHost compares addresses through shared.SameHost instead, and treats a wildcard bind as no information about our own identity. A failed bind was printed from inside a goroutine while the main loop kept running, so a server that could not listen stayed up and refused every connection - a container in that state reports Up and a pod reports Ready. The listener error is now returned, and the process exits non zero. --- server/self_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++++ server/server.go | 42 +++++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 server/self_test.go diff --git a/server/self_test.go b/server/self_test.go new file mode 100644 index 0000000..22567fb --- /dev/null +++ b/server/self_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2015-2024 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package server + +import "testing" + +func TestIsSelfHost(t *testing.T) { + cases := []struct { + name string + bind string + real string + host string + expected bool + }{ + {"real ip matches", "0.0.0.0:9010", "10.0.0.1", "10.0.0.1", true}, + // The bug this replaces: a substring match dropped these peers and the + // server silently tested a smaller mesh than it was asked to. + {"real ip is a prefix of the peer", "0.0.0.0:9010", "10.0.0.1", "10.0.0.10", false}, + {"real ipv6 is a prefix of the peer", "[::]:9010", "fd00::1", "fd00::10", false}, + {"real ipv6 matches", "[::]:9010", "fd00::1", "fd00::1", true}, + {"real ipv6 matches expanded", "[::]:9010", "fd00::1", "fd00:0000:0000:0000:0000:0000:0000:0001", true}, + {"bind address matches", "10.0.0.1:9010", "", "10.0.0.1", true}, + {"bind address is a prefix of the peer", "10.0.0.1:9010", "", "10.0.0.10", false}, + {"bracketed bind address matches", "[fd00::1]:9010", "", "fd00::1", true}, + {"wildcard bind tells us nothing", "0.0.0.0:9010", "", "10.0.0.1", false}, + {"ipv6 wildcard bind tells us nothing", "[::]:9010", "", "fd00::1", false}, + {"hostnames are compared as names", "0.0.0.0:9010", "node1.example.com", "node1.example.com", true}, + {"hostname does not match a longer name", "0.0.0.0:9010", "node1.example.com", "node10.example.com", false}, + } + + origBind, origReal := bindAddress, realIP + defer func() { + bindAddress, realIP = origBind, origReal + }() + + for _, c := range cases { + bindAddress, realIP = c.bind, c.real + if got := isSelfHost(c.host); got != c.expected { + t.Errorf("%s: isSelfHost(%q) with bind %q and real ip %q = %v, expected %v", + c.name, c.host, c.bind, c.real, got, c.expected) + } + } +} diff --git a/server/server.go b/server/server.go index b553271..cc39e84 100644 --- a/server/server.go +++ b/server/server.go @@ -28,6 +28,7 @@ import ( "math" "net" "net/http" + "net/netip" "os" "runtime" "runtime/debug" @@ -227,17 +228,22 @@ func startAPIandWS(ctx context.Context) (err error) { return c.SendStatus(200) }) + // A failed bind has to end the process, otherwise the server keeps running + // without a listener and looks healthy while refusing every connection. + listenErr := make(chan error, 1) go func() { - err = httpServer.Listen(bindAddress) - if err != nil { - fmt.Println(err) - } + listenErr <- httpServer.Listen(bindAddress) }() routineMonitor <- 1 for { select { + case lerr := <-listenErr: + if lerr != nil { + return fmt.Errorf("unable to listen on %s: %w", bindAddress, lerr) + } + return nil case id := <-routineMonitor: if id == 1 { go getServerStats(id) @@ -362,6 +368,27 @@ func SendDone(c *websocket.Conn) error { return c.WriteJSON(msg) } +// isSelfHost reports whether host points back at this server. Addresses are +// compared as addresses and not as substrings, so --real-ip 10.0.0.1 no longer +// swallows the peer 10.0.0.10 and --real-ip fd00::1 no longer swallows +// fd00::10. +func isSelfHost(host string) bool { + if realIP != "" && shared.SameHost(host, realIP) { + return true + } + + bindHost := shared.HostOnly(bindAddress) + if bindHost == "" { + return false + } + // A wildcard bind says nothing about our own identity, that is what + // --real-ip is for. + if addr, err := netip.ParseAddr(bindHost); err == nil && addr.IsUnspecified() { + return false + } + return shared.SameHost(host, bindHost) +} + func newTest(c shared.Config) (t *test, err error) { testLock.Lock() defer testLock.Unlock() @@ -385,11 +412,8 @@ func newTest(c shared.Config) (t *test, err error) { for i := range c.Hosts { - joinedHostPort := net.JoinHostPort(c.Hosts[i], c.Port) - if realIP != "" && strings.Contains(joinedHostPort, realIP) { - continue - } - if joinedHostPort == bindAddress { + if isSelfHost(c.Hosts[i]) { + shared.DEBUG("Skipping self:", c.Hosts[i]) continue } t.Readers = append(t.Readers, From 514a292d323945fcaa2e57f699b867ed12ddc4bc Mon Sep 17 00:00:00 2001 From: zveinn Date: Wed, 19 Aug 2026 09:37:20 +0000 Subject: [PATCH 6/7] print data points while listening to a running test The Stats handler was wired to the collect only path, which left the printing path orphaned and made `hperf listen` attach to a test and then sit silent. Attached clients now print each data point as it arrives, which is also what makes the header refresh on a grown column useful. Running tests keep printing their own aggregate table. filterSelf compares hosts through shared.SameHost, and the debug lines join host and port instead of concatenating them. --- client/client.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/client/client.go b/client/client.go index 5b5ac9b..9696b4a 100644 --- a/client/client.go +++ b/client/client.go @@ -26,6 +26,7 @@ import ( "errors" "fmt" "math" + "net" "net/http" "net/url" "os" @@ -78,7 +79,7 @@ func (c *wsClient) Remove() (err error) { func filterSelf(hosts []string, self string) []string { for i, v := range hosts { - if v == self { + if shared.SameHost(v, self) { hosts = slices.Delete(hosts, i, i+1) break } @@ -179,7 +180,7 @@ func handleWSConnection(ctx context.Context, c *shared.Config, host string, id i WriteBufferSize: 1000000, } - shared.DEBUG(WarningStyle.Render("Connecting to ", host, ":", c.Port)) + shared.DEBUG(WarningStyle.Render("Connecting to ", net.JoinHostPort(host, c.Port))) scheme := "wss" if c.Insecure { @@ -210,7 +211,7 @@ func handleWSConnection(ctx context.Context, c *shared.Config, host string, id i PrintError(err) return } - shared.DEBUG(SuccessStyle.Render("Connected to ", host, ":", c.Port)) + shared.DEBUG(SuccessStyle.Render("Connected to ", net.JoinHostPort(host, c.Port))) done <- struct{}{} for { @@ -225,7 +226,13 @@ func handleWSConnection(ctx context.Context, c *shared.Config, host string, id i } switch signal.SType { case shared.Stats: - go collectDataPointv2(signal.DataPoint) + // Live tests print an aggregate table of their own, attached + // clients print the incoming data points as they arrive. + if c.PrintLive { + go printAndCollectDataPoints(signal.DataPoint, c) + } else { + go collectDataPointv2(signal.DataPoint) + } case shared.ListTests: go parseTestList(signal.TestList) case shared.GetTest: @@ -308,6 +315,7 @@ func keepAliveLoop(ctx context.Context, c *shared.Config, tickerfunc func() (sho func Listen(ctx context.Context, c shared.Config) (err error) { cancelContext, cancel := context.WithCancel(ctx) defer cancel() + c.PrintLive = true err = initializeClient(cancelContext, &c) if err != nil { return From 8069b22b212fd48b4fa63d51caa97325a705bc9b Mon Sep 17 00:00:00 2001 From: zveinn Date: Wed, 19 Aug 2026 09:37:20 +0000 Subject: [PATCH 7/7] document IPv6 usage and run build and tests in CI The only workflow was govulncheck, so nothing built the code or ran the tests on a pull request. Adds a workflow that checks formatting, builds, vets and runs the tests with -race. Documents the IPv6 host and bind forms, --ip-family and --dns-server, and the fact that a wildcard bind now accepts both address families: with fiber.NetworkTCP, --address 0.0.0.0:9010 listens on IPv6 as well, which matters for an unauthenticated API. --- .github/workflows/go.yml | 36 ++++++++++++++++++++++++++++++++++ CLAUDE.md | 8 ++++++-- README.md | 42 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/go.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..ef5fc4b --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,36 @@ +name: Go +on: + pull_request: + branches: + - master + - main + push: + branches: + - master + - main +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ 1.26.x ] + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + check-latest: true + - name: Check formatting + run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1) + shell: bash + - name: Build + run: go build ./... + shell: bash + - name: Vet + run: go vet ./... + shell: bash + - name: Test + run: go test -race ./... + shell: bash diff --git a/CLAUDE.md b/CLAUDE.md index 6376c09..0a34fc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,9 @@ docker build -t hperf:latest . ### Critical Implementation Details -**Server IP handling**: Servers need `--real-ip` flag when `--address` differs from external IP. Without this, servers report internal IPs in stats and may test against themselves (server/server.go:388-390). +**Server IP handling**: Servers need `--real-ip` flag when `--address` differs from external IP (or is a wildcard). Without this, servers report the bind address in stats and cannot recognize themselves in the host list (`isSelfHost` in server/server.go). + +**Address handling**: All host entries pass through `shared.NormalizeHost` in `ParseHosts`, which strips brackets and canonicalizes IP literals, so one spelling reaches the wire, the self filters and the output. Use `shared.URLHostPort` when building a URL (it percent-encodes an IPv6 zone as RFC 6874 requires), `shared.HostOnly` to drop a port for display, and `shared.SameHost` to compare hosts - never substring matching, which used to make `--real-ip 10.0.0.1` swallow the peer `10.0.0.10`. The server binds with `fiber.NetworkTCP`, so a wildcard bind is dual-stack. **Data persistence**: Test results are saved to `--storage-path` (default: current directory + `/hperf-tests/`). Each data point is JSON with a prefix byte (0=DataPoint, 1=ErrorPoint) followed by newline. Files are named by test ID. @@ -83,6 +85,8 @@ docker build -t hperf:latest . - `--payload-size`: HTTP payload size in bytes (default: 1000000) - `--request-delay`: Delay between requests in milliseconds (default: 0) - `--save`: Save test results on server for later retrieval (default: true) +- `--ip-family`: Address family used when resolving hostnames in `--hosts`: `auto`, `4` or `6` (default: auto) +- `--dns-server`: Resolve hostnames in `--hosts` through this DNS server ## Development Notes @@ -91,7 +95,7 @@ docker build -t hperf:latest . - WebSocket library: gofiber/contrib/websocket (server) and fasthttp/websocket (client) - System metrics: shirou/gopsutil for CPU/memory stats - UI: charmbracelet/lipgloss for terminal styling -- The codebase filters servers from testing themselves: see client/client.go:78-87 and server/server.go:386-399 +- The codebase filters servers from testing themselves: see `filterSelf` in client/client.go and `isSelfHost` in server/server.go ## Helm Deployment diff --git a/README.md b/README.md index d740199..edcebcc 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,42 @@ hperf supports flexible host specification: ./hperf latency --hosts file:/home/user/hosts.txt ``` +### IPv6 + +IPv6 works everywhere IPv4 does. Addresses are accepted with or without +brackets and are canonicalized internally, so `2001:db8::1`, `[2001:db8::1]` +and `2001:0db8:0000:0000:0000:0000:0000:0001` all refer to the same host: + +```bash +# IPv6 literals, ellipsis patterns and scoped link-local addresses +./hperf latency --hosts 2001:db8::1,2001:db8::2 +./hperf latency --hosts 2001:db8::{1...10} +./hperf latency --hosts fe80::1%eth0,fe80::2%eth0 +``` + +Servers need a listener on an IPv6 address: + +```bash +# Dual-stack: accepts IPv4 and IPv6 on every interface +./hperf server --address '[::]:9010' + +# A single IPv6 address, with the same address reported in results +./hperf server --address '[2001:db8::1]:9010' --real-ip 2001:db8::1 +``` + +Note that a wildcard bind (`0.0.0.0:9010` or `[::]:9010`, including the +default) listens for both address families. Bind a specific address if you +need to restrict the server to one family. The server API is unauthenticated, +so this matters when the port is reachable from untrusted networks. + +When `--hosts` contains hostnames, `--ip-family` picks the address family they +resolve to (`auto`, `4` or `6`), and `--dns-server` resolves them through a +specific DNS server: + +```bash +./hperf latency --hosts node{1...4}.example.com --ip-family 6 +``` + ## Understanding Test Results ### Real-Time Output @@ -213,6 +249,8 @@ Find optimal buffer/payload sizes for your workload: | `--request-delay` | 0 | Delay between requests in milliseconds | | `--save` | true | Save test results on servers | | `--insecure` | false | Use HTTP instead of HTTPS | +| `--dns-server` | (system) | DNS server used to resolve hostnames in `--hosts` | +| `--ip-family` | auto | Address family for hostname resolution: `auto`, `4` or `6` | | `--debug` | false | Enable debug output | ### Environment Variables @@ -271,6 +309,10 @@ docker run -p 9010:9010 minio/hperf:latest server --address 0.0.0.0:9010 **Symptom**: Unusually high throughput or low latency results **Solution**: Ensure `--real-ip` matches the external IP used for inter-server communication +### Server exits with "unable to listen on ..." +**Symptom**: The server stops right after start +**Solution**: The bind address is not usable on this host. `--address '[::]:9010'` is the dual-stack wildcard; an IPv6 literal has to be bracketed (`'[2001:db8::1]:9010'`) + ### No data points received **Symptom**: Client shows no statistics during test **Solution**: Check firewall rules, verify servers can reach each other on the specified port, enable `--debug`