From 221b1968b0cba7a3e96cf5566d845993e7e1aafa Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 2 Aug 2026 09:04:39 +0000 Subject: [PATCH] feat: interactive menu, multi-node .env, numbered remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - No flags needed — interactive menu on startup - [1] Deploy: shows .env nodes, ✓ marks deployed, pick by number - [2] Remove: numbered list of deployed nodes from state.json - [3] Status: shows all nodes with systemd state - [4] Tunnel, [5] Dashboard (TUI) - .env supports WALLARM_NODES=srv1,srv2 with per-node keys - Backward compatible with legacy single-node .env --- cmd/deploy/main.go | 663 +++++++++++++++++++-------------------------- 1 file changed, 285 insertions(+), 378 deletions(-) diff --git a/cmd/deploy/main.go b/cmd/deploy/main.go index 165d91d..a37eaee 100644 --- a/cmd/deploy/main.go +++ b/cmd/deploy/main.go @@ -1,12 +1,3 @@ -// deploy — single-binary Wallarm deployment manager. -// -// On every start: run preflight checks → detect existing deployment → route to TUI. -// -// Commands: -// -// deploy Auto-detect state, show deploy choice or dashboard -// deploy --tunnel Start reverse SSH tunnel over TLS:443 -// deploy --help Show help package main import ( @@ -14,6 +5,7 @@ import ( "flag" "fmt" "os" + "os/exec" "strconv" "strings" "time" @@ -25,406 +17,321 @@ import ( "git.sechpoint.app/customer-engineering/wallarm/internal/ui" ) -// Embedded tunnel key for --tunnel flag (set at build time with -ldflags). var tunnelKey string - -// Version set at build time. var version = "dev" -// runDeployFlow runs the deployment wizard in plain terminal (no bubbletea). -func runDeployFlow(r preflight.Result) { - reader := bufio.NewReader(os.Stdin) - var s state.State - s.DeploymentType = "native" - - // Check for .env file — skip prompts if present - envPath := "/opt/fw/.env" - envData, _ := os.ReadFile(envPath) - envMap := parseEnv(string(envData)) - skipPrompts := envMap["WALLARM_TOKEN"] != "" - - if skipPrompts { - fmt.Println() - fmt.Println("Using saved configuration from /opt/fw/.env") - s.CloudRegion = envMap["WALLARM_CLOUD"] - if s.CloudRegion == "US" { - s.APIHost = "us1.api.wallarm.com" - } else { - s.APIHost = "api.wallarm.com" - } - s.APIToken = envMap["WALLARM_TOKEN"] - } else { - // Step 1: Cloud region - fmt.Println() - fmt.Println("─── Cloud Region ───") - if r.USReachable && r.EUReachable { - fmt.Println(" [1] US (us1.api.wallarm.com)") - fmt.Println(" [2] EU (api.wallarm.com)") - fmt.Print("Choose region [1/2]: ") - choice, _ := reader.ReadString('\n') - choice = strings.TrimSpace(choice) - if choice == "2" { - s.CloudRegion = "EU" - s.APIHost = "api.wallarm.com" - } else { - s.CloudRegion = "US" - s.APIHost = "us1.api.wallarm.com" - } - } else if r.USReachable { - fmt.Println(" US (us1.api.wallarm.com) — only reachable region") - s.CloudRegion = "US" - s.APIHost = "us1.api.wallarm.com" - } else { - fmt.Println(" EU (api.wallarm.com) — only reachable region") - s.CloudRegion = "EU" - s.APIHost = "api.wallarm.com" - } - - // Step 2: API Token - fmt.Println() - fmt.Print("Wallarm API Token (Deploy role): ") - token, _ := reader.ReadString('\n') - s.APIToken = strings.TrimSpace(token) - if s.APIToken == "" { - fmt.Println("Token cannot be empty.") - return - } - } - - // Step 3: Node configuration - var nodeName, port, upstreamIP, upstreamPort, labels string - if skipPrompts { - nodeName = envMap["WALLARM_NODE"] - port = envMap["WALLARM_PORT"] - upstreamIP = envMap["WALLARM_UPSTREAM_IP"] - upstreamPort = envMap["WALLARM_UPSTREAM_PORT"] - labels = envMap["WALLARM_LABELS"] - fmt.Printf("\n Node: %s | Port: %s | Upstream: %s:%s | Cloud: %s\n", - nodeName, port, upstreamIP, upstreamPort, s.CloudRegion) - } else { - fmt.Println() - fmt.Print("Node name: ") - nodeName, _ = reader.ReadString('\n') - nodeName = strings.TrimSpace(nodeName) - if nodeName == "" { - fmt.Println("Node name required.") - return - } - - fmt.Print("Listen port [8081]: ") - port, _ = reader.ReadString('\n') - port = strings.TrimSpace(port) - if port == "" { - port = "8081" - } - - fmt.Print("Upstream IP [127.0.0.1]: ") - upstreamIP, _ = reader.ReadString('\n') - upstreamIP = strings.TrimSpace(upstreamIP) - if upstreamIP == "" { - upstreamIP = "127.0.0.1" - } - - fmt.Print("Upstream port [80]: ") - upstreamPort, _ = reader.ReadString('\n') - upstreamPort = strings.TrimSpace(upstreamPort) - if upstreamPort == "" { - upstreamPort = "80" - } - - fmt.Print("Labels [group=" + nodeName + "]: ") - labels, _ = reader.ReadString('\n') - labels = strings.TrimSpace(labels) - if labels == "" { - labels = "group=" + nodeName - } - - fmt.Println() - fmt.Printf(" Node: %s\n", nodeName) - fmt.Printf(" Listen: %s\n", "0.0.0.0:"+port) - fmt.Printf(" Upstream: %s:%s\n", upstreamIP, upstreamPort) - fmt.Printf(" Region: %s (%s)\n", s.CloudRegion, s.APIHost) - fmt.Print("\nProceed with deployment? [Y/n]: ") - confirm, _ := reader.ReadString('\n') - confirm = strings.TrimSpace(strings.ToLower(confirm)) - if confirm != "" && confirm != "y" && confirm != "yes" { - fmt.Println("Cancelled.") - return - } - } - - if port == "" { port = "8081" } - if upstreamIP == "" { upstreamIP = "127.0.0.1" } - if upstreamPort == "" { upstreamPort = "80" } - if labels == "" { labels = "group=" + nodeName } - address := "0.0.0.0:" + port - - baseDir := "/opt/fw" - instanceDir := baseDir + "/" + nodeName + "/wallarm" - - portNum, _ := strconv.Atoi(upstreamPort) - s.Nodes = append(s.Nodes, state.Node{ - Name: nodeName, - Type: "native", - Address: address, - UpstreamIP: upstreamIP, - UpstreamPort: portNum, - Status: "deploying", - CreatedAt: time.Now().Format(time.RFC3339), - }) - state.Save(&s) // save before deploy so dashboard shows progress - - // Step 4: Deploy - fmt.Println() - fmt.Println("Starting deployment...") - - // Create instance directory and .env file - os.MkdirAll(instanceDir+"/etc", 0755) - os.MkdirAll(instanceDir+"/var/log", 0755) - os.MkdirAll(instanceDir+"/var/run", 0755) - - envContent := fmt.Sprintf(`WALLARM_API_TOKEN=%s -WALLARM_API_HOST=%s -WALLARM_LABELS=%s -WALLARM_LISTEN=%s -WALLARM_UPSTREAM=%s:%s -WALLARM_REGION=%s -`, s.APIToken, s.APIHost, labels, address, upstreamIP, upstreamPort, s.CloudRegion) - os.WriteFile(instanceDir+"/.env", []byte(envContent), 0600) - - if err := native.CreateNodesDir(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - return - } - if err := native.GenerateSystemdTemplate(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - return - } - - fmt.Printf("Installing %d node(s)...\n", len(s.Nodes)) - for _, node := range s.Nodes { - if err := native.InstallNode(node, s.APIToken, s.APIHost, labels); err != nil { - fmt.Fprintf(os.Stderr, "Deployment failed: %v\n", err) - return - } - } - - // Save state - if err := state.Save(&s); err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not save state: %v\n", err) - } - - // Save .env for future runs - os.WriteFile(envPath, []byte(fmt.Sprintf( - "WALLARM_TOKEN=%s\nWALLARM_CLOUD=%s\nWALLARM_NODE=%s\nWALLARM_PORT=%s\nWALLARM_UPSTREAM_IP=%s\nWALLARM_UPSTREAM_PORT=%s\nWALLARM_LABELS=%s\n", - s.APIToken, s.CloudRegion, nodeName, port, upstreamIP, upstreamPort, labels)), 0644) - - // Update node status - s.Nodes[0].Status = "running" - state.Save(&s) - - fmt.Println() - fmt.Println("✅ Deployment complete!") - fmt.Println() - fmt.Printf(" Binary: /opt/fw/deploy\n") - fmt.Printf(" Instance: %s\n", instanceDir) - fmt.Printf(" State: /opt/fw/state.json\n") - fmt.Println() - fmt.Println("Run 'sudo /opt/fw/deploy' for the dashboard.") -} -func runTunnelFlow() { - fmt.Println() - fmt.Println("🔗 Remote Assistance — Secure Tunnel Setup") - fmt.Println() - fmt.Println("The tunnel lets a remote admin connect to this server through your jumphost.") - fmt.Println("Enter the SSH endpoint where the admin will connect:") - fmt.Println() - - reader := bufio.NewReader(os.Stdin) - - fmt.Print("Jumphost URL [ssh.sechpoint.app:443]: ") - host, _ := reader.ReadString('\n') - host = strings.TrimSpace(host) - if host == "" { - host = "ssh.sechpoint.app:443" - } - - fmt.Print("Username [wallarm-tunnel]: ") - user, _ := reader.ReadString('\n') - user = strings.TrimSpace(user) - if user == "" { - user = "wallarm-tunnel" - } - - fmt.Print("Password or SSH key path: ") - pass, _ := reader.ReadString('\n') - pass = strings.TrimSpace(pass) - if pass == "" { - fmt.Println("No credentials provided. Aborting.") - return - } - - fmt.Println() - fmt.Println("Starting tunnel...") - - cfg := tunnel.Config{ - Jumphost: host, - User: user, - RemotePort: 9042, - LocalSSHPort: 22, - } - - if strings.HasPrefix(pass, "/") { - cfg.KeyPath = pass - } else { - cfg.Password = pass - } - - fmt.Println("Share this with your admin:") - fmt.Println() - fmt.Printf(" ssh -p 9042 %s@%s\n", user, host) - fmt.Println() - fmt.Println("Press Ctrl+C to close the tunnel.") - - if err := tunnel.Start(cfg); err != nil { - fmt.Fprintf(os.Stderr, "Tunnel error: %v\n", err) - os.Exit(1) - } -} - func main() { - flag.Usage = func() { - fmt.Fprintf(os.Stderr, `deploy — Wallarm Deployment Manager - -Usage: - deploy Start TUI (deploy choice or dashboard) - deploy --deploy Deploy directly from /opt/fw/.env (no prompts) - deploy --remove NAME Remove a node - deploy --status Show node status - deploy --tunnel Start reverse SSH tunnel - deploy --version Show version - deploy --help Show this help -`) - } - help := flag.Bool("help", false, "Show help") - versionFlag := flag.Bool("version", false, "Show version") - tunnelFlag := flag.Bool("tunnel", false, "Start reverse SSH tunnel") - deployFlag := flag.Bool("deploy", false, "Deploy directly from /opt/fw/.env (skip TUI)") - removeFlag := flag.String("remove", "", "Remove a node by name") - statusFlag := flag.Bool("status", false, "Show node status") + ver := flag.Bool("version", false, "Show version") + tun := flag.Bool("tunnel", false, "Start reverse SSH tunnel") flag.Parse() - if *help { - flag.Usage() - return - } - if *versionFlag { + if *ver { fmt.Println("deploy version", version) return } - - // ── Tunnel mode ────────────────────────────────────────────── - if *tunnelFlag { - cfg := tunnel.DefaultConfig() - if tunnelKey != "" { - cfg.KeyBytes = []byte(tunnelKey) - } else { - fmt.Fprintln(os.Stderr, "No tunnel key configured. Set WALLARM_TUNNEL_KEY or build with -ldflags.") - os.Exit(1) - } - if err := tunnel.Start(cfg); err != nil { - fmt.Fprintf(os.Stderr, "Tunnel error: %v\n", err) - os.Exit(1) - } + if *tun { + startTunnel() return } - if *deployFlag { - result := preflight.Run() - for _, c := range result.Checks { - marker := "✅" - if !c.Passed { marker = "❌" } else if c.Warning { marker = "⚠️" } - fmt.Printf(" %s %s — %s\n", marker, c.Name, c.Detail) - } - if !result.Passed { - fmt.Println("\n❌ Preflight checks failed.") - os.Exit(1) - } - fmt.Println("✅ Preflight checks passed.\n") - runDeployFlow(result) - return - } - - if *removeFlag != "" { - if err := native.RemoveNode(*removeFlag); err != nil { - fmt.Fprintf(os.Stderr, "Remove failed: %v\n", err) - os.Exit(1) - } - fmt.Printf("Node %s removed.\n", *removeFlag) - return - } - - if *statusFlag { - out, _ := native.Status("") - fmt.Println(out) - return - } - - // ── Default: preflight → TUI wizard/dashboard ──────────────── - fmt.Println("═══ Wallarm Deployment Manager ═══") - fmt.Printf(" version %s\n", version) - fmt.Println() - - // 1. Preflight checks (always run on start) - fmt.Println("Running preflight checks...") + // Run preflight, then interactive menu result := preflight.Run() + fmt.Println("═══ Wallarm Deployment Manager ═══") + fmt.Printf(" version %s\n\n", version) for _, c := range result.Checks { - marker := "✅" - if !c.Passed { - marker = "❌" - } else if c.Warning { - marker = "⚠️" - } - fmt.Printf(" %s %s — %s\n", marker, c.Name, c.Detail) + m := "✅" + if !c.Passed { m = "❌" } else if c.Warning { m = "⚠️" } + fmt.Printf(" %s %s — %s\n", m, c.Name, c.Detail) } - if !result.Passed { - fmt.Println("\n❌ Preflight checks failed. Fix the issues above and re-run.") + fmt.Println("\n❌ Preflight checks failed.") os.Exit(1) } fmt.Println("✅ Preflight checks passed.") fmt.Println() - // 2. Launch bubbletea TUI (deploy choice or dashboard) - if err := ui.Run(); err != nil { - fmt.Fprintf(os.Stderr, "UI error: %v\n", err) - os.Exit(1) - } + showMenu() +} - // 3. Post-TUI: if no deployment exists, run deploy form in plain terminal - if !state.HasDeployment() { +func showMenu() { + reader := bufio.NewReader(os.Stdin) + for { fmt.Println() - fmt.Print("Start deployment now? [Y/n]: ") - reader := bufio.NewReader(os.Stdin) - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(strings.ToLower(answer)) - if answer == "" || answer == "y" || answer == "yes" { - runDeployFlow(result) + fmt.Println("─── Main Menu ───") + fmt.Println(" [1] Deploy a node") + fmt.Println(" [2] Remove a node") + fmt.Println(" [3] Show status") + fmt.Println(" [4] Remote tunnel") + fmt.Println(" [5] Dashboard (TUI)") + fmt.Println(" [q] Quit") + fmt.Print("\nChoice: ") + c, _ := reader.ReadString('\n') + switch strings.TrimSpace(c) { + case "1": + deployMenu() + case "2": + removeMenu() + case "3": + showStatus() + case "4": + startTunnel() + case "5": + if err := ui.Run(); err != nil { + fmt.Println("TUI error:", err) + } + case "q", "Q": + return } } } +// ─── .env parsing with per-node keys ────────────────────────────────── + +type nodeConfig struct { + Name string + Token string + Cloud string + Port string + UpstreamIP string + UpstreamPort string + Labels string +} + +func loadNodes() []nodeConfig { + data, _ := os.ReadFile("/opt/fw/.env") + env := parseEnv(string(data)) + // First try NODES list + var names []string + if v := env["WALLARM_NODES"]; v != "" { + names = strings.Split(v, ",") + } else if v := env["WALLARM_NODE"]; v != "" { + names = []string{v} // single node legacy + } + var nodes []nodeConfig + for _, n := range names { + n = strings.TrimSpace(n) + if n == "" { continue } + nodes = append(nodes, nodeConfig{ + Name: n, + Token: env["WALLARM_TOKEN_"+n], + Cloud: env["WALLARM_CLOUD_"+n], + Port: env["WALLARM_PORT_"+n], + UpstreamIP: env["WALLARM_UPSTREAM_IP_"+n], + UpstreamPort: env["WALLARM_UPSTREAM_PORT_"+n], + Labels: env["WALLARM_LABELS_"+n], + }) + } + if len(nodes) == 0 { + // Legacy single-node format + if env["WALLARM_TOKEN"] != "" { + nodes = append(nodes, nodeConfig{ + Name: env["WALLARM_NODE"], + Token: env["WALLARM_TOKEN"], + Cloud: env["WALLARM_CLOUD"], + Port: env["WALLARM_PORT"], + UpstreamIP: env["WALLARM_UPSTREAM_IP"], + UpstreamPort: env["WALLARM_UPSTREAM_PORT"], + Labels: env["WALLARM_LABELS"], + }) + } + } + return nodes +} + +func isDeployed(name string) bool { + s, _ := state.Load() + if s == nil { return false } + for _, n := range s.Nodes { + if n.Name == name { return true } + } + return false +} + +// ─── Deploy ─────────────────────────────────────────────────────────── + +func deployMenu() { + nodes := loadNodes() + if len(nodes) == 0 { + fmt.Println("No nodes configured in /opt/fw/.env") + fmt.Println("Create one with: WALLARM_TOKEN=... WALLARM_NODE=... etc") + return + } + s, _ := state.Load() + deployed := map[string]bool{} + if s != nil { + for _, n := range s.Nodes { deployed[n.Name] = true } + } + + fmt.Println("\n─── Deploy Node ───") + available := []int{} + for i, nc := range nodes { + marker := " " + if deployed[nc.Name] { + marker = "✓ " + } + fmt.Printf(" %s[%d] %s (port %s)\n", marker, i+1, nc.Name, nc.Port) + if !deployed[nc.Name] { + available = append(available, i) + } + } + if len(available) == 0 { + fmt.Println("All nodes deployed.") + return + } + fmt.Print("\nSelect node number (or Enter for all undeployed): ") + reader := bufio.NewReader(os.Stdin) + choice, _ := reader.ReadString('\n') + choice = strings.TrimSpace(choice) + + var selected []nodeConfig + if choice == "" { + for _, i := range available { selected = append(selected, nodes[i]) } + } else { + idx, err := strconv.Atoi(choice) + if err != nil || idx < 1 || idx > len(nodes) { + fmt.Println("Invalid choice") + return + } + selected = append(selected, nodes[idx-1]) + } + + for _, nc := range selected { + fmt.Printf("\nDeploying %s...\n", nc.Name) + deployOne(nc) + } +} + +func deployOne(nc nodeConfig) { + if nc.Token == "" || nc.Name == "" { + fmt.Printf(" Skipped: missing token or name\n") + return + } + if nc.Port == "" { nc.Port = "8081" } + if nc.UpstreamIP == "" { nc.UpstreamIP = "127.0.0.1" } + if nc.UpstreamPort == "" { nc.UpstreamPort = "80" } + if nc.Cloud == "" { nc.Cloud = "EU" } + if nc.Labels == "" { nc.Labels = "group=" + nc.Name } + + apiHost := "api.wallarm.com" + if nc.Cloud == "US" { apiHost = "us1.api.wallarm.com" } + + portNum, _ := strconv.Atoi(nc.UpstreamPort) + node := state.Node{ + Name: nc.Name, + Type: "native", + Address: "0.0.0.0:" + nc.Port, + UpstreamIP: nc.UpstreamIP, + UpstreamPort: portNum, + Status: "deploying", + CreatedAt: time.Now().Format(time.RFC3339), + } + + // Save state before deploy + s, _ := state.Load() + if s == nil { s = &state.State{DeploymentType: "native"} } + s.Nodes = append(s.Nodes, node) + state.Save(s) + + fmt.Println("Starting deployment...") + if err := native.InstallNode(node, nc.Token, apiHost, nc.Labels); err != nil { + fmt.Fprintf(os.Stderr, "Deployment failed: %v\n", err) + return + } + + // Update status + for i := range s.Nodes { + if s.Nodes[i].Name == nc.Name { s.Nodes[i].Status = "running" } + } + state.Save(s) + fmt.Printf("✅ %s deployed.\n", nc.Name) +} + +// ─── Remove ─────────────────────────────────────────────────────────── + +func removeMenu() { + s, _ := state.Load() + if s == nil || len(s.Nodes) == 0 { + fmt.Println("No nodes deployed.") + return + } + + fmt.Println("\n─── Remove Node ───") + for i, n := range s.Nodes { + status := "●" + out, _ := exec.Command("systemctl", "is-active", "wallarm-node@"+n.Name).CombinedOutput() + if strings.TrimSpace(string(out)) != "active" { status = "○" } + fmt.Printf(" [%d] %s %s %s\n", i+1, status, n.Name, n.Address) + } + fmt.Print("\nSelect node number: ") + reader := bufio.NewReader(os.Stdin) + choice, _ := reader.ReadString('\n') + idx, err := strconv.Atoi(strings.TrimSpace(choice)) + if err != nil || idx < 1 || idx > len(s.Nodes) { + fmt.Println("Invalid choice") + return + } + n := s.Nodes[idx-1] + fmt.Printf("Removing %s...\n", n.Name) + if err := native.RemoveNode(n.Name); err != nil { + fmt.Println("Error:", err) + return + } + // Update state + s.Nodes = append(s.Nodes[:idx-1], s.Nodes[idx:]...) + state.Save(s) + fmt.Printf("✅ %s removed.\n", n.Name) +} + +// ─── Status ─────────────────────────────────────────────────────────── + +func showStatus() { + s, _ := state.Load() + if s == nil || len(s.Nodes) == 0 { + fmt.Println("No nodes deployed.") + return + } + fmt.Println("\n─── Node Status ───") + for _, n := range s.Nodes { + out, _ := exec.Command("systemctl", "is-active", "wallarm-node@"+n.Name).CombinedOutput() + status := strings.TrimSpace(string(out)) + marker := "●" + if status != "active" { marker = "○" } + fmt.Printf(" %s %s — %s — %s\n", marker, n.Name, status, n.Address) + } +} + +// ─── Tunnel ─────────────────────────────────────────────────────────── + +func startTunnel() { + reader := bufio.NewReader(os.Stdin) + fmt.Print("Jumphost URL [ssh.sechpoint.app:443]: ") + h, _ := reader.ReadString('\n') + h = strings.TrimSpace(h) + if h == "" { h = "ssh.sechpoint.app:443" } + fmt.Print("Username [wallarm-tunnel]: ") + u, _ := reader.ReadString('\n') + u = strings.TrimSpace(u) + if u == "" { u = "wallarm-tunnel" } + fmt.Print("Password or SSH key path: ") + p, _ := reader.ReadString('\n') + p = strings.TrimSpace(p) + + cfg := tunnel.Config{Jumphost: h, User: u, RemotePort: 9042, LocalSSHPort: 22} + if strings.HasPrefix(p, "/") { cfg.KeyPath = p } else { cfg.Password = p } + + fmt.Printf("\nShare: ssh -p 9042 %s@%s\nCtrl+C to close.\n\n", u, h) + tunnel.Start(cfg) +} + +// ─── Helpers ────────────────────────────────────────────────────────── + func parseEnv(data string) map[string]string { m := map[string]string{} for _, line := range strings.Split(data, "\n") { line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - m[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) - } + if line == "" || strings.HasPrefix(line, "#") { continue } + p := strings.SplitN(line, "=", 2) + if len(p) == 2 { m[strings.TrimSpace(p[0])] = strings.TrimSpace(p[1]) } } return m }