network/pkg/installer/installer.go
2026-01-29 08:03:06 +02:00

398 lines
10 KiB
Go

// 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")
}