// Package installer provides an interactive TUI installer for Orama Network package installer import ( "fmt" "net" "os" "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/DeBrosOfficial/network/pkg/config" "github.com/DeBrosOfficial/network/pkg/config/validate" "github.com/DeBrosOfficial/network/pkg/installer/discovery" "github.com/DeBrosOfficial/network/pkg/installer/steps" "github.com/DeBrosOfficial/network/pkg/installer/validation" ) // renderHeader renders the application header func renderHeader() string { logo := ` ___ ____ _ __ __ _ / _ \| _ \ / \ | \/ | / \ | | | | |_) | / _ \ | |\/| | / _ \ | |_| | _ < / ___ \| | | |/ ___ \ \___/|_| \_\/_/ \_\_| |_/_/ \_\ ` return steps.TitleStyle.Render(logo) + "\n" + steps.SubtitleStyle.Render("Network Installation Wizard") } // 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 || m.step == StepSwarmKey { 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 := validation.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 := validation.ValidateDomain(domain); err != nil { m.err = err return m, nil } // Check SNI DNS records for this domain (non-blocking warning) m.discovering = true m.discoveryInfo = "Checking SNI DNS records for " + domain + "..." if warning := validation.ValidateSNIDNSRecords(domain); warning != "" { // Log warning but continue - SNI DNS is optional for single-node setups m.sniWarning = warning } m.discovering = false 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 := validation.ValidateDomain(peerDomain); err != nil { m.err = err return m, nil } // Check SNI DNS records for peer domain (non-blocking warning) m.discovering = true m.discoveryInfo = "Checking SNI DNS records for " + peerDomain + "..." if warning := validation.ValidateSNIDNSRecords(peerDomain); warning != "" { // Log warning but continue - peer might have different DNS setup m.sniWarning = warning } // Discover peer info from domain (try HTTPS first, then HTTP) m.discovering = true m.discoveryInfo = "Discovering peer from " + peerDomain + "..." disc, err := discovery.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 = disc.PeerID // Resolve peer domain to IP for direct RQLite TLS connection // RQLite uses native TLS on port 7002 (not SNI gateway on 7001) peerIPs, err := net.LookupIP(peerDomain) if err != nil || len(peerIPs) == 0 { m.err = fmt.Errorf("failed to resolve peer domain %s to IP: %w", peerDomain, err) return m, nil } // Prefer IPv4 var peerIP string for _, ip := range peerIPs { if ip.To4() != nil { peerIP = ip.String() break } } if peerIP == "" { peerIP = peerIPs[0].String() } m.config.PeerIP = peerIP // Auto-populate join address using port 7001 (standard RQLite Raft port) // config.go will adjust to 7002 if HTTPS/SNI is enabled m.config.JoinAddress = fmt.Sprintf("%s:7001", peerIP) m.config.Peers = []string{ fmt.Sprintf("/dns4/%s/tcp/4001/p2p/%s", peerDomain, disc.PeerID), } // Store IPFS peer info for Peering.Peers configuration if disc.IPFSPeerID != "" { m.config.IPFSPeerID = disc.IPFSPeerID m.config.IPFSSwarmAddrs = disc.IPFSSwarmAddrs } // Store IPFS Cluster peer info for cluster peer_addresses configuration if disc.IPFSClusterPeerID != "" { m.config.IPFSClusterPeerID = disc.IPFSClusterPeerID m.config.IPFSClusterAddrs = disc.IPFSClusterAddrs } m.err = nil m.step = StepClusterSecret m.setupStepInput() case StepClusterSecret: secret := strings.TrimSpace(m.textInput.Value()) if err := validation.ValidateClusterSecret(secret); err != nil { m.err = err return m, nil } m.config.ClusterSecret = secret m.err = nil m.step = StepSwarmKey m.setupStepInput() case StepSwarmKey: swarmKey := validate.ExtractSwarmKeyHex(m.textInput.Value()) if err := config.ValidateSwarmKey(swarmKey); err != nil { m.err = err return m, nil } m.config.SwarmKeyHex = swarmKey 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 := validation.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 case StepSwarmKey: 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} } } // 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: welcome := &steps.Welcome{} s.WriteString(welcome.View()) case StepNodeType: nodeType := &steps.NodeType{Cursor: m.cursor} s.WriteString(nodeType.View()) case StepVpsIP: vpsIP := &steps.VpsIP{Input: m.textInput, Error: m.err} s.WriteString(vpsIP.View()) case StepDomain: domain := &steps.Domain{Input: m.textInput, Error: m.err} s.WriteString(domain.View()) case StepPeerDomain: peerDomain := &steps.PeerDomain{ Input: m.textInput, Error: m.err, Discovering: m.discovering, DiscoveryInfo: m.discoveryInfo, DiscoveredPeer: m.discoveredPeer, } s.WriteString(peerDomain.View()) case StepClusterSecret: clusterSecret := &steps.ClusterSecret{Input: m.textInput, Error: m.err} s.WriteString(clusterSecret.View()) case StepSwarmKey: swarmKey := &steps.SwarmKey{Input: m.textInput, Error: m.err} s.WriteString(swarmKey.View()) case StepBranch: branch := &steps.Branch{Cursor: m.cursor} s.WriteString(branch.View()) case StepNoPull: noPull := &steps.NoPull{Cursor: m.cursor} s.WriteString(noPull.View()) case StepConfirm: confirm := &steps.Confirm{ VpsIP: m.config.VpsIP, Domain: m.config.Domain, Branch: m.config.Branch, NoPull: m.config.NoPull, IsFirstNode: m.config.IsFirstNode, PeerDomain: m.config.PeerDomain, JoinAddress: m.config.JoinAddress, Peers: m.config.Peers, ClusterSecret: m.config.ClusterSecret, SwarmKeyHex: m.config.SwarmKeyHex, IPFSPeerID: m.config.IPFSPeerID, SNIWarning: m.sniWarning, } s.WriteString(confirm.View()) case StepInstalling: installing := &steps.Installing{Output: m.installOutput} s.WriteString(installing.View()) case StepDone: done := &steps.Done{} s.WriteString(done.View()) } return s.String() } // 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") }