// Package installer provides an interactive TUI installer for Orama Network package installer import ( "encoding/json" "fmt" "net" "net/http" "os" "path/filepath" "regexp" "strings" "time" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/DeBrosOfficial/network/pkg/certutil" "github.com/DeBrosOfficial/network/pkg/tlsutil" ) // InstallerConfig holds the configuration gathered from the TUI type InstallerConfig struct { VpsIP string Domain string PeerDomain string // Domain of existing node to join JoinAddress string // Auto-populated: raft.{PeerDomain}:7001 Peers []string // Auto-populated: /dns4/{PeerDomain}/tcp/4001/p2p/{PeerID} ClusterSecret string Branch string IsFirstNode bool NoPull bool } // Step represents a step in the installation wizard type Step int const ( StepWelcome Step = iota StepNodeType StepVpsIP StepDomain StepPeerDomain // Domain of existing node to join (replaces StepJoinAddress) StepClusterSecret StepBranch StepNoPull StepConfirm StepInstalling StepDone ) // Model is the bubbletea model for the installer type Model struct { step Step config InstallerConfig textInput textinput.Model err error width int height int installing bool installOutput []string cursor int // For selection menus discovering bool // Whether domain discovery is in progress discoveryInfo string // Info message during discovery discoveredPeer string // Discovered peer ID from domain } // Styles var ( titleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#00D4AA")). MarginBottom(1) subtitleStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#888888")). MarginBottom(1) focusedStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#00D4AA")) blurredStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#666666")) cursorStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#00D4AA")) helpStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#626262")). MarginTop(1) errorStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF6B6B")). Bold(true) successStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#00D4AA")). Bold(true) boxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#00D4AA")). Padding(1, 2) ) // NewModel creates a new installer model func NewModel() Model { ti := textinput.New() ti.Focus() ti.CharLimit = 256 ti.Width = 50 return Model{ step: StepWelcome, textInput: ti, config: InstallerConfig{ Branch: "main", }, } } // Init initializes the model func (m Model) Init() tea.Cmd { return textinput.Blink } // Update handles messages func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil case installCompleteMsg: m.step = StepDone return m, nil case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": if m.step != StepInstalling { return m, tea.Quit } case "enter": return m.handleEnter() case "up", "k": if m.step == StepNodeType || m.step == StepBranch || m.step == StepNoPull { if m.cursor > 0 { m.cursor-- } } case "down", "j": if m.step == StepNodeType || m.step == StepBranch || m.step == StepNoPull { if m.cursor < 1 { m.cursor++ } } case "esc": if m.step > StepWelcome && m.step < StepInstalling { m.step-- m.err = nil m.setupStepInput() } } } // Update text input for input steps if m.step == StepVpsIP || m.step == StepDomain || m.step == StepPeerDomain || m.step == StepClusterSecret { var cmd tea.Cmd m.textInput, cmd = m.textInput.Update(msg) return m, cmd } return m, nil } func (m *Model) handleEnter() (tea.Model, tea.Cmd) { switch m.step { case StepWelcome: m.step = StepNodeType m.cursor = 0 case StepNodeType: m.config.IsFirstNode = m.cursor == 0 m.step = StepVpsIP m.setupStepInput() case StepVpsIP: ip := strings.TrimSpace(m.textInput.Value()) if err := validateIP(ip); err != nil { m.err = err return m, nil } m.config.VpsIP = ip m.err = nil m.step = StepDomain m.setupStepInput() case StepDomain: domain := strings.TrimSpace(m.textInput.Value()) if err := validateDomain(domain); err != nil { m.err = err return m, nil } m.config.Domain = domain m.err = nil // Auto-generate self-signed certificates for this domain m.discovering = true m.discoveryInfo = "Generating SSL certificates for " + domain + "..." if err := ensureCertificatesForDomain(domain); err != nil { m.discovering = false m.err = fmt.Errorf("failed to generate certificates: %w", err) return m, nil } m.discovering = false if m.config.IsFirstNode { m.step = StepBranch m.cursor = 0 } else { m.step = StepPeerDomain m.setupStepInput() } case StepPeerDomain: peerDomain := strings.TrimSpace(m.textInput.Value()) if err := validateDomain(peerDomain); err != nil { m.err = err return m, nil } // Discover peer info from domain (try HTTPS first, then HTTP) m.discovering = true m.discoveryInfo = "Discovering peer from " + peerDomain + "..." peerID, err := discoverPeerFromDomain(peerDomain) m.discovering = false if err != nil { m.err = fmt.Errorf("failed to discover peer: %w", err) return m, nil } // Store discovered info m.config.PeerDomain = peerDomain m.discoveredPeer = peerID // Auto-populate join address and bootstrap peers m.config.JoinAddress = fmt.Sprintf("raft.%s:7001", peerDomain) m.config.Peers = []string{ fmt.Sprintf("/dns4/%s/tcp/4001/p2p/%s", peerDomain, peerID), } m.err = nil m.step = StepClusterSecret m.setupStepInput() case StepClusterSecret: secret := strings.TrimSpace(m.textInput.Value()) if err := validateClusterSecret(secret); err != nil { m.err = err return m, nil } m.config.ClusterSecret = secret m.err = nil m.step = StepBranch m.cursor = 0 case StepBranch: if m.cursor == 0 { m.config.Branch = "main" } else { m.config.Branch = "nightly" } m.cursor = 0 // Reset cursor for next step m.step = StepNoPull case StepNoPull: if m.cursor == 0 { m.config.NoPull = false } else { m.config.NoPull = true } m.step = StepConfirm case StepConfirm: m.step = StepInstalling return m, m.startInstallation() case StepDone: return m, tea.Quit } return m, nil } func (m *Model) setupStepInput() { m.textInput.Reset() m.textInput.Focus() m.textInput.EchoMode = textinput.EchoNormal // Reset echo mode switch m.step { case StepVpsIP: m.textInput.Placeholder = "e.g., 203.0.113.1" // Try to auto-detect public IP if ip := detectPublicIP(); ip != "" { m.textInput.SetValue(ip) } case StepDomain: m.textInput.Placeholder = "e.g., node-1.orama.network" case StepPeerDomain: m.textInput.Placeholder = "e.g., node-123.orama.network" case StepClusterSecret: m.textInput.Placeholder = "64 hex characters" m.textInput.EchoMode = textinput.EchoPassword } } func (m Model) startInstallation() tea.Cmd { return func() tea.Msg { // This would trigger the actual installation // For now, we return the config for the CLI to handle return installCompleteMsg{config: m.config} } } type installCompleteMsg struct { config InstallerConfig } // View renders the UI func (m Model) View() string { var s strings.Builder // Header s.WriteString(renderHeader()) s.WriteString("\n\n") switch m.step { case StepWelcome: s.WriteString(m.viewWelcome()) case StepNodeType: s.WriteString(m.viewNodeType()) case StepVpsIP: s.WriteString(m.viewVpsIP()) case StepDomain: s.WriteString(m.viewDomain()) case StepPeerDomain: s.WriteString(m.viewPeerDomain()) case StepClusterSecret: s.WriteString(m.viewClusterSecret()) case StepBranch: s.WriteString(m.viewBranch()) case StepNoPull: s.WriteString(m.viewNoPull()) case StepConfirm: s.WriteString(m.viewConfirm()) case StepInstalling: s.WriteString(m.viewInstalling()) case StepDone: s.WriteString(m.viewDone()) } return s.String() } func renderHeader() string { logo := ` ___ ____ _ __ __ _ / _ \| _ \ / \ | \/ | / \ | | | | |_) | / _ \ | |\/| | / _ \ | |_| | _ < / ___ \| | | |/ ___ \ \___/|_| \_\/_/ \_\_| |_/_/ \_\ ` return titleStyle.Render(logo) + "\n" + subtitleStyle.Render("Network Installation Wizard") } func (m Model) viewWelcome() string { var s strings.Builder s.WriteString(boxStyle.Render( titleStyle.Render("Welcome to Orama Network!") + "\n\n" + "This wizard will guide you through setting up your node.\n\n" + "You'll need:\n" + " • A public IP address for your server\n" + " • A domain name (e.g., node-1.orama.network)\n" + " • For joining: cluster secret from existing node\n", )) s.WriteString("\n\n") s.WriteString(helpStyle.Render("Press Enter to continue • q to quit")) return s.String() } func (m Model) viewNodeType() string { var s strings.Builder s.WriteString(titleStyle.Render("Node Type") + "\n\n") s.WriteString("Is this the first node in a new cluster?\n\n") options := []string{"Yes, create new cluster", "No, join existing cluster"} for i, opt := range options { if i == m.cursor { s.WriteString(cursorStyle.Render("→ ") + focusedStyle.Render(opt) + "\n") } else { s.WriteString(" " + blurredStyle.Render(opt) + "\n") } } s.WriteString("\n") s.WriteString(helpStyle.Render("↑/↓ to select • Enter to confirm • Esc to go back")) return s.String() } func (m Model) viewVpsIP() string { var s strings.Builder s.WriteString(titleStyle.Render("Server IP Address") + "\n\n") s.WriteString("Enter your server's public IP address:\n\n") s.WriteString(m.textInput.View()) if m.err != nil { s.WriteString("\n\n" + errorStyle.Render("✗ " + m.err.Error())) } s.WriteString("\n\n") s.WriteString(helpStyle.Render("Enter to confirm • Esc to go back")) return s.String() } func (m Model) viewDomain() string { var s strings.Builder s.WriteString(titleStyle.Render("Domain Name") + "\n\n") s.WriteString("Enter the domain for this node:\n\n") s.WriteString(m.textInput.View()) if m.err != nil { s.WriteString("\n\n" + errorStyle.Render("✗ " + m.err.Error())) } s.WriteString("\n\n") s.WriteString(helpStyle.Render("Enter to confirm • Esc to go back")) return s.String() } func (m Model) viewPeerDomain() string { var s strings.Builder s.WriteString(titleStyle.Render("Existing Node Domain") + "\n\n") s.WriteString("Enter the domain of an existing node to join:\n") s.WriteString(subtitleStyle.Render("The installer will auto-discover peer info via HTTPS/HTTP") + "\n\n") s.WriteString(m.textInput.View()) if m.discovering { s.WriteString("\n\n" + subtitleStyle.Render("🔍 "+m.discoveryInfo)) } if m.discoveredPeer != "" && m.err == nil { s.WriteString("\n\n" + successStyle.Render("✓ Discovered peer: "+m.discoveredPeer[:12]+"...")) } if m.err != nil { s.WriteString("\n\n" + errorStyle.Render("✗ " + m.err.Error())) } s.WriteString("\n\n") s.WriteString(helpStyle.Render("Enter to discover & continue • Esc to go back")) return s.String() } func (m Model) viewClusterSecret() string { var s strings.Builder s.WriteString(titleStyle.Render("Cluster Secret") + "\n\n") s.WriteString("Enter the cluster secret from an existing node:\n") s.WriteString(subtitleStyle.Render("Get it with: cat ~/.orama/secrets/cluster-secret") + "\n\n") s.WriteString(m.textInput.View()) if m.err != nil { s.WriteString("\n\n" + errorStyle.Render("✗ " + m.err.Error())) } s.WriteString("\n\n") s.WriteString(helpStyle.Render("Enter to confirm • Esc to go back")) return s.String() } func (m Model) viewBranch() string { var s strings.Builder s.WriteString(titleStyle.Render("Release Channel") + "\n\n") s.WriteString("Select the release channel:\n\n") options := []string{"main (stable)", "nightly (latest features)"} for i, opt := range options { if i == m.cursor { s.WriteString(cursorStyle.Render("→ ") + focusedStyle.Render(opt) + "\n") } else { s.WriteString(" " + blurredStyle.Render(opt) + "\n") } } s.WriteString("\n") s.WriteString(helpStyle.Render("↑/↓ to select • Enter to confirm • Esc to go back")) return s.String() } func (m Model) viewNoPull() string { var s strings.Builder s.WriteString(titleStyle.Render("Git Repository") + "\n\n") s.WriteString("Pull latest changes from repository?\n\n") options := []string{"Pull latest (recommended)", "Skip git pull (use existing source)"} for i, opt := range options { if i == m.cursor { s.WriteString(cursorStyle.Render("→ ") + focusedStyle.Render(opt) + "\n") } else { s.WriteString(" " + blurredStyle.Render(opt) + "\n") } } s.WriteString("\n") s.WriteString(helpStyle.Render("↑/↓ to select • Enter to confirm • Esc to go back")) return s.String() } func (m Model) viewConfirm() string { var s strings.Builder s.WriteString(titleStyle.Render("Confirm Installation") + "\n\n") noPullStr := "Pull latest" if m.config.NoPull { noPullStr = "Skip git pull" } config := fmt.Sprintf( " VPS IP: %s\n"+ " Domain: %s\n"+ " Branch: %s\n"+ " Git Pull: %s\n"+ " Node Type: %s\n", m.config.VpsIP, m.config.Domain, m.config.Branch, noPullStr, map[bool]string{true: "First node (new cluster)", false: "Join existing cluster"}[m.config.IsFirstNode], ) if !m.config.IsFirstNode { config += fmt.Sprintf(" Peer Node: %s\n", m.config.PeerDomain) config += fmt.Sprintf(" Join Addr: %s\n", m.config.JoinAddress) if len(m.config.Peers) > 0 { config += fmt.Sprintf(" Bootstrap: %s...\n", m.config.Peers[0][:40]) } if len(m.config.ClusterSecret) >= 8 { config += fmt.Sprintf(" Secret: %s...\n", m.config.ClusterSecret[:8]) } } s.WriteString(boxStyle.Render(config)) s.WriteString("\n\n") s.WriteString(helpStyle.Render("Press Enter to install • Esc to go back")) return s.String() } func (m Model) viewInstalling() string { var s strings.Builder s.WriteString(titleStyle.Render("Installing...") + "\n\n") s.WriteString("Please wait while the node is being configured.\n\n") for _, line := range m.installOutput { s.WriteString(line + "\n") } return s.String() } func (m Model) viewDone() string { var s strings.Builder s.WriteString(successStyle.Render("✓ Installation Complete!") + "\n\n") s.WriteString("Your node is now running.\n\n") s.WriteString("Useful commands:\n") s.WriteString(" orama status - Check service status\n") s.WriteString(" orama logs node - View node logs\n") s.WriteString(" orama logs gateway - View gateway logs\n") s.WriteString("\n") s.WriteString(helpStyle.Render("Press Enter or q to exit")) return s.String() } // GetConfig returns the installer configuration after the TUI completes func (m Model) GetConfig() InstallerConfig { return m.config } // Validation helpers func validateIP(ip string) error { if ip == "" { return fmt.Errorf("IP address is required") } if net.ParseIP(ip) == nil { return fmt.Errorf("invalid IP address format") } return nil } func validateDomain(domain string) error { if domain == "" { return fmt.Errorf("domain is required") } // Basic domain validation domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$`) if !domainRegex.MatchString(domain) { return fmt.Errorf("invalid domain format") } return nil } // discoverPeerFromDomain queries an existing node to get its peer ID // Tries HTTPS first, then falls back to HTTP // Respects DEBROS_TRUSTED_TLS_DOMAINS and DEBROS_CA_CERT_PATH environment variables for certificate verification func discoverPeerFromDomain(domain string) (string, error) { // Use centralized TLS configuration that respects CA certificates and trusted domains client := tlsutil.NewHTTPClientForDomain(10*time.Second, domain) // Try HTTPS first url := fmt.Sprintf("https://%s/v1/network/status", domain) resp, err := client.Get(url) // If HTTPS fails, try HTTP if err != nil { // Finally try plain HTTP url = fmt.Sprintf("http://%s/v1/network/status", domain) resp, err = client.Get(url) if err != nil { return "", fmt.Errorf("could not connect to %s (tried HTTPS and HTTP): %w", domain, err) } } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("unexpected status from %s: %s", domain, resp.Status) } // Parse response var status struct { NodeID string `json:"node_id"` } if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { return "", fmt.Errorf("failed to parse response from %s: %w", domain, err) } if status.NodeID == "" { return "", fmt.Errorf("no node_id in response from %s", domain) } return status.NodeID, nil } func validateClusterSecret(secret string) error { if len(secret) != 64 { return fmt.Errorf("cluster secret must be 64 hex characters") } secretRegex := regexp.MustCompile(`^[a-fA-F0-9]{64}$`) if !secretRegex.MatchString(secret) { return fmt.Errorf("cluster secret must be valid hexadecimal") } return nil } // ensureCertificatesForDomain generates self-signed certificates for the domain func ensureCertificatesForDomain(domain string) error { // Get home directory home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get home directory: %w", err) } // Create cert directory certDir := filepath.Join(home, ".orama", "certs") if err := os.MkdirAll(certDir, 0700); err != nil { return fmt.Errorf("failed to create cert directory: %w", err) } // Create certificate manager cm := certutil.NewCertificateManager(certDir) // Ensure CA certificate exists caCertPEM, caKeyPEM, err := cm.EnsureCACertificate() if err != nil { return fmt.Errorf("failed to ensure CA certificate: %w", err) } // Ensure node certificate exists for the domain _, _, err = cm.EnsureNodeCertificate(domain, caCertPEM, caKeyPEM) if err != nil { return fmt.Errorf("failed to ensure node certificate: %w", err) } // Also create wildcard certificate if domain is not already wildcard if !strings.HasPrefix(domain, "*.") { wildcardDomain := "*." + domain _, _, err = cm.EnsureNodeCertificate(wildcardDomain, caCertPEM, caKeyPEM) if err != nil { return fmt.Errorf("failed to ensure wildcard certificate: %w", err) } } return nil } func detectPublicIP() string { // Try to detect public IP from common interfaces addrs, err := net.InterfaceAddrs() if err != nil { return "" } for _, addr := range addrs { if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { if ipnet.IP.To4() != nil && !ipnet.IP.IsPrivate() { return ipnet.IP.String() } } } return "" } // Run starts the TUI installer and returns the configuration func Run() (*InstallerConfig, error) { // Check if running as root if os.Geteuid() != 0 { return nil, fmt.Errorf("installer must be run as root (use sudo)") } model := NewModel() p := tea.NewProgram(&model, tea.WithAltScreen()) finalModel, err := p.Run() if err != nil { return nil, err } m := finalModel.(*Model) if m.step == StepInstalling || m.step == StepDone { config := m.GetConfig() return &config, nil } return nil, fmt.Errorf("installation cancelled") }