Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package goldpinger for openSUSE:Factory checked in at 2026-09-21 12:09:29 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/goldpinger (Old) and /work/SRC/openSUSE:Factory/.goldpinger.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "goldpinger" Mon Sep 21 12:09:29 2026 rev:6 rq:1379201 version:3.11.3 Changes: -------- --- /work/SRC/openSUSE:Factory/goldpinger/goldpinger.changes 2026-04-25 21:35:56.010263119 +0200 +++ /work/SRC/openSUSE:Factory/.goldpinger.new.383539/goldpinger.changes 2026-09-21 12:09:32.251979764 +0200 @@ -1,0 +2,8 @@ +Sun Sep 20 07:23:08 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 3.11.3: + * fix(logging): install configured logger as zap global + * fix(heatmap): skip unknown peer-reported destinations + * fix(client): bound untrusted peer results in CheckCluster + +------------------------------------------------------------------- Old: ---- goldpinger-3.11.2.obscpio New: ---- goldpinger-3.11.3.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ goldpinger.spec ++++++ --- /var/tmp/diff_new_pack.gxrjqE/_old 2026-09-21 12:09:33.403027657 +0200 +++ /var/tmp/diff_new_pack.gxrjqE/_new 2026-09-21 12:09:33.406027782 +0200 @@ -17,7 +17,7 @@ Name: goldpinger -Version: 3.11.2 +Version: 3.11.3 Release: 0 Summary: Tests and displays connectivity between nodes in a Kubernetes cluster License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.gxrjqE/_old 2026-09-21 12:09:33.445029404 +0200 +++ /var/tmp/diff_new_pack.gxrjqE/_new 2026-09-21 12:09:33.449029571 +0200 @@ -1,9 +1,9 @@ <services> <service name="obs_scm" mode="manual"> - <param name="url">https://github.com/bloomberg/goldpinger</param> + <param name="url">https://github.com/bloomberg/goldpinger.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">v3.11.2</param> + <param name="revision">refs/tags/v3.11.3</param> <param name="match-tag">v*</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.gxrjqE/_old 2026-09-21 12:09:33.476030694 +0200 +++ /var/tmp/diff_new_pack.gxrjqE/_new 2026-09-21 12:09:33.478030777 +0200 @@ -1,6 +1,8 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/bloomberg/goldpinger</param> - <param name="changesrevision">6f2fec60743f13d2235345b09ffc4f797d617bd9</param></service></servicedata> + <param name="changesrevision">6f2fec60743f13d2235345b09ffc4f797d617bd9</param></service><service name="tar_scm"> + <param name="url">https://github.com/bloomberg/goldpinger.git</param> + <param name="changesrevision">2475792dbdd0c893982fda0750a473e2d093d67c</param></service></servicedata> (No newline at EOF) ++++++ goldpinger-3.11.2.obscpio -> goldpinger-3.11.3.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goldpinger-3.11.2/Makefile new/goldpinger-3.11.3/Makefile --- old/goldpinger-3.11.2/Makefile 2026-04-23 17:37:08.000000000 +0200 +++ new/goldpinger-3.11.3/Makefile 2026-09-14 20:00:33.000000000 +0200 @@ -1,5 +1,5 @@ name ?= goldpinger -version ?= v3.11.2 +version ?= v3.11.3 bin ?= goldpinger pkg ?= "github.com/bloomberg/goldpinger" tag = $(name):$(version) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goldpinger-3.11.2/charts/goldpinger/Chart.yaml new/goldpinger-3.11.3/charts/goldpinger/Chart.yaml --- old/goldpinger-3.11.2/charts/goldpinger/Chart.yaml 2026-04-23 17:37:08.000000000 +0200 +++ new/goldpinger-3.11.3/charts/goldpinger/Chart.yaml 2026-09-14 20:00:33.000000000 +0200 @@ -1,7 +1,7 @@ apiVersion: v1 name: goldpinger -appVersion: "3.11.2" -version: 1.1.2 +appVersion: "3.11.3" +version: 1.1.3 description: Goldpinger is a tool to help debug, troubleshoot and visualize network connectivity and slowness issues. home: https://github.com/bloomberg/goldpinger sources: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goldpinger-3.11.2/cmd/goldpinger/main.go new/goldpinger-3.11.3/cmd/goldpinger/main.go --- old/goldpinger-3.11.2/cmd/goldpinger/main.go 2026-04-23 17:37:08.000000000 +0200 +++ new/goldpinger-3.11.3/cmd/goldpinger/main.go 2026-09-14 20:00:33.000000000 +0200 @@ -110,6 +110,11 @@ } defer logger.Sync() + // Install this logger as the global returned by zap.L(). Without this, + // every zap.L() call throughout the goldpinger package resolves to zap's + // default no-op logger and those logs are silently dropped. + zap.ReplaceGlobals(logger) + undo := zap.RedirectStdLog(logger) defer undo() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goldpinger-3.11.2/pkg/goldpinger/client.go new/goldpinger-3.11.3/pkg/goldpinger/client.go --- old/goldpinger-3.11.2/pkg/goldpinger/client.go 2026-04-23 17:37:08.000000000 +0200 +++ new/goldpinger-3.11.3/pkg/goldpinger/client.go 2026-09-14 20:00:33.000000000 +0200 @@ -54,12 +54,17 @@ // CheckCluster does a CheckNeighboursNeighbours and analyses results to produce a binary OK or not OK func CheckCluster(ctx context.Context) *models.ClusterHealthResults { + return checkClusterHealth(ctx, SelectPods()) +} + +// checkClusterHealth calls every pod in selectedPods and aggregates the responses +// into a binary OK or not OK for the cluster. +func checkClusterHealth(ctx context.Context, selectedPods map[string]*GoldpingerPod) *models.ClusterHealthResults { start := time.Now() output := models.ClusterHealthResults{ GeneratedAt: strfmt.DateTime(start), OK: true, } - selectedPods := SelectPods() // precompute the expected set of nodes expectedNodes := []string{} @@ -75,41 +80,108 @@ if len(checkAll.Responses) < 1 { output.OK = false } - for _, resp := range checkAll.Responses { + for podName, resp := range checkAll.Responses { + verdict := evaluatePeerResponse(podName, resp, expectedNodes) // 1. check that all nodes report OK - if *resp.OK { - output.NodesHealthy = append(output.NodesHealthy, resp.HostIP.String()) + if verdict.reachable { + output.NodesHealthy = append(output.NodesHealthy, verdict.hostIP) } else { - output.NodesUnhealthy = append(output.NodesUnhealthy, resp.HostIP.String()) + output.NodesUnhealthy = append(output.NodesUnhealthy, verdict.hostIP) output.OK = false } output.NodesTotal++ // 2. check that all nodes report the expected peers - // on error, there might be no response from the node - if resp.Response == nil { - output.OK = false - continue - } - // if we get a response, let's check we get the expected nodes - observedNodes := []string{} - for _, peer := range resp.Response.PodResults { - observedNodes = append(observedNodes, string(peer.HostIP)) - } - sort.Strings(observedNodes) - if len(observedNodes) != len(expectedNodes) { + if !verdict.reportsExpectedNodes { output.OK = false } - for i, val := range observedNodes { - if val != expectedNodes[i] { - output.OK = false - break - } - } } output.DurationNs = time.Since(start).Nanoseconds() return &output } +// peerVerdict is the outcome of inspecting a single peer's response. +type peerVerdict struct { + // hostIP is the node the peer was discovered on, as we know it - never + // as the peer reports it. + hostIP string + // reachable is true if the peer answered our /check call successfully. + reachable bool + // reportsExpectedNodes is true if the peer's view of the cluster matches ours. + reportsExpectedNodes bool +} + +// evaluatePeerResponse inspects a single peer's response to our /check call. +// +// Peer identity rests on kubernetes metadata and network reachability rather +// than on any cryptographic authentication, so anything able to join the +// discovered peer set can serve an arbitrary body here. The response is +// therefore untrusted input: nothing below indexes or dereferences a +// peer-controlled value without first bounding its shape, and any panic that +// still gets through is recovered so one hostile peer cannot take the whole +// process down (CWE-248). +func evaluatePeerResponse(podName string, resp models.CheckAllPodResult, expectedNodes []string) (verdict peerVerdict) { + defer func() { + if recovered := recover(); recovered != nil { + zap.L().Error( + "Recovered from panic while processing a peer response", + zap.String("op", "checkCluster"), + zap.String("name", podName), + zap.String("hostIP", verdict.hostIP), + zap.Any("panic", recovered), + ) + CountError("checkClusterPeerResponse") + verdict.reachable = false + verdict.reportsExpectedNodes = false + } + }() + + verdict.hostIP = resp.HostIP.String() + verdict.reachable = resp.OK != nil && *resp.OK + // on error, there might be no response from the node + if resp.Response == nil { + return + } + + verdict.reportsExpectedNodes = reportsExpectedNodes(expectedNodes, resp.Response.PodResults) + if !verdict.reportsExpectedNodes { + zap.L().Warn( + "Peer reported an unexpected set of nodes", + zap.String("op", "checkCluster"), + zap.String("name", podName), + zap.String("hostIP", verdict.hostIP), + zap.Int("observedPodResults", len(resp.Response.PodResults)), + zap.Int("expectedNodes", len(expectedNodes)), + ) + } + return +} + +// reportsExpectedNodes reports whether the host IPs in an untrusted peer's pod +// results are exactly the nodes we expect, compared as sorted multisets. +// expectedNodes must already be sorted. +// +// The count is compared first, both because a peer that reports a different +// number of nodes already disagrees with us and because it bounds the work we +// do - and the slice we index - to the number of pods we discovered ourselves. +// A peer that returns extra or unexpected entries is simply not in agreement; +// it must never be able to walk this loop off the end of expectedNodes. +func reportsExpectedNodes(expectedNodes []string, podResults map[string]models.PodResult) bool { + if len(podResults) != len(expectedNodes) { + return false + } + observedNodes := make([]string, 0, len(expectedNodes)) + for _, peer := range podResults { + observedNodes = append(observedNodes, string(peer.HostIP)) + } + sort.Strings(observedNodes) + for i, val := range observedNodes { + if val != expectedNodes[i] { + return false + } + } + return true +} + // PingAllPodsResult holds results from pinging all nodes type PingAllPodsResult struct { podName string diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goldpinger-3.11.2/pkg/goldpinger/client_test.go new/goldpinger-3.11.3/pkg/goldpinger/client_test.go --- old/goldpinger-3.11.2/pkg/goldpinger/client_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/goldpinger-3.11.3/pkg/goldpinger/client_test.go 2026-09-14 20:00:33.000000000 +0200 @@ -0,0 +1,286 @@ +// Copyright 2018 Bloomberg Finance L.P. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package goldpinger + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "github.com/bloomberg/goldpinger/v3/pkg/models" + "github.com/go-openapi/strfmt" +) + +const ( + // peerPodName is the name goldpinger discovers for the peer under test. + peerPodName = "goldpinger-peer" + // peerHostIP is the host IP goldpinger discovers for the peer under test. + // The peer is dialled over loopback, but is reported to live on this node. + peerHostIP = "10.0.0.1" + // peerLoopbackIP is the address the peer's HTTP server actually listens on. + peerLoopbackIP = "127.0.0.1" + // checkPath is the path the generated client calls for a /check. + checkPath = "/check" + + testCheckTimeout = 5 * time.Second + testCheckAllTimeout = 10 * time.Second + testHostname = "goldpinger-under-test" + + // floodedPodResults is the number of pod results a hostile peer crams into + // a single /check response. Any value greater than the number of pods we + // discovered ourselves is enough to walk off the end of the expected list. + floodedPodResults = 5000 +) + +// startPeer runs a real HTTP server answering GET /check with the given +// handler and points GoldpingerConfig at its port, so the generated swagger +// client dials it over a real connection. It returns the single-pod peer set +// that checkClusterHealth should be given. +func startPeer(t *testing.T, handler http.Handler) map[string]*GoldpingerPod { + t.Helper() + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("could not parse peer server URL %q: %v", server.URL, err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatalf("could not parse peer server port from %q: %v", server.URL, err) + } + + originalConfig := GoldpingerConfig + t.Cleanup(func() { GoldpingerConfig = originalConfig }) + GoldpingerConfig.Port = port + GoldpingerConfig.UseHostIP = false + GoldpingerConfig.Hostname = testHostname + GoldpingerConfig.CheckTimeout = testCheckTimeout + GoldpingerConfig.CheckAllTimeout = testCheckAllTimeout + + // Checking a peer registers per-peer label sets on package-global + // collectors. Drop them again while the config they were labelled with is + // still in place, so we don't leak label sets into other tests. Registered + // after the config restore above so cleanups run in that order. + t.Cleanup(func() { DeletePeerMetrics(peerHostIP, peerLoopbackIP) }) + + return map[string]*GoldpingerPod{ + peerPodName: {Name: peerPodName, PodIP: peerLoopbackIP, HostIP: peerHostIP}, + } +} + +// checkHandler serves body as the JSON response to GET /check, and fails the +// test if the client asks for anything else. +func checkHandler(t *testing.T, body []byte) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != checkPath { + t.Errorf("peer received unexpected request for %q, want %q", r.URL.Path, checkPath) + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(body); err != nil { + t.Errorf("peer could not write response body: %v", err) + } + }) +} + +// peerCheckResults builds a /check response body listing one pod result per +// host IP given, as an honest peer would report its own neighbours. +func peerCheckResults(t *testing.T, hostIPs ...string) []byte { + t.Helper() + + ok := true + results := models.CheckResults{PodResults: make(map[string]models.PodResult, len(hostIPs))} + for index, hostIP := range hostIPs { + results.PodResults[fmt.Sprintf("pod-%d", index)] = models.PodResult{ + OK: &ok, + HostIP: strfmt.IPv4(hostIP), + PodIP: strfmt.IPv4(hostIP), + StatusCode: http.StatusOK, + } + } + + body, err := json.Marshal(results) + if err != nil { + t.Fatalf("could not marshal peer check results: %v", err) + } + return body +} + +// syntheticHostIPs generates count distinct host IPs, for peers that report +// far more nodes than actually exist. +func syntheticHostIPs(count int) []string { + hostIPs := make([]string, 0, count) + for i := 0; i < count; i++ { + hostIPs = append(hostIPs, fmt.Sprintf("10.%d.%d.%d", 1+i/65536, (i/256)%256, i%256)) + } + return hostIPs +} + +// TestCheckClusterHealthWithHostilePeer covers /cluster_health aggregation when +// the single discovered peer returns crafted results. A workload that can join +// the peer set controls this body entirely, so no shape of it may panic the +// process (CWE-248) — it may only mark the cluster not OK. +func TestCheckClusterHealthWithHostilePeer(t *testing.T) { + tests := []struct { + name string + // body is the raw bytes the peer serves for GET /check. + body []byte + // wantOK is the expected aggregated cluster verdict. + wantOK bool + }{ + { + name: "peer reports exactly the expected node", + body: peerCheckResults(t, peerHostIP), + wantOK: true, + }, + { + name: "peer reports extra pod results", + body: peerCheckResults(t, peerHostIP, "10.0.0.2", "10.0.0.3"), + wantOK: false, + }, + { + name: "peer reports no pod results", + body: peerCheckResults(t), + wantOK: false, + }, + { + name: "peer reports a node we never discovered", + body: peerCheckResults(t, "10.9.9.9"), + wantOK: false, + }, + { + name: "peer floods pod results", + body: peerCheckResults(t, syntheticHostIPs(floodedPodResults)...), + wantOK: false, + }, + { + name: "peer returns a null body", + body: []byte("null"), + wantOK: false, + }, + { + name: "peer returns an empty object", + body: []byte("{}"), + wantOK: false, + }, + { + name: "peer returns pod results with empty host IPs", + body: []byte(`{"podResults":{"a":{},"b":{}}}`), + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pods := startPeer(t, checkHandler(t, tt.body)) + + // A panic anywhere in here fails the test: that is the bug under test. + output := checkClusterHealth(context.Background(), pods) + + if output.OK != tt.wantOK { + t.Errorf("cluster OK = %v, want %v (healthy=%v unhealthy=%v)", + output.OK, tt.wantOK, output.NodesHealthy, output.NodesUnhealthy) + } + if output.NodesTotal != 1 { + t.Errorf("NodesTotal = %d, want 1 — exactly one peer was discovered", output.NodesTotal) + } + // The peer answered with a 200, so it counts as a reachable node + // regardless of how nonsensical its payload was. + if len(output.NodesHealthy) != 1 || output.NodesHealthy[0] != peerHostIP { + t.Errorf("NodesHealthy = %v, want [%s]", output.NodesHealthy, peerHostIP) + } + if len(output.NodesUnhealthy) != 0 { + t.Errorf("NodesUnhealthy = %v, want []", output.NodesUnhealthy) + } + }) + } +} + +// TestCheckClusterHealthWithUnreachablePeer covers peers that fail the /check +// call outright: they must be reported unhealthy rather than crashing or +// silently passing. +func TestCheckClusterHealthWithUnreachablePeer(t *testing.T) { + tests := []struct { + name string + handler http.Handler + }{ + { + name: "peer returns a server error", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }), + }, + { + name: "peer returns a body that is not JSON", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte("this is not json")); err != nil { + t.Errorf("peer could not write response body: %v", err) + } + }), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pods := startPeer(t, tt.handler) + + output := checkClusterHealth(context.Background(), pods) + + if output.OK { + t.Errorf("cluster OK = true, want false for an unreachable peer") + } + if output.NodesTotal != 1 { + t.Errorf("NodesTotal = %d, want 1", output.NodesTotal) + } + if len(output.NodesUnhealthy) != 1 || output.NodesUnhealthy[0] != peerHostIP { + t.Errorf("NodesUnhealthy = %v, want [%s]", output.NodesUnhealthy, peerHostIP) + } + if len(output.NodesHealthy) != 0 { + t.Errorf("NodesHealthy = %v, want []", output.NodesHealthy) + } + }) + } +} + +// TestCheckClusterHealthWithNoPeers verifies the degenerate case: with nothing +// to talk to, we should at least not report the cluster as OK. +func TestCheckClusterHealthWithNoPeers(t *testing.T) { + originalConfig := GoldpingerConfig + t.Cleanup(func() { GoldpingerConfig = originalConfig }) + GoldpingerConfig.Hostname = testHostname + GoldpingerConfig.CheckTimeout = testCheckTimeout + GoldpingerConfig.CheckAllTimeout = testCheckAllTimeout + + output := checkClusterHealth(context.Background(), map[string]*GoldpingerPod{}) + + if output.OK { + t.Errorf("cluster OK = true, want false when no peers responded") + } + if output.NodesTotal != 0 { + t.Errorf("NodesTotal = %d, want 0", output.NodesTotal) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goldpinger-3.11.2/pkg/goldpinger/heatmap.go new/goldpinger-3.11.3/pkg/goldpinger/heatmap.go --- old/goldpinger-3.11.2/pkg/goldpinger/heatmap.go 2026-04-23 17:37:08.000000000 +0200 +++ new/goldpinger-3.11.3/pkg/goldpinger/heatmap.go 2026-09-14 20:00:33.000000000 +0200 @@ -121,12 +121,20 @@ // draw all the boxes for sourceIP, results := range checkResults.Responses { - if *results.OK { - for destinationIP, response := range results.Response.PodResults { - x, y := getPingBoxCoordinates(order[sourceIP], order[destinationIP], boxSize, paddingSize) - color := getPingBoxColor(response.ResponseTimeMs, tresholdLatencies) - drawPingBox(canvas, boxSize+x, boxSize+y, boxSize, color) + if results.OK == nil || !*results.OK || results.Response == nil { + continue + } + for destinationIP, response := range results.Response.PodResults { + // Peer responses are untrusted: a peer can name any destination it + // likes. Skip the ones we didn't discover ourselves rather than + // letting them default to row 0 and paint over a real peer's row. + destination, known := order[destinationIP] + if !known { + continue } + x, y := getPingBoxCoordinates(order[sourceIP], destination, boxSize, paddingSize) + color := getPingBoxColor(response.ResponseTimeMs, tresholdLatencies) + drawPingBox(canvas, boxSize+x, boxSize+y, boxSize, color) } } ++++++ goldpinger.obsinfo ++++++ --- /var/tmp/diff_new_pack.gxrjqE/_old 2026-09-21 12:09:33.823045133 +0200 +++ /var/tmp/diff_new_pack.gxrjqE/_new 2026-09-21 12:09:33.828045341 +0200 @@ -1,5 +1,5 @@ name: goldpinger -version: 3.11.2 -mtime: 1776958628 -commit: 6f2fec60743f13d2235345b09ffc4f797d617bd9 +version: 3.11.3 +mtime: 1789408833 +commit: 2475792dbdd0c893982fda0750a473e2d093d67c ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/goldpinger/vendor.tar.gz /work/SRC/openSUSE:Factory/.goldpinger.new.383539/vendor.tar.gz differ: char 140, line 2
