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/.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..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,15 +85,17 @@ 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
-- 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
- 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`
diff --git a/client/client.go b/client/client.go
index 745636f..9696b4a 100644
--- a/client/client.go
+++ b/client/client.go
@@ -26,7 +26,9 @@ import (
"errors"
"fmt"
"math"
+ "net"
"net/http"
+ "net/url"
"os"
"reflect"
"runtime/debug"
@@ -77,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
}
@@ -178,12 +180,13 @@ 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)))
- connectString := "wss://" + host + ":" + c.Port + "/ws/" + host
+ scheme := "wss"
if c.Insecure {
- connectString = "ws://" + host + ":" + c.Port + "/ws/" + host
+ scheme = "ws"
}
+ connectString := scheme + "://" + shared.URLHostPort(host, c.Port) + "/ws/" + url.PathEscape(host)
con, _, dialErr := dialer.DialContext(
ctx,
@@ -208,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 {
@@ -223,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:
@@ -306,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
@@ -844,6 +854,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..7e2c087 100644
--- a/client/table.go
+++ b/client/table.go
@@ -20,7 +20,6 @@ package client
import (
"fmt"
"strconv"
- "strings"
"time"
"github.com/charmbracelet/lipgloss"
@@ -94,6 +93,23 @@ func initHeaders() {
headerSlice[HumanTime] = header{"Time", 30}
}
+func growHostColumns(dps []shared.DP) (grew bool) {
+ if headerSlice[0].width == 0 {
+ initHeaders()
+ }
+ for i := range dps {
+ if w := len(shared.HostOnly(dps[i].Local)); w > headerSlice[Local].width {
+ headerSlice[Local].width = w
+ grew = true
+ }
+ if w := len(shared.HostOnly(dps[i].Remote)); w > headerSlice[Remote].width {
+ headerSlice[Remote].width = w
+ grew = true
+ }
+ }
+ return
+}
+
func GenerateFormatString(columnCount int) (fs string) {
for i := 0; i < columnCount; i++ {
fs += "%-*s "
@@ -256,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{strings.Split(entry.Local, ":")[0], headerSlice[Local].width},
- column{strings.Split(entry.Remote, ":")[0], 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},
@@ -269,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{strings.Split(entry.Local, ":")[0], headerSlice[Local].width},
- column{strings.Split(entry.Remote, ":")[0], 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},
@@ -299,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
}
@@ -312,8 +328,9 @@ func praseDataPoint(r *shared.DataReponseToClient, c *shared.Config) {
if len(r.DPS) > 0 {
c.TestType = r.DPS[0].Type
}
+ 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..13dfc32
--- /dev/null
+++ b/client/table_test.go
@@ -0,0 +1,47 @@
+// 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 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/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/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=
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 5d8e7e8..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"
@@ -47,6 +48,7 @@ import (
var (
httpServer = fiber.New(fiber.Config{
+ Network: fiber.NetworkTCP,
StreamRequestBody: true,
ServerHeader: "hperf",
AppName: "hperf",
@@ -226,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)
@@ -361,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()
@@ -384,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,
@@ -413,6 +438,7 @@ type netPerfReader struct {
buf []byte
addr string
+ url string
ip string
client *http.Client
@@ -690,6 +716,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
@@ -771,7 +798,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/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 1101201..5cfcd01 100644
--- a/shared/shared.go
+++ b/shared/shared.go
@@ -19,10 +19,12 @@ package shared
import (
"bytes"
+ "context"
"encoding/json"
"errors"
"fmt"
"net"
+ "net/netip"
"os"
"strconv"
"strings"
@@ -162,6 +164,7 @@ type Config struct {
// Client Only
ResolveHosts string `json:"-"`
+ PrintLive bool `json:"-"`
PrintStats bool `json:"-"`
PrintAll bool `json:"-"`
PrintErrors bool `json:"-"`
@@ -222,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)
}
@@ -295,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
}
@@ -307,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)
}
}