feat: add install call-home telemetry to admin init and join

Sends anonymous telemetry to install.mulgadc.com/install during
spx admin init and join. Reports machine_id (/etc/machine-id),
region, az, node count, version, and arch. Fire-and-forget with
5-second timeout — never blocks the user.

Opt out with --no-telemetry flag or SPX_NO_TELEMETRY=1 env var.
This commit is contained in:
Ben Duncan
2026-03-30 16:34:24 +10:00
parent 32f5ec92c0
commit 6e807f88cf
3 changed files with 273 additions and 0 deletions
+52
View File
@@ -32,6 +32,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/aws/aws-sdk-go/aws"
@@ -236,6 +237,7 @@ func init() {
adminInitCmd.Flags().String("predastore-nodes", "", "Comma-separated IPs for multi-node Predastore cluster (e.g., 10.11.12.1,10.11.12.2,10.11.12.3). Requires >= 3 nodes.")
adminInitCmd.Flags().String("formation-timeout", "10m", "Timeout for cluster formation (e.g., 5m, 30s)")
adminInitCmd.Flags().String("cluster-name", "spinifex", "NATS cluster name")
adminInitCmd.Flags().Bool("no-telemetry", false, "Disable telemetry metrics sent during init (default: enabled)")
adminInitCmd.Flags().StringSlice("services", nil, "Services this node runs (default: all). Valid: nats,predastore,viperblock,daemon,awsgw,ui")
// External networking flags
@@ -259,6 +261,7 @@ func init() {
adminJoinCmd.Flags().String("cluster-bind", "", "IP address to bind NATS cluster services to (e.g., 10.11.12.1 for multi-node)")
adminJoinCmd.Flags().String("cluster-routes", "", "NATS cluster hosts for routing specify multiple with comma (e.g., 10.11.12.1:4248,10.11.12.2:4248 for multi-node)")
adminJoinCmd.Flags().StringSlice("services", nil, "Services this node runs (default: all)")
adminJoinCmd.Flags().Bool("no-telemetry", false, "Disable telemetry metrics sent during join (default: enabled)")
adminJoinCmd.MarkFlagRequired("node")
adminJoinCmd.MarkFlagRequired("host")
@@ -577,6 +580,31 @@ func runAdminInit(cmd *cobra.Command, args []string) {
gatewayIP, _ := cmd.Flags().GetString("gateway-ip")
noExternal, _ := cmd.Flags().GetBool("no-external")
// Fire telemetry in background (completes during init work, waited at end)
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
if os.Getenv("SPX_NO_TELEMETRY") == "1" {
noTelemetry = true
}
var telemetryWg sync.WaitGroup
defer telemetryWg.Wait()
if !noTelemetry {
telemetryWg.Go(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
admin.SendTelemetry(ctx, admin.TelemetryPayload{
MachineID: admin.ReadMachineID(),
Event: "init",
Region: region,
AZ: az,
Node: node,
Nodes: nodes,
BindIP: bindIP,
Version: Version,
ExternalMode: externalMode,
})
})
}
// Auto-detect network topology
var poolStart, poolEnd string
var detectedNet *admin.DetectedNetwork
@@ -1383,6 +1411,30 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
fmt.Printf("✅ Cluster formation complete! (%d nodes)\n\n", statusResp.Expected)
// Fire telemetry after formation (now we know the cluster topology)
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
if os.Getenv("SPX_NO_TELEMETRY") == "1" {
noTelemetry = true
}
var telemetryWg sync.WaitGroup
defer telemetryWg.Wait()
if !noTelemetry {
telemetryWg.Go(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
admin.SendTelemetry(ctx, admin.TelemetryPayload{
MachineID: admin.ReadMachineID(),
Event: "join",
Region: region,
AZ: az,
Node: node,
Nodes: statusResp.Expected,
BindIP: bindIP,
Version: Version,
})
})
}
// Extract credentials and CA from formation status
creds := statusResp.Credentials
if creds == nil {
+114
View File
@@ -0,0 +1,114 @@
package admin
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"runtime"
"strings"
"time"
)
const defaultTelemetryURL = "https://install.mulgadc.com/install"
// TelemetryPayload is the JSON body sent to the telemetry endpoint.
type TelemetryPayload struct {
MachineID string `json:"machine_id"`
Event string `json:"event"` // "init" or "join"
Region string `json:"region"`
AZ string `json:"az"`
Node string `json:"node"`
Nodes int `json:"nodes"`
BindIP string `json:"bind_ip"`
Version string `json:"version"`
Arch string `json:"arch"`
OS string `json:"os"`
ExternalMode string `json:"external_mode,omitempty"`
Timestamp string `json:"timestamp"`
// URL overrides the telemetry endpoint (for testing only). Not serialized.
URL string `json:"-"`
}
// SendTelemetry posts the payload to the telemetry endpoint.
// It respects the context deadline and logs at Debug level only.
func SendTelemetry(ctx context.Context, payload TelemetryPayload) {
if payload.Arch == "" {
payload.Arch = runtime.GOARCH
}
if payload.OS == "" {
payload.OS = runtime.GOOS
}
if payload.Timestamp == "" {
payload.Timestamp = time.Now().UTC().Format(time.RFC3339)
}
url := payload.URL
if url == "" {
url = defaultTelemetryURL
}
body, err := json.Marshal(payload)
if err != nil {
slog.Debug("telemetry: failed to marshal payload", "error", err)
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
slog.Debug("telemetry: failed to create request", "error", err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Debug("telemetry: request failed", "error", err)
return
}
defer resp.Body.Close()
slog.Debug("telemetry: sent", "status", resp.StatusCode, "event", payload.Event)
}
// ReadMachineID returns a stable anonymous identifier for this machine.
// Reads /etc/machine-id (standard Linux UUID). Falls back to a SHA-256
// hash of hostname + first non-loopback MAC address.
func ReadMachineID() string {
data, err := os.ReadFile("/etc/machine-id")
if err == nil {
id := strings.TrimSpace(string(data))
if id != "" {
return id
}
}
// Fallback: hash hostname + MAC
hostname, _ := os.Hostname()
mac := firstMAC()
hash := sha256.Sum256([]byte(hostname + mac))
return fmt.Sprintf("%x", hash[:16])
}
// firstMAC returns the MAC address of the first non-loopback, up interface.
func firstMAC() string {
ifaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
continue
}
if mac := iface.HardwareAddr.String(); mac != "" {
return mac
}
}
return ""
}
+107
View File
@@ -0,0 +1,107 @@
package admin
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadMachineID_ReturnsNonEmpty(t *testing.T) {
t.Parallel()
id := ReadMachineID()
assert.NotEmpty(t, id, "machine ID should never be empty")
assert.Greater(t, len(id), 8, "machine ID should be a reasonable length")
}
func TestSendTelemetry_PostsCorrectPayload(t *testing.T) {
t.Parallel()
var received TelemetryPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(body, &received))
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
ctx := context.Background()
SendTelemetry(ctx, TelemetryPayload{
MachineID: "test-machine-123",
Event: "init",
Region: "ap-southeast-2",
AZ: "ap-southeast-2a",
Node: "node1",
Nodes: 3,
BindIP: "10.11.12.1",
Version: "v0.5.0",
URL: server.URL,
})
assert.Equal(t, "test-machine-123", received.MachineID)
assert.Equal(t, "init", received.Event)
assert.Equal(t, "ap-southeast-2", received.Region)
assert.Equal(t, 3, received.Nodes)
assert.Equal(t, "v0.5.0", received.Version)
assert.NotEmpty(t, received.Arch, "arch should be auto-filled")
assert.NotEmpty(t, received.OS, "os should be auto-filled")
assert.NotEmpty(t, received.Timestamp, "timestamp should be auto-filled")
}
func TestSendTelemetry_RespectsTimeout(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Second)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
SendTelemetry(ctx, TelemetryPayload{MachineID: "timeout-test", URL: server.URL})
elapsed := time.Since(start)
assert.Less(t, elapsed, 2*time.Second, "should respect context timeout")
}
func TestSendTelemetry_HandlesServerError(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
ctx := context.Background()
SendTelemetry(ctx, TelemetryPayload{MachineID: "error-test", URL: server.URL})
}
func TestSendTelemetry_HandlesUnreachableServer(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
// URL pointing to a port that's not listening
SendTelemetry(ctx, TelemetryPayload{MachineID: "unreachable-test", URL: "http://127.0.0.1:1"})
}
func TestFirstMAC_ReturnsNonEmpty(t *testing.T) {
t.Parallel()
mac := firstMAC()
assert.NotEmpty(t, mac, "expected at least one MAC address")
}