diff --git a/cmd/installer/autoinstall/autoinstall.go b/cmd/installer/autoinstall/autoinstall.go index 649f55988..ea625a0a0 100644 --- a/cmd/installer/autoinstall/autoinstall.go +++ b/cmd/installer/autoinstall/autoinstall.go @@ -96,6 +96,9 @@ func buildConfig() (*install.Config, error) { Disk: disk, Hostname: hostname, RootPassword: password, + // Optional on the headless path — empty means no --email passed to + // spx admin init, which omits the operator identity from telemetry. + Email: strings.TrimSpace(os.Getenv("SPINIFEX_EMAIL")), WANInterface: wanIface, WANDHCPMode: strings.ToLower(os.Getenv("SPINIFEX_WAN_MODE")) != "static", } diff --git a/cmd/installer/firstboot/firstboot.go b/cmd/installer/firstboot/firstboot.go index 99c1bde31..5f765e039 100644 --- a/cmd/installer/firstboot/firstboot.go +++ b/cmd/installer/firstboot/firstboot.go @@ -23,6 +23,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/mulgadc/spinifex/cmd/installer/systemd" ) @@ -38,6 +39,10 @@ type Config struct { ClusterRole string // JoinAddr is host:port of the primary node, only used when ClusterRole is "join". JoinAddr string + // Email is the operator email collected by the TUI or SPINIFEX_EMAIL on + // the headless path. Passed to `spx admin init --email=` when set; + // omitted entirely when empty. + Email string } // Write drops the firstboot script and systemd unit into root, which should be @@ -179,10 +184,24 @@ systemctl start spinifex.target } func buildClusterCmd(cfg Config) string { + emailFlag := "" + if cfg.Email != "" { + // shellEscapeSingle keeps the email safe if it ever contains a + // character shell treats specially — belt-and-braces; the regex + // validator already rejects whitespace and @-chains. + emailFlag = " --email=" + shellEscapeSingle(cfg.Email) + } switch cfg.ClusterRole { case "join": - return fmt.Sprintf("spx admin join --node %s --host %s", cfg.Hostname, cfg.JoinAddr) + return fmt.Sprintf("spx admin join --node %s --host %s%s", cfg.Hostname, cfg.JoinAddr, emailFlag) default: - return fmt.Sprintf("spx admin init --node %s --nodes 1", cfg.Hostname) + return fmt.Sprintf("spx admin init --node %s --nodes 1%s", cfg.Hostname, emailFlag) } } + +// shellEscapeSingle wraps s in single quotes with any embedded single +// quotes escaped. Minimal — we only need this because the email value is +// interpolated into a shell script written by Write(). +func shellEscapeSingle(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/cmd/installer/install/config.go b/cmd/installer/install/config.go index 5c5a9f38f..8a65756be 100644 --- a/cmd/installer/install/config.go +++ b/cmd/installer/install/config.go @@ -61,4 +61,10 @@ type Config struct { // RootPassword is the password to set for the root account on the installed system. RootPassword string + + // Email is the operator's email address, used by the call-home telemetry + // endpoint to notify of important system updates and security advisories. + // Required on interactive installs; may be empty on headless/CI installs + // when SPINIFEX_EMAIL is not supplied on the kernel cmdline. + Email string } diff --git a/cmd/installer/install/install.go b/cmd/installer/install/install.go index ff8ffea83..1bb3eedb3 100644 --- a/cmd/installer/install/install.go +++ b/cmd/installer/install/install.go @@ -568,6 +568,7 @@ func (c *Config) toFirstbootConfig() firstboot.Config { EncapIP: encapIP, ClusterRole: c.ClusterRole, JoinAddr: c.JoinAddr, + Email: c.Email, } } diff --git a/cmd/installer/ui/ui.go b/cmd/installer/ui/ui.go index 82d296502..0ab6d2ec1 100644 --- a/cmd/installer/ui/ui.go +++ b/cmd/installer/ui/ui.go @@ -31,6 +31,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/mulgadc/spinifex/cmd/installer/branding" "github.com/mulgadc/spinifex/cmd/installer/install" + "github.com/mulgadc/spinifex/spinifex/admin" ) // screen represents which step of the wizard is active. @@ -126,10 +127,11 @@ type model struct { joinIPInput textinput.Model joinPortInput textinput.Model - // Password + // Credentials (email + password) + emailInput textinput.Model passwordInput textinput.Model passwordConfirmInput textinput.Model - passwordFocus int // 0 = password, 1 = confirm + credsFocus int // 0 = email, 1 = password, 2 = confirm // Accumulated validation error shown on current screen validationErr string @@ -249,6 +251,11 @@ func newModel(disks []diskInfo, nics []nicInfo) model { joinPortIn.Placeholder = "4432" joinPortIn.CharLimit = 5 + emailIn := textinput.New() + emailIn.Placeholder = "admin@mydomain.com" + emailIn.CharLimit = 254 // RFC 5321 upper bound + emailIn.Width = 40 + passIn := textinput.New() passIn.Placeholder = "Admin password" passIn.EchoMode = textinput.EchoPassword @@ -286,6 +293,7 @@ func newModel(disks []diskInfo, nics []nicInfo) model { lanSSID: lanSSIDIn, lanWiFiPass: lanWiFiPassIn, hostnameInput: hostnameIn, + emailInput: emailIn, passwordInput: passIn, passwordConfirmInput: passConfirmIn, joinIPInput: joinIPIn, @@ -459,8 +467,8 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } m.screen = screenPassword - m.passwordInput.Focus() - m.passwordFocus = 0 + m.emailInput.Focus() + m.credsFocus = 0 default: if m.hostnameInput.Focused() { var cmd tea.Cmd @@ -472,36 +480,37 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case screenPassword: switch key { case "tab", "down": - m.passwordInput.Blur() - m.passwordConfirmInput.Blur() - if m.passwordFocus == 0 { - m.passwordConfirmInput.Focus() - m.passwordFocus = 1 - } else { - m.passwordInput.Focus() - m.passwordFocus = 0 - } + m = m.setCredsFocus((m.credsFocus + 1) % 3) case "shift+tab", "up": - m.passwordInput.Blur() - m.passwordConfirmInput.Blur() - if m.passwordFocus == 1 { - m.passwordInput.Focus() - m.passwordFocus = 0 - } else { - m.passwordConfirmInput.Focus() - m.passwordFocus = 1 - } + m = m.setCredsFocus((m.credsFocus + 2) % 3) case "enter": - if m.passwordFocus == 0 { - m.passwordInput.Blur() - m.passwordConfirmInput.Focus() - m.passwordFocus = 1 + // On email field: validate, then advance to password. + if m.credsFocus == 0 { + if err := admin.ValidateEmail(m.emailInput.Value()); err != nil { + m.validationErr = err.Error() + return m, nil + } + m.validationErr = "" + m = m.setCredsFocus(1) + return m, nil + } + // On password field: just advance (defer validation to confirm). + if m.credsFocus == 1 { + m = m.setCredsFocus(2) + return m, nil + } + // On confirm: validate email (again, in case user tabbed past), + // then validate password + match. + if err := admin.ValidateEmail(m.emailInput.Value()); err != nil { + m.validationErr = err.Error() + m = m.setCredsFocus(0) return m, nil } pw := m.passwordInput.Value() confirm := m.passwordConfirmInput.Value() if pw == "" { m.validationErr = "Password is required" + m = m.setCredsFocus(1) return m, nil } if pw != confirm { @@ -516,15 +525,19 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.screen = screenConfirm } case "esc": + m.emailInput.Blur() m.passwordInput.Blur() m.passwordConfirmInput.Blur() m.screen = screenIdentity m.hostnameInput.Focus() default: var cmd tea.Cmd - if m.passwordFocus == 0 { + switch m.credsFocus { + case 0: + m.emailInput, cmd = m.emailInput.Update(msg) + case 1: m.passwordInput, cmd = m.passwordInput.Update(msg) - } else { + default: m.passwordConfirmInput, cmd = m.passwordConfirmInput.Update(msg) } return m, cmd @@ -556,8 +569,7 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.joinIPInput.Blur() m.joinPortInput.Blur() m.screen = screenPassword - m.passwordConfirmInput.Focus() - m.passwordFocus = 1 + m = m.setCredsFocus(2) default: var cmd tea.Cmd if m.joinIPInput.Focused() { @@ -584,8 +596,7 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.joinPortInput.Blur() } else { m.screen = screenPassword - m.passwordInput.Focus() - m.passwordFocus = 0 + m = m.setCredsFocus(0) } } } @@ -593,6 +604,28 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// setCredsFocus moves focus among the three credential inputs (email, +// password, confirm) and ensures exactly one is focused. Returns the +// updated model — callers must reassign (m = m.setCredsFocus(...)). +// Value receiver keeps model's method set consistent with the other +// bubbletea View/buildConfig methods (avoids golangci-lint recvcheck). +func (m model) setCredsFocus(i int) model { + m.emailInput.Blur() + m.passwordInput.Blur() + m.passwordConfirmInput.Blur() + switch i { + case 0: + m.emailInput.Focus() + case 1: + m.passwordInput.Focus() + default: + m.passwordConfirmInput.Focus() + i = 2 + } + m.credsFocus = i + return m +} + // ── WAN screen key handling ─────────────────────────────────────────────────── func (m model) handleWANKey(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -1065,9 +1098,12 @@ func (m model) updateActiveInput(msg tea.Msg) (tea.Model, tea.Cmd) { } case screenPassword: var cmd tea.Cmd - if m.passwordFocus == 0 { + switch m.credsFocus { + case 0: + m.emailInput, cmd = m.emailInput.Update(msg) + case 1: m.passwordInput, cmd = m.passwordInput.Update(msg) - } else { + default: m.passwordConfirmInput, cmd = m.passwordConfirmInput.Update(msg) } return m, cmd @@ -1364,12 +1400,19 @@ func (m model) viewIdentity(w int) string { } func (m model) viewPassword(w int) string { - title := styleTitle.Render("Root Password") + title := styleTitle.Render("Administrator Credentials") + emailLabel := styleLabel.Render("Email") + emailHelp := styleHelp.Render("Used to notify of important system updates to Spinifex or security announcements") passLabel := styleLabel.Render("Password") confirmLabel := styleLabel.Render("Confirm password") var lines []string - lines = append(lines, title, "", passLabel, m.passwordInput.View(), "", confirmLabel, m.passwordConfirmInput.View()) + lines = append(lines, + title, "", + emailLabel, m.emailInput.View(), emailHelp, "", + passLabel, m.passwordInput.View(), "", + confirmLabel, m.passwordConfirmInput.View(), + ) if m.validationErr != "" { lines = append(lines, "", styleError.Render(m.validationErr)) } @@ -1519,6 +1562,7 @@ func (m model) buildConfig() *install.Config { cfg.JoinAddr = net.JoinHostPort(strings.TrimSpace(m.joinIPInput.Value()), port) } cfg.RootPassword = m.passwordInput.Value() + cfg.Email = strings.TrimSpace(m.emailInput.Value()) return cfg } diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index 69503dce6..a9b017dbb 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -261,6 +261,7 @@ func init() { adminInitCmd.Flags().String("token-ttl", "30m", "Join token validity duration (e.g. 30m, 1h, 2h)") 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().String("email", "", "Operator email address (used for update and security notifications)") adminInitCmd.Flags().StringSlice("services", nil, "Services this node runs (default: all). Valid: nats,predastore,viperblock,daemon,awsgw,ui") // External networking flags @@ -286,6 +287,7 @@ func init() { adminJoinCmd.Flags().String("token", "", "Join token from the init node (required)") 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.Flags().String("email", "", "Operator email address (used for update and security notifications)") adminJoinCmd.MarkFlagRequired("node") adminJoinCmd.MarkFlagRequired("host") adminJoinCmd.MarkFlagRequired("token") @@ -633,6 +635,19 @@ func runAdminInit(cmd *cobra.Command, args []string) { clusterName, _ := cmd.Flags().GetString("cluster-name") services, _ := cmd.Flags().GetStringSlice("services") + // Optional operator email — validated up-front so a bad address fails + // before we touch any config state. Empty is allowed here; reset / repeat + // inits on a box that already has an email in /etc/spinifex/spinifex.toml + // should preserve it (see config-preservation below). + email, _ := cmd.Flags().GetString("email") + email = strings.TrimSpace(email) + if email != "" { + if err := admin.ValidateEmail(email); err != nil { + fmt.Fprintf(os.Stderr, "--email: %v\n", err) + os.Exit(1) + } + } + // External networking flags externalMode, _ := cmd.Flags().GetString("external-mode") externalIface, _ := cmd.Flags().GetString("external-iface") @@ -664,6 +679,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { BindIP: bindIP, Version: Version, ExternalMode: externalMode, + Email: email, }) }) } @@ -821,6 +837,13 @@ func runAdminInit(cmd *cobra.Command, args []string) { os.Exit(0) } + // Preserve the previously-captured operator email across --force re-inits + // when --email is omitted (e.g. reset-dev-env.sh workflows). Without this + // a reset would silently blank the address. + if email == "" && admin.FileExists(spinifexTomlPath) { + email = admin.ReadOperatorEmail(spinifexTomlPath) + } + // Create config directory if err := os.MkdirAll(configDir, 0700); err != nil { fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err) @@ -918,7 +941,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { } runAdminInitMultiNode(cmd, accessKey, secretKey, accountID, natsToken, clusterName, - configDir, spxRoot, certPath, region, az, node, bindIP, clusterBind, + configDir, spxRoot, certPath, region, az, node, bindIP, clusterBind, email, port, nodes, formationTimeoutStr, tokenTTLStr, services, networkConfig) return } @@ -1024,6 +1047,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { PoolPrefixLen: externalPrefixLen, PoolDNSServers: dnsServers, + OperatorEmail: email, BootstrapAccountId: admin.DefaultAccountID(), BootstrapVpcId: bootstrapVpcId, BootstrapSubnetId: bootstrapSubnetId, @@ -1069,7 +1093,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { // It starts a formation server, registers this node, waits for all nodes to join, // then generates configs with complete cluster topology. func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, natsToken, clusterName, - configDir, spxRoot, certPath, region, az, node, bindIP, clusterBind string, + configDir, spxRoot, certPath, region, az, node, bindIP, clusterBind, email string, port, expectedNodes int, formationTimeoutStr, tokenTTLStr string, services []string, networkConfig *formation.NetworkConfig) { formationTimeout, err := time.ParseDuration(formationTimeoutStr) if err != nil { @@ -1239,6 +1263,8 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, Services: services, RemoteNodes: buildRemoteNodes(allNodes, node), + OperatorEmail: email, + // Init node runs ovn-central locally OVNNBAddr: "tcp:127.0.0.1:6641", OVNSBAddr: "tcp:127.0.0.1:6642", @@ -1298,6 +1324,17 @@ func runAdminJoin(cmd *cobra.Command, args []string) { clusterBind, _ := cmd.Flags().GetString("cluster-bind") services, _ := cmd.Flags().GetStringSlice("services") + email, _ := cmd.Flags().GetString("email") + email = strings.TrimSpace(email) + if email != "" { + if err := admin.ValidateEmail(email); err != nil { + fmt.Fprintf(os.Stderr, "--email: %v\n", err) + os.Exit(1) + } + } else if admin.FileExists(filepath.Join(configDir, "spinifex.toml")) { + email = admin.ReadOperatorEmail(filepath.Join(configDir, "spinifex.toml")) + } + // Validate required parameters if node == "" { fmt.Fprintf(os.Stderr, "❌ Error: --node is required\n") @@ -1475,6 +1512,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) { Nodes: statusResp.Expected, BindIP: bindIP, Version: Version, + Email: email, }) }) } @@ -1610,6 +1648,8 @@ func runAdminJoin(cmd *cobra.Command, args []string) { Services: services, RemoteNodes: buildRemoteNodes(statusResp.Nodes, node), + OperatorEmail: email, + // Joining nodes connect to the init node's OVN NB/SB DB OVNNBAddr: fmt.Sprintf("tcp:%s:6641", leaderIP), OVNSBAddr: fmt.Sprintf("tcp:%s:6642", leaderIP), diff --git a/cmd/spinifex/cmd/templates/spinifex.toml b/cmd/spinifex/cmd/templates/spinifex.toml index 432dd70f0..da9318e16 100644 --- a/cmd/spinifex/cmd/templates/spinifex.toml +++ b/cmd/spinifex/cmd/templates/spinifex.toml @@ -2,6 +2,11 @@ version = "2" epoch = 1 node = "{{.Node}}" +{{- if .OperatorEmail}} + +[operator] +email = "{{.OperatorEmail}}" +{{- end}} {{- if .ExternalMode}} [network] diff --git a/docs/terraform-workbooks/nginx-alb/README.md b/docs/terraform-workbooks/nginx-alb/README.md index 36915a2a6..0592cea10 100644 --- a/docs/terraform-workbooks/nginx-alb/README.md +++ b/docs/terraform-workbooks/nginx-alb/README.md @@ -95,6 +95,14 @@ Next install the load balancer AMI images, which is used as the AMI disk image f spx admin images import --name lb-alpine-3.21.6-x86_64 ``` +### Step 2.1 Install Debian AMI + +Next, install the Debian 12 AMI which is used in the example to host the `nginx` webservers as an EC2 instance. + +```bash +spx admin images import --name debian-12-x86_64 +``` + ### Step 3. Deploy ```bash diff --git a/scripts/reset-dev-env.sh b/scripts/reset-dev-env.sh index 4148ecf13..6c33275ed 100755 --- a/scripts/reset-dev-env.sh +++ b/scripts/reset-dev-env.sh @@ -57,6 +57,7 @@ POOL_START="" POOL_END="" EXT_GATEWAY="" EXT_PREFIX="" +OPERATOR_EMAIL="" if sudo test -f "$CONFIG_FILE"; then # Copy config to a temp file we can read without sudo for cleaner parsing. @@ -87,8 +88,13 @@ if sudo test -f "$CONFIG_FILE"; then POOL_END=$(toml_scalar '^\[\[network\.external_pools\]\]' 'range_end') EXT_GATEWAY=$(toml_scalar '^\[\[network\.external_pools\]\]' 'gateway') EXT_PREFIX=$(toml_scalar '^\[\[network\.external_pools\]\]' 'prefix_len') + OPERATOR_EMAIL=$(toml_scalar '^\[operator\]' 'email') fi +# Allow operator to override on the command line (e.g. first reset on a box +# installed before --email existed): SPINIFEX_EMAIL=me@example.com ./reset-dev-env.sh +OPERATOR_EMAIL="${SPINIFEX_EMAIL:-$OPERATOR_EMAIL}" + # Defaults REGION="${REGION:-ap-southeast-2}" AZ="${AZ:-${REGION}a}" @@ -98,6 +104,9 @@ echo "Preserving: region=$REGION az=$AZ external_mode=$EXT_MODE" if [ "$EXT_MODE" = "pool" ] && [ -n "$POOL_START" ]; then echo " pool: $POOL_START - $POOL_END gw=$EXT_GATEWAY prefix=$EXT_PREFIX" fi +if [ -n "$OPERATOR_EMAIL" ]; then + echo " operator email: $OPERATOR_EMAIL" +fi # --- Shutdown services --- echo "==> Stopping services" @@ -220,10 +229,14 @@ esac # --- Initialize platform --- echo "==> Initializing platform (region=$REGION az=$AZ)" +EMAIL_INIT_ARG="" +if [ -n "$OPERATOR_EMAIL" ]; then + EMAIL_INIT_ARG="--email=$OPERATOR_EMAIL" +fi # shellcheck disable=SC2086 # intentional word-splitting for arg list sudo /usr/local/bin/spx admin init --force \ --region "$REGION" --az "$AZ" --node node1 --nodes 1 \ - $EXTERNAL_INIT_ARGS + $EXTERNAL_INIT_ARGS $EMAIL_INIT_ARG # --- Install CA cert into system trust store --- echo "==> Installing CA certificate" diff --git a/spinifex/admin/admin.go b/spinifex/admin/admin.go index 679c831a8..484b8db62 100644 --- a/spinifex/admin/admin.go +++ b/spinifex/admin/admin.go @@ -79,6 +79,12 @@ type ConfigSettings struct { PoolPrefixLen int // Subnet prefix length (default 24) PoolDNSServers []string // DNS servers for VM DHCP (auto-detected from host) + // OperatorEmail is the address collected at install time (TUI, SPINIFEX_EMAIL, + // or `spx admin init --email`). Written under [operator] in spinifex.toml so + // reset-dev-env.sh can preserve it across wipes. Empty means no operator + // identity was supplied. + OperatorEmail string + // Other nodes in the cluster (for config source of truth) RemoteNodes []RemoteNode diff --git a/spinifex/admin/email.go b/spinifex/admin/email.go new file mode 100644 index 000000000..c99a28536 --- /dev/null +++ b/spinifex/admin/email.go @@ -0,0 +1,60 @@ +package admin + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strings" +) + +// Deliberately loose — we want to catch obvious typos (missing @, missing +// dot after @, whitespace) without pretending to validate RFC 5321. +// Identical check applied in the installer TUI and in `spx admin init --email`. +var emailRE = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + +// ValidateEmail returns nil if addr is a plausible email address, or an +// error describing the failure. Empty strings fail. +func ValidateEmail(addr string) error { + addr = strings.TrimSpace(addr) + if addr == "" { + return fmt.Errorf("email is required") + } + if !emailRE.MatchString(addr) { + return fmt.Errorf("%q is not a valid email address", addr) + } + return nil +} + +// ReadOperatorEmail extracts the [operator].email scalar from a spinifex.toml +// file, or returns "" if the file doesn't exist, can't be read, or has no +// [operator] section. Used by `spx admin init --force` (and reset flows) to +// preserve a previously-captured email across re-inits when --email isn't +// supplied on the new invocation. Minimal text scan — no TOML parser, since +// we only need one scalar from one section. +func ReadOperatorEmail(tomlPath string) string { + f, err := os.Open(tomlPath) + if err != nil { + return "" + } + defer f.Close() + + keyRE := regexp.MustCompile(`^\s*email\s*=\s*"([^"]*)"`) + sc := bufio.NewScanner(f) + inOperator := false + for sc.Scan() { + line := sc.Text() + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") { + inOperator = trimmed == "[operator]" + continue + } + if !inOperator { + continue + } + if m := keyRE.FindStringSubmatch(line); m != nil { + return m[1] + } + } + return "" +} diff --git a/spinifex/admin/email_test.go b/spinifex/admin/email_test.go new file mode 100644 index 000000000..f2b87dabf --- /dev/null +++ b/spinifex/admin/email_test.go @@ -0,0 +1,120 @@ +package admin + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidateEmail(t *testing.T) { + cases := []struct { + in string + wantErr bool + }{ + {"admin@example.com", false}, + {"foo.bar+tag@sub.example.co.uk", false}, + {" ok@example.io ", false}, // trimmed + {"", true}, + {" ", true}, + {"nodomain", true}, + {"missing@tld", true}, // no dot after @ + {"@example.com", true}, // empty local part + {"user@.com", true}, // dot immediately after @ + {"user@@example.com", true}, // double @ + {"user @example.com", true}, // whitespace in local + {"user@exa mple.com", true}, // whitespace in domain + } + for _, c := range cases { + err := ValidateEmail(c.in) + if c.wantErr && err == nil { + t.Errorf("ValidateEmail(%q) = nil, want error", c.in) + } + if !c.wantErr && err != nil { + t.Errorf("ValidateEmail(%q) = %v, want nil", c.in, err) + } + } +} + +func TestReadOperatorEmail(t *testing.T) { + dir := t.TempDir() + + write := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + return p + } + + t.Run("nonexistent file", func(t *testing.T) { + if got := ReadOperatorEmail(filepath.Join(dir, "missing.toml")); got != "" { + t.Errorf("want empty string for missing file, got %q", got) + } + }) + + t.Run("no operator section", func(t *testing.T) { + p := write("none.toml", `version = "2" +[network] +external_mode = "pool" +`) + if got := ReadOperatorEmail(p); got != "" { + t.Errorf("want empty string, got %q", got) + } + }) + + t.Run("operator section with email", func(t *testing.T) { + p := write("ok.toml", `version = "2" + +[operator] +email = "admin@example.com" + +[network] +external_mode = "pool" +`) + if got := ReadOperatorEmail(p); got != "admin@example.com" { + t.Errorf("want admin@example.com, got %q", got) + } + }) + + t.Run("email scalar in wrong section ignored", func(t *testing.T) { + // A stray "email = ..." outside [operator] must not be returned. + p := write("stray.toml", `version = "2" +email = "wrong@example.com" + +[some_other] +email = "alsowrong@example.com" +`) + if got := ReadOperatorEmail(p); got != "" { + t.Errorf("want empty (stray scalars ignored), got %q", got) + } + }) + + t.Run("operator section trailing", func(t *testing.T) { + // Section that appears last in the file. + p := write("last.toml", `version = "2" + +[network] +external_mode = "pool" + +[operator] +email = "late@example.com" +`) + if got := ReadOperatorEmail(p); got != "late@example.com" { + t.Errorf("want late@example.com, got %q", got) + } + }) + + t.Run("operator section first, other section after", func(t *testing.T) { + // Ensures the scanner correctly re-enters "not in operator" state on + // the next section header. + p := write("mixed.toml", `[operator] +email = "first@example.com" + +[nodes.node1] +region = "ap-southeast-2" +`) + if got := ReadOperatorEmail(p); got != "first@example.com" { + t.Errorf("want first@example.com, got %q", got) + } + }) +} diff --git a/spinifex/admin/network_detect.go b/spinifex/admin/network_detect.go index c0330c2b2..06de6753f 100644 --- a/spinifex/admin/network_detect.go +++ b/spinifex/admin/network_detect.go @@ -55,7 +55,7 @@ func DetectNetwork() (*DetectedNetwork, error) { } // Get all routes to find interfaces and their subnets - allRoutes, err := exec.Command("ip", "-4", "route", "show", "proto", "kernel", "scope", "link").Output() + allRoutes, err := exec.Command("ip", "-4", "route", "show", "scope", "link").Output() if err != nil { return nil, fmt.Errorf("failed to get routes: %w", err) } diff --git a/spinifex/admin/telemetry.go b/spinifex/admin/telemetry.go index 28116099f..befb6a455 100644 --- a/spinifex/admin/telemetry.go +++ b/spinifex/admin/telemetry.go @@ -30,7 +30,11 @@ type TelemetryPayload struct { Arch string `json:"arch"` OS string `json:"os"` ExternalMode string `json:"external_mode,omitempty"` - Timestamp string `json:"timestamp"` + // Email is the operator's address from `spx admin init --email`. Used + // by install.mulgadc.com to notify of updates/security advisories. + // Empty for headless installs that didn't supply SPINIFEX_EMAIL. + Email string `json:"email,omitempty"` + Timestamp string `json:"timestamp"` // URL overrides the telemetry endpoint (for testing only). Not serialized. URL string `json:"-"`