Fix telemetry sender restarts and dual-stack health probes

An unchanged telemetry preference save restarted the sender and emitted a
new startup event. Invoke its callback only after a persisted boolean
transition so unrelated and repeated settings saves preserve its lifecycle.

An IPv6 wildcard listener can serve IPv4 while IPv6 loopback is disabled.
Inspect the bound socket mode and use IPv4 loopback for dual-stack sockets
while retaining IPv6 loopback for IPv6-only listeners.

Pin both regressions with persisted-settings and HTTP/HTTPS listener tests.
Keep the telemetry observation limits in the owning subsystem contracts.
This commit is contained in:
rcourtman 2026-09-30 21:10:00 +01:00
parent fa1d9eb36d
commit f50c4d6e3b
11 changed files with 179 additions and 3 deletions

View file

@ -3642,6 +3642,14 @@ fields. No agent-lifecycle behavior keyed off them — agent update targeting
and command admission are unaffected — and the extension-point expectations
on the system-settings boundary are otherwise unchanged.
### Shared telemetry settings preserve sender lifecycle ownership
Telemetry preference saves invoke the sender callback only after a persisted
effective boolean transition. Repeated saves do not restart that sender.
This lifecycle belongs to telemetry reporting, not agent registration or
command admission. A telemetry `startup` event also follows re-enabling the
sender and therefore cannot serve as a count of agent or server restarts.
### Shared system-settings boundary gained an SSH backoff reset side effect
The shared `internal/api` system-settings surface this subsystem consumes

View file

@ -4729,6 +4729,14 @@ ignores them without a validation error and never writes them back into
`internal/api/system_settings_telemetry_test.go` and the response snapshot in
`internal/api/contract_test.go` pin that payload shape.
### Telemetry preference saves preserve the sender lifecycle
The telemetry preference callback is change-driven after durable persistence.
Resubmitting the current `telemetryEnabled` boolean saves the preference
without restarting or stopping its sender. Actual disable/re-enable transitions
still invoke the live callback. `TestTelemetryUpdate_OnlyPreferenceTransitionsToggle`
pins repeated saves, both transitions, and the persisted boolean.
### System settings save clears the temperature SSH failure backoff
A successful `POST /api/system-settings` save now also calls

View file

@ -1502,6 +1502,15 @@ error, account, customer, or infrastructure identity may enter the payload or
persisted receiver row. The previous-release fields are direct adjacent-release
observations, not 30-day update counters, and are the only valid basis for a
before/after release-health cohort in the adoption report.
For an unspecified IPv6 TCP listener, the probe inspects the bound socket's
IPv6-only option. A dual-stack socket uses IPv4 loopback, including when IPv6
is disabled on loopback, while an IPv6-only socket retains IPv6 loopback.
Saving an unchanged effective `telemetryEnabled` value must not restart the
sender. Only a persisted preference transition invokes its live toggle.
The `startup` event also follows a genuine telemetry re-enable, so its count
alone does not establish process restarts. A degraded active-alert persistence
gauge establishes a recovery marker, not its filesystem or database cause.
Neither anonymous signal identifies a customer or proves a release regression.
That same outbound usage telemetry floor now also permits only content-free Pulse
Patrol control and governed Pulse Intelligence operations adoption flags and
counters inside the same rotating 30-day telemetry window:

View file

@ -2778,6 +2778,15 @@ fields. Persisted `system.json` files that still carry the legacy keys load
cleanly with the keys ignored, so tenant workspace preservation and recovery
flows that copy `system.json` forward are unaffected.
### Shared telemetry preference updates preserve recovery ownership
Telemetry preference updates remain durable before the sender callback.
Resubmitting the current effective boolean persists the preference without
restarting the sender. The settings document and recovery ownership retain
their existing shapes. A degraded active-alert persistence gauge separately
records the presence of its recovery marker and must not be interpreted as a
specific disk, database, or migration diagnosis without local evidence.
### Shared system-settings boundary gained an SSH backoff reset side effect
The shared `internal/api` system-settings surface this subsystem consumes

View file

