wallarm/cmd/deploy/main.go

418 lines
12 KiB
Go

package main
import (
"bufio"
"flag"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
"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"
"git.sechpoint.app/customer-engineering/wallarm/internal/ui"
)
var tunnelKey string
var version = "dev"
func main() {
ver := flag.Bool("version", false, "Show version")
tun := flag.Bool("tunnel", false, "Start reverse SSH tunnel")
flag.Parse()
if *ver {
fmt.Println("deploy version", version)
return
}
if *tun {
startTunnel()
return
}
// 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 {
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.")
os.Exit(1)
}
fmt.Println("✅ Preflight checks passed.")
fmt.Println()
showMenu()
}
func showMenu() {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Println()
fmt.Println("─── Main Menu ───")
fmt.Println(" [1] Deploy a node")
fmt.Println(" [2] Remove a node")
fmt.Println(" [3] Edit a node")
fmt.Println(" [4] Show status")
fmt.Println(" [5] Remote tunnel")
fmt.Println(" [6] 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":
editMenu()
case "4":
showStatus()
case "5":
startTunnel()
case "6":
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)
}
// ─── Edit ─────────────────────────────────────────────────────────────
func editMenu() {
s, _ := state.Load()
if s == nil || len(s.Nodes) == 0 {
fmt.Println("No nodes deployed.")
return
}
fmt.Println("\n─── Edit Node ───")
for i, n := range s.Nodes {
out, _ := exec.Command("systemctl", "is-active", "wallarm-node@"+n.Name).CombinedOutput()
status := "●"
if strings.TrimSpace(string(out)) != "active" { status = "○" }
fmt.Printf(" [%d] %s %s %s → %s:%d\n",
i+1, status, n.Name, n.Address, n.UpstreamIP, n.UpstreamPort)
}
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.Println("\nCurrent config:")
fmt.Printf(" Name: %s\n", n.Name)
fmt.Printf(" Listen: %s\n", n.Address)
fmt.Printf(" Upstream: %s:%d\n", n.UpstreamIP, n.UpstreamPort)
fmt.Println("\nEnter new values (leave blank to keep current):")
fmt.Print("Listen port [" + strings.TrimPrefix(n.Address, "0.0.0.0:") + "]: ")
port, _ := reader.ReadString('\n')
port = strings.TrimSpace(port)
fmt.Printf("Upstream IP [%s]: ", n.UpstreamIP)
ip, _ := reader.ReadString('\n')
ip = strings.TrimSpace(ip)
fmt.Printf("Upstream port [%d]: ", n.UpstreamPort)
portStr, _ := reader.ReadString('\n')
portStr = strings.TrimSpace(portStr)
if port == "" && ip == "" && portStr == "" {
fmt.Println("No changes.")
return
}
if port != "" { n.Address = "0.0.0.0:" + port }
if ip != "" { n.UpstreamIP = ip }
if portStr != "" {
p, _ := strconv.Atoi(portStr)
if p > 0 { n.UpstreamPort = p }
}
// Update nginx config
base := "/opt/fw/" + n.Name + "/wallarm"
nginxConf := base + "/nginx/conf/nginx.conf"
if _, err := os.Stat(nginxConf); err == nil {
data, _ := os.ReadFile(nginxConf)
newData := string(data)
if port != "" {
newData = strings.ReplaceAll(newData, n.Address, "0.0.0.0:"+port)
}
if ip != "" || portStr != "" {
oldUp := fmt.Sprintf("%s:%d", n.UpstreamIP, n.UpstreamPort)
newUp := fmt.Sprintf("%s:%d", n.UpstreamIP, n.UpstreamPort)
newData = strings.ReplaceAll(newData, oldUp, newUp)
}
os.WriteFile(nginxConf, []byte(newData), 0644)
}
state.Save(s)
fmt.Println("✅ Config updated. Restart the node to apply:")
fmt.Printf(" systemctl restart wallarm-node@%s\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 }
p := strings.SplitN(line, "=", 2)
if len(p) == 2 { m[strings.TrimSpace(p[0])] = strings.TrimSpace(p[1]) }
}
return m
}