From b88dfdfdd97ef40dfe695392e0a43d5ce352ce0a Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Tue, 21 Apr 2026 21:20:38 +1000 Subject: [PATCH] fix: auto-detect primary IP for admin init --bind default Bare `spx admin init` used a 127.0.0.1 default that kept awsgw on loopback only, so system VMs on br-mgmt could never reach it. Auto-detect the default-route source IPv4 instead, which keeps TLS cert SANs specific (still not a wildcard). Dev-install scripts now pass --bind 127.0.0.1 explicitly to preserve loopback-only behavior on laptops. Closes mulga-siv-7. --- cmd/spinifex/cmd/admin.go | 11 ++++++++++- scripts/dev-install.sh | 2 +- scripts/dev-setup.sh | 2 +- scripts/reset-dev-env.sh | 2 +- spinifex/admin/admin.go | 18 ++++++++++++++++++ spinifex/admin/admin_test.go | 15 +++++++++++++++ 6 files changed, 46 insertions(+), 4 deletions(-) diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index ac77f25e1..f12ed051d 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -239,7 +239,7 @@ func init() { adminInitCmd.Flags().Int("nodes", 3, "Number of nodes to expect for cluster") adminInitCmd.Flags().String("host", "", "Leader node to join (if not specified, tries multicast discovery)") adminInitCmd.Flags().Int("port", 4432, "Port to bind cluster services on") - adminInitCmd.Flags().String("bind", "127.0.0.1", "IP address to bind services to (e.g., 10.11.12.1 for multi-node)") + adminInitCmd.Flags().String("bind", "", "IP address to bind services to (default: auto-detect primary non-loopback IPv4; e.g., 10.11.12.1 for multi-node)") adminInitCmd.Flags().String("cluster-bind", "", "IP address to bind NATS cluster services to (e.g., 10.11.12.1 for multi-node)") adminInitCmd.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)") 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.") @@ -607,6 +607,15 @@ func runAdminInit(cmd *cobra.Command, args []string) { nodes, _ := cmd.Flags().GetInt("nodes") port, _ := cmd.Flags().GetInt("port") bindIP, _ := cmd.Flags().GetString("bind") + if bindIP == "" { + bindIP = admin.DiscoverPrimaryIP() + if bindIP == "" { + fmt.Fprintf(os.Stderr, "❌ Error: could not auto-detect primary IP. Pass --bind explicitly.\n") + fmt.Fprintf(os.Stderr, " Detected local IPs: %v\n", admin.DiscoverLocalIPs()) + os.Exit(1) + } + fmt.Printf("Auto-detected --bind=%s (pass --bind to override)\n", bindIP) + } clusterBind, _ := cmd.Flags().GetString("cluster-bind") clusterRoutesStr, _ := cmd.Flags().GetString("cluster-routes") var clusterRoutes []string diff --git a/scripts/dev-install.sh b/scripts/dev-install.sh index 8cc6cbda6..f823498eb 100755 --- a/scripts/dev-install.sh +++ b/scripts/dev-install.sh @@ -58,7 +58,7 @@ echo "=== Setting up OVN ===" sudo /usr/local/share/spinifex/setup-ovn.sh --management echo "=== Initializing ===" -sudo spx admin init --force --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1 +sudo spx admin init --force --bind 127.0.0.1 --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1 echo "=== Installing CA certificate ===" sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index 3412a2984..096f59fdd 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -176,5 +176,5 @@ echo " make reinstall # Changed a .service file? Re-run full setup" echo "" echo "Legacy workflow (deprecated, retained for pseudo-multinode CI only):" echo " ./scripts/setup-ovn.sh --management" -echo " sudo ./bin/spx admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1" +echo " sudo ./bin/spx admin init --bind 127.0.0.1 --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1" echo " ./scripts/start-dev.sh" diff --git a/scripts/reset-dev-env.sh b/scripts/reset-dev-env.sh index a09f8373c..18a90a0aa 100755 --- a/scripts/reset-dev-env.sh +++ b/scripts/reset-dev-env.sh @@ -168,7 +168,7 @@ echo "Re-initializing OVN" ./scripts/setup-ovn.sh --management $SETUP_OVN_FLAGS echo "Initializing platform" -ADMIN_INIT_ARGS="--region $REGION --az ${REGION}a --node node1 --nodes 1" +ADMIN_INIT_ARGS="--bind 127.0.0.1 --region $REGION --az ${REGION}a --node node1 --nodes 1" # External networking mode: set EXTERNAL_MODE=nat for NAT/DHCP mode (outbound-only), # or leave unset / EXTERNAL_MODE=pool for pool mode (per-VM public IPs). diff --git a/spinifex/admin/admin.go b/spinifex/admin/admin.go index 679c831a8..4620f8b39 100644 --- a/spinifex/admin/admin.go +++ b/spinifex/admin/admin.go @@ -517,6 +517,24 @@ func GenerateCACert(caCertPath, caKeyPath string) error { return nil } +// DiscoverPrimaryIP returns the host's default-route source IPv4, determined +// via a UDP-dial probe against TEST-NET-1 (RFC 5737). No packet is sent — the +// kernel picks the outbound interface for the given destination and we read +// its source address back off the socket. Returns "" when no usable default +// route exists or the resolved source is a loopback. +func DiscoverPrimaryIP() string { + conn, err := net.Dial("udp4", "192.0.2.1:9") + if err != nil { + return "" + } + defer conn.Close() + addr, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok || addr.IP == nil || addr.IP.IsLoopback() { + return "" + } + return addr.IP.String() +} + // DiscoverLocalIPs enumerates all non-loopback network interface addresses on // this machine and returns them as strings. Link-local addresses are excluded. func DiscoverLocalIPs() []string { diff --git a/spinifex/admin/admin_test.go b/spinifex/admin/admin_test.go index b4aa57253..699d7e5d7 100644 --- a/spinifex/admin/admin_test.go +++ b/spinifex/admin/admin_test.go @@ -443,6 +443,21 @@ func TestDiscoverLocalIPs(t *testing.T) { } } +func TestDiscoverPrimaryIP(t *testing.T) { + t.Parallel() + ip := DiscoverPrimaryIP() + if ip == "" { + t.Skip("no default-route interface available in this environment") + } + parsed := net.ParseIP(ip) + require.NotNil(t, parsed, "returned IP should be parseable: %s", ip) + assert.False(t, parsed.IsLoopback(), "should not return loopback: %s", ip) + assert.False(t, parsed.IsUnspecified(), "should not return 0.0.0.0: %s", ip) + + locals := DiscoverLocalIPs() + assert.Contains(t, locals, ip, "primary IP should appear in local interface list") +} + func TestDiscoverHostname(t *testing.T) { t.Parallel() hostname := DiscoverHostname()