@ -55,7 +55,7 @@ type SystemSettingsHandler struct {
// runtime, so the router can reconfigure the monitor's checker and
// collector without a restart.
guestDockerInventoryToggleFunc func()
mtMonitor interface {
mtMonitor interface {
GetMonitor(string) (*monitoring.Monitor, error)
}
defaultMonitor SystemSettingsMonitor
@ -1069,7 +1069,8 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
Bool("enabled", settings.EnableProxmoxGuestDockerInventory).
Msg("Proxmox guest Docker inventory opt-in changed via settings")
}
if _, ok := rawRequest["telemetryEnabled"]; ok && settings.TelemetryEnabled != nil {
if _, ok := rawRequest["telemetryEnabled"]; ok && settings.TelemetryEnabled != nil &&
h.config.TelemetryEnabled != *settings.TelemetryEnabled {
h.config.TelemetryEnabled = *settings.TelemetryEnabled
if h.telemetryToggleFunc != nil {
h.telemetryToggleFunc(*settings.TelemetryEnabled)

View file

@ -403,6 +403,43 @@ func TestTelemetryUpdate_NoMutationOnPersistFailure(t *testing.T) {
}
}
func TestTelemetryUpdate_OnlyPreferenceTransitionsToggle(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir, ConfigPath: tempDir, TelemetryEnabled: true,
EnvOverrides: make(map[string]bool),
}
handler, persistence, token := setupTelemetryTest(t, cfg)
settings := config.DefaultSystemSettings()
enabled := true
settings.TelemetryEnabled = &enabled
if err := persistence.SaveSystemSettings(*settings); err != nil {
t.Fatal(err)
}
var toggles []bool
handler.SetTelemetryToggleFunc(func(value bool) { toggles = append(toggles, value) })
for i, value := range []bool{true, true, false, false, true, true} {
body, err := json.Marshal(map[string]interface{}{"telemetryEnabled": value})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK || cfg.TelemetryEnabled != value {
t.Fatalf("save %d returned %d, enabled=%v: %s", i, rec.Code, cfg.TelemetryEnabled, rec.Body.String())
}
persisted, err := persistence.LoadSystemSettings()
if err != nil || persisted.TelemetryEnabled == nil || *persisted.TelemetryEnabled != value {
t.Fatalf("save %d did not preserve requested preference: settings=%#v error=%v", i, persisted, err)
}
}
if len(toggles) != 2 || toggles[0] != false || toggles[1] != true {
t.Fatalf("runtime toggles = %v, want only the disable and re-enable transitions", toggles)
}
}
func TestTelemetryUpdate_UnrelatedUpdateDoesNotToggle(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{

View file

@ -11,6 +11,7 @@ import (
"net/url"
"regexp"
"strings"
"syscall"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/telemetry"
@ -99,7 +100,7 @@ func localServiceHealthBaseURL(listener net.Listener, tlsEnabled bool) (string,
}
ip := tcpAddr.IP
if ip == nil || ip.IsUnspecified() {
if ip != nil && ip.To4() == nil {
if ip != nil && ip.To4() == nil && !serviceHealthListenerAcceptsIPv4(listener) {
ip = net.IPv6loopback
} else {
ip = net.IPv4(127, 0, 0, 1)
@ -112,6 +113,28 @@ func localServiceHealthBaseURL(listener net.Listener, tlsEnabled bool) (string,
return fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(ip.String(), fmt.Sprintf("%d", tcpAddr.Port))), true
}
// An IPv6 wildcard TCP listener can also serve IPv4. Prefer IPv4 loopback
// when its socket actually accepts it: IPv6 can be disabled on loopback even
// while this listener serves the UI/API normally over IPv4. Keep IPv6-only
// listeners on their own address rather than probing another socket's port.
func serviceHealthListenerAcceptsIPv4(listener net.Listener) bool {
socket, ok := listener.(syscall.Conn)
if !ok {
return false
}
raw, err := socket.SyscallConn()
if err != nil {
return false
}
dualStack := false
if err := raw.Control(func(fd uintptr) {
dualStack = serviceHealthSocketIsDualStack(fd)
}); err != nil {
return false
}
return dualStack
}
func frontendAssetPaths(index []byte) []string {
matches := frontendAssetReferencePattern.FindAllSubmatch(index, serviceHealthAssetLimit)
paths := make([]string, 0, len(matches))

View file

@ -0,0 +1,7 @@
//go:build !unix && !windows
package server
func serviceHealthSocketIsDualStack(_ uintptr) bool {
return false
}

View file

@ -0,0 +1,10 @@
//go:build unix
package server
import "golang.org/x/sys/unix"
func serviceHealthSocketIsDualStack(fd uintptr) bool {
v6Only, err := unix.GetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_V6ONLY)
return err == nil && v6Only == 0
}

View file

@ -0,0 +1,10 @@
//go:build windows
package server
import "golang.org/x/sys/windows"
func serviceHealthSocketIsDualStack(fd uintptr) bool {
v6Only, err := windows.GetsockoptInt(windows.Handle(fd), windows.IPPROTO_IPV6, windows.IPV6_V6ONLY)
return err == nil && v6Only == 0
}

View file

@ -5,6 +5,8 @@ import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
@ -14,6 +16,58 @@ import (
"github.com/rs/zerolog"
)
func TestServiceHealthProbeHandlesWildcardListeners(t *testing.T) {
for _, test := range []struct {
name, network, address, wantHost string
}{
{"ipv4", "tcp4", "0.0.0.0:0", "127.0.0.1"},
{"dual-stack", "tcp", "[::]:0", "127.0.0.1"},
{"ipv6-only", "tcp6", "[::]:0", "::1"},
} {
for _, tlsEnabled := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/tls=%v", test.name, tlsEnabled), func(t *testing.T) {
listener, err := net.Listen(test.network, test.address)
if err != nil {
if test.network != "tcp4" {
t.Skipf("IPv6 listener unavailable: %v", err)
}
t.Fatal(err)
}
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/health":
fmt.Fprint(w, `{"status":"healthy"}`)
case "/":
fmt.Fprint(w, `<html><script src="/assets/app.js"></script></html>`)
case "/assets/app.js":
fmt.Fprint(w, "console.log('ok')")
default:
http.NotFound(w, r)
}
}))
_ = server.Listener.Close()
server.Listener = listener
if tlsEnabled {
server.StartTLS()
} else {
server.Start()
}
t.Cleanup(server.Close)
baseURL, ok := localServiceHealthBaseURL(listener, tlsEnabled)
parsed, err := url.Parse(baseURL)
if !ok || err != nil || parsed.Hostname() != test.wantHost {
t.Fatalf("probe target = %q, want bound listener reachable through %s", baseURL, test.wantHost)
}
got := newServiceHealthProbe(listener, tlsEnabled)()
if !got.Observed || !got.Healthy || got.FailureCategory != "" {
t.Fatalf("reachable listener reported unhealthy: %#v", got)
}
})
}
}
}
func TestServiceHealthProbeCoversAPIUIAndFrontendAssets(t *testing.T) {
tests := []struct {
name string