// Package ui provides the bubbletea terminal UI for wallarm: // - Wizard: guides new deployments (type → region → config → deploy) // - Dashboard: lists existing nodes with actions (add, config, remove, tunnel) package ui import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "git.sechpoint.app/customer-engineering/wallarm/internal/native" "git.sechpoint.app/customer-engineering/wallarm/internal/preflight" "git.sechpoint.app/customer-engineering/wallarm/internal/state" "git.sechpoint.app/customer-engineering/wallarm/internal/tunnel" ) // Styles var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).MarginBottom(1) goodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("227")) badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) activeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true) ) // Model is the top-level bubbletea model. type Model struct { state stateView width int height int status string // feedback message during deployment deploying bool // true while deployment is in progress tunnelUser string // tunnel credentials (collected in remote config) tunnelPass string tunnelHost string } type stateView int const ( viewPreflight stateView = iota viewRoute viewDeployChoice // new: local deploy or remote assist? viewRemoteConfig // new: prompt for tunnel credentials viewTunnelActive // new: tunnel is running, waiting for remote admin viewWizard viewDashboard viewDone viewError ) var preflightResult preflight.Result // deployCompleteMsg is sent when deployment finishes. type deployCompleteMsg struct { err error } // Run starts the bubbletea TUI. func Run(r preflight.Result) error { preflightResult = r m := Model{state: viewRoute} p := tea.NewProgram(m, tea.WithAltScreen()) if _, err := p.Run(); err != nil { return err } return nil } func (m Model) Init() tea.Cmd { return nil } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "1": if m.state == viewDeployChoice { m.state = viewWizard return m, nil } if m.state == viewWizard && !m.deploying { return m, m.startDeploy() } case "2": if m.state == viewDeployChoice { m.state = viewRemoteConfig return m, nil } case "s": if m.state == viewRemoteConfig && m.tunnelHost == "" { return m, m.collectTunnelCreds() } if m.state == viewRemoteConfig && m.tunnelHost != "" { return m, m.startTunnel() } case "enter": if m.state == viewDone || m.state == viewError { return m, tea.Quit } } case deployCompleteMsg: m.deploying = false if msg.err != nil { m.status = fmt.Sprintf("Deployment failed: %v", msg.err) m.state = viewError } else { m.status = "Deployment successful!" m.state = viewDone } return m, nil case tunnelCredsMsg: if msg.err != nil { m.status = fmt.Sprintf("Could not collect credentials: %v", msg.err) m.state = viewError } else { m.state = viewRemoteConfig // refresh to show collected creds } return m, nil case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height } switch m.state { case viewRoute: if state.HasDeployment() { m.state = viewDashboard } else { m.state = viewDeployChoice } } return m, nil } func (m Model) View() string { switch m.state { case viewRoute: return "" case viewDeployChoice: return deployChoiceView(m) case viewRemoteConfig: return remoteConfigView(m) case viewTunnelActive: return tunnelActiveView(m) case viewWizard: return wizardView(m) case viewDashboard: return dashboardView(m) case viewDone: return goodStyle.Render("✅ " + m.status) + "\n\n" + dimStyle.Render("Press Enter to exit.") case viewError: return badStyle.Render("❌ " + m.status) + "\n\n" + dimStyle.Render("Press Enter to exit.") } return "" } // collectTunnelCreds runs a huh form to collect tunnel endpoint details. func (m Model) collectTunnelCreds() tea.Cmd { return func() tea.Msg { var host, user, pass string err := huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Jumphost URL"). Placeholder("ssh.sechpoint.app:443"). Value(&host), huh.NewInput(). Title("Username"). Placeholder("wallarm-tunnel"). Value(&user), huh.NewInput(). Title("Password (or SSH key path)"). Placeholder("password or /path/to/key"). EchoMode(huh.EchoModePassword). Value(&pass), ), ).WithTheme(huh.ThemeCharm()).Run() m.tunnelHost = host m.tunnelUser = user m.tunnelPass = pass return tunnelCredsMsg{err: err} } } type tunnelCredsMsg struct{ err error } // startTunnel opens the reverse SSH tunnel with collected credentials. func (m Model) startTunnel() tea.Cmd { return func() tea.Msg { cfg := tunnel.Config{ Jumphost: m.tunnelHost, User: m.tunnelUser, Password: m.tunnelPass, RemotePort: 9042, LocalSSHPort: 22, } // If password looks like a file path, use key auth instead if strings.HasPrefix(m.tunnelPass, "/") { cfg.Password = "" cfg.KeyPath = m.tunnelPass } err := tunnel.Start(cfg) return deployCompleteMsg{err: err} } } func (m Model) startDeploy() tea.Cmd { return func() tea.Msg { m.deploying = true s, err := runDeployForm() if err != nil { return deployCompleteMsg{err} } // Deploy native node err = deployNative(s) if err == nil { state.Save(&s) } return deployCompleteMsg{err} } } // ─── Deploy Choice ─────────────────────────────────────────────────── func deployChoiceView(m Model) string { s := titleStyle.Render("🛡️ Wallarm Setup") + "\n\n" s += "How would you like to proceed?\n\n" s += activeStyle.Render(" [1] Deploy locally") + "\n" s += " Configure and deploy the Wallarm node on this server now.\n\n" s += activeStyle.Render(" [2] Remote assistance") + "\n" s += " Open a secure tunnel so a remote admin can deploy for you.\n" s += " No credentials are exposed — you provide the tunnel endpoint.\n\n" s += dimStyle.Render("Press 1 or 2, q to quit") return s } // ─── Remote Config ─────────────────────────────────────────────────── func remoteConfigView(m Model) string { s := titleStyle.Render("🔗 Remote Assistance Setup") + "\n\n" s += "Enter the tunnel endpoint where the remote admin will connect:\n\n" if m.tunnelHost == "" { s += dimStyle.Render(" (Form will appear below — enter values and press Enter to continue)") + "\n" s += dimStyle.Render(" Press 's' to start the tunnel after filling in credentials") + "\n" } else { s += fmt.Sprintf(" Jumphost: %s\n", goodStyle.Render(m.tunnelHost)) s += fmt.Sprintf(" User: %s\n", m.tunnelUser) s += dimStyle.Render("\n Tunnel credentials collected. Press 's' to start.") + "\n" } return s } // ─── Tunnel Active ─────────────────────────────────────────────────── func tunnelActiveView(m Model) string { s := titleStyle.Render("🔗 Tunnel Active") + "\n\n" s += goodStyle.Render("Secure tunnel is running.") + "\n\n" s += "Share this with your admin:\n" s += dimStyle.Render(" ┌─────────────────────────────────────────┐") + "\n" s += fmt.Sprintf(" │ %s", activeStyle.Render("ssh -p 9042 root@"+m.tunnelHost)) + dimStyle.Render(" │") + "\n" s += dimStyle.Render(" └─────────────────────────────────────────┘") + "\n\n" s += dimStyle.Render("The admin will see the deployment wizard on their terminal.") + "\n" s += dimStyle.Render("Tunnel stays open until you press q.") + "\n" return s } func wizardView(m Model) string { s := titleStyle.Render("🛡️ Wallarm Setup Wizard") + "\n" if m.deploying { s += dimStyle.Render("Configuring deployment — follow the prompts below...") + "\n" return s } s += dimStyle.Render("No existing deployment found. Let's set one up.") + "\n\n" s += activeStyle.Render("Wallarm Native Node Deployment") + "\n" s += " Press Enter to begin configuration" + "\n\n" s += activeStyle.Render("Step 2: Cloud region") + "\n" r := preflightResult if r.USReachable { s += goodStyle.Render(" US") + " — us1.api.wallarm.com (reachable)\n" } else { s += dimStyle.Render(" US — not reachable") + "\n" } if r.EUReachable { s += goodStyle.Render(" EU") + " — api.wallarm.com (reachable)\n" } else { s += dimStyle.Render(" EU — not reachable") + "\n" } s += dimStyle.Render("\nPress q to quit") return s } // runDeployForm collects configuration via huh interactive forms. func runDeployForm() (state.State, error) { var s state.State s.DeploymentType = "native" // Step 1: Cloud region regionOptions := []huh.Option[string]{} if preflightResult.USReachable { regionOptions = append(regionOptions, huh.NewOption("US (us1.api.wallarm.com)", "US")) } if preflightResult.EUReachable { regionOptions = append(regionOptions, huh.NewOption("EU (api.wallarm.com)", "EU")) } if len(regionOptions) == 0 { return s, fmt.Errorf("no cloud regions reachable") } var region string if len(regionOptions) == 1 { region = regionOptions[0].Value // auto-select } else { err := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("Choose Wallarm cloud region"). Options(regionOptions...). Value(®ion), ), ).WithTheme(huh.ThemeCharm()).Run() if err != nil { return s, err } } s.CloudRegion = region if region == "US" { s.APIHost = "us1.api.wallarm.com" } else { s.APIHost = "api.wallarm.com" } // Step 2: API Token var apiToken string err := huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Wallarm API Token (Deploy role)"). Placeholder("Paste your token here"). EchoMode(huh.EchoModePassword). Value(&apiToken). Validate(func(v string) error { if len(v) < 10 { return fmt.Errorf("token too short") } return nil }), ), ).WithTheme(huh.ThemeCharm()).Run() if err != nil { return s, err } s.APIToken = apiToken // Step 3: Node configuration var nodeName, address, labels string nodeForm := huh.NewForm( huh.NewGroup( huh.NewInput().Title("Node name").Value(&nodeName).Validate(func(v string) error { if v == "" { return fmt.Errorf("required") } return nil }), huh.NewInput().Title("Listen address (IP:Port)").Placeholder("0.0.0.0:8081").Value(&address).Validate(func(v string) error { if v == "" { return fmt.Errorf("required") } return nil }), huh.NewInput().Title("Labels (optional)").Placeholder("group=prod").Value(&labels), ), ) err = nodeForm.WithTheme(huh.ThemeCharm()).Run() if err != nil { return s, err } s.Nodes = append(s.Nodes, state.Node{ Name: nodeName, Type: "native", Address: address, Status: "deploying", CreatedAt: time.Now().Format(time.RFC3339), }) return s, nil } // deployNative executes the native deployment. func deployNative(s state.State) error { if err := native.CreateNodesDir(); err != nil { return err } if err := native.GenerateSystemdTemplate(); err != nil { return err } for _, node := range s.Nodes { labels := "group=" + node.Name if err := native.InstallNode(node, s.APIToken, s.APIHost, labels); err != nil { return err } } return nil } // ─── Dashboard ─────────────────────────────────────────────────────── func dashboardView(m Model) string { s, err := state.Load() if err != nil || s == nil { return badStyle.Render("Error loading state.") + "\nPress q to quit" } out := titleStyle.Render("📊 Wallarm Dashboard") + "\n" out += fmt.Sprintf("Type: %s | Cloud: %s (%s)\n", s.DeploymentType, s.CloudRegion, s.APIHost) out += dimStyle.Render(strings.Repeat("─", 50)) + "\n\n" if len(s.Nodes) == 0 { out += dimStyle.Render("No nodes deployed yet.") + "\n\n" } else { out += activeStyle.Render("Nodes:") + "\n" for _, n := range s.Nodes { marker := "●" style := goodStyle if n.Status != "running" { marker = "○" style = warnStyle } detail := n.Address if detail == "" && n.Port != 0 { detail = fmt.Sprintf(":%d → %s:%d", n.Port, n.UpstreamIP, n.UpstreamPort) } out += style.Render(fmt.Sprintf(" %s %s — %s", marker, n.Name, n.Status)) if detail != "" { out += dimStyle.Render(fmt.Sprintf(" (%s)", detail)) } out += "\n" } out += "\n" } out += "Actions:\n" out += fmt.Sprintf(" %s\n", activeStyle.Render("[a] Add node")) out += fmt.Sprintf(" %s\n", activeStyle.Render("[c] Configure")) out += fmt.Sprintf(" %s\n", activeStyle.Render("[r] Remove node")) out += fmt.Sprintf(" %s\n", activeStyle.Render("[t] Start tunnel")) out += fmt.Sprintf(" %s\n", dimStyle.Render("[q] Quit")) return out }