501 lines
14 KiB
Go
501 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"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] Edit a node")
|
|
fmt.Println(" [3] Show status")
|
|
fmt.Println(" [4] Remove a node")
|
|
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":
|
|
editMenu()
|
|
case "3":
|
|
showStatus()
|
|
case "4":
|
|
removeMenu()
|
|
case "5":
|
|
startTunnel()
|
|
case "6":
|
|
if err := ui.Run(); err != nil {
|
|
fmt.Println("TUI error:", err)
|
|
}
|
|
case "q", "Q":
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Config (fw.conf JSON) ──────────────────────────────────────────
|
|
|
|
type nodeConfig struct {
|
|
Name string
|
|
Token string
|
|
Cloud string
|
|
Port string
|
|
UpstreamIP string
|
|
UpstreamPort string
|
|
Labels string
|
|
Mode string
|
|
}
|
|
|
|
type fwConfig struct {
|
|
Nodes map[string]struct {
|
|
Token string `json:"token"`
|
|
Cloud string `json:"cloud"`
|
|
Port string `json:"port"`
|
|
UpstreamIP string `json:"upstream_ip"`
|
|
UpstreamPort string `json:"upstream_port"`
|
|
Labels string `json:"labels"`
|
|
Mode string `json:"mode"`
|
|
} `json:"nodes"`
|
|
}
|
|
|
|
func loadConfig() (fwConfig, error) {
|
|
var cfg fwConfig
|
|
data, err := os.ReadFile("/opt/fw/fw.conf")
|
|
if os.IsNotExist(err) {
|
|
// Fall back to legacy .env
|
|
return fwConfig{}, fmt.Errorf("no fw.conf")
|
|
}
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return cfg, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func loadNodes() []nodeConfig {
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
// Fall back to legacy .env
|
|
return loadNodesLegacy()
|
|
}
|
|
var nodes []nodeConfig
|
|
for name, n := range cfg.Nodes {
|
|
mode := n.Mode
|
|
if mode == "" { mode = "monitoring" }
|
|
nodes = append(nodes, nodeConfig{
|
|
Name: name,
|
|
Token: n.Token,
|
|
Cloud: n.Cloud,
|
|
Port: n.Port,
|
|
UpstreamIP: n.UpstreamIP,
|
|
UpstreamPort: n.UpstreamPort,
|
|
Labels: n.Labels,
|
|
Mode: mode,
|
|
})
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
// loadNodesLegacy reads the old WALLARM_TOKEN_srv1=... format
|
|
func loadNodesLegacy() []nodeConfig {
|
|
data, _ := os.ReadFile("/opt/fw/.env")
|
|
env := parseEnv(string(data))
|
|
var names []string
|
|
if v := env["WALLARM_NODES"]; v != "" {
|
|
names = strings.Split(v, ",")
|
|
} else if v := env["WALLARM_NODE"]; v != "" {
|
|
names = []string{v}
|
|
}
|
|
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],
|
|
Mode: env["WALLARM_MODE_"+n],
|
|
})
|
|
}
|
|
if len(nodes) == 0 && 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"],
|
|
Mode: env["WALLARM_MODE"],
|
|
})
|
|
}
|
|
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 }
|
|
|
|
// Ask for traffic mode
|
|
reader := bufio.NewReader(os.Stdin)
|
|
mode := nc.Mode
|
|
if mode == "" { mode = "monitoring" }
|
|
def := "1"
|
|
switch mode {
|
|
case "safe_blocking": def = "2"
|
|
case "block": def = "3"
|
|
case "off": def = "4"
|
|
}
|
|
fmt.Println("\nTraffic processing mode:")
|
|
fmt.Println(" [1] monitoring — detect attacks, do not block")
|
|
fmt.Println(" [2] safe_blocking — block only definitely malicious requests")
|
|
fmt.Println(" [3] block — block all detected attacks")
|
|
fmt.Println(" [4] off — disable traffic analysis")
|
|
fmt.Printf("Choose mode [%s]: ", def)
|
|
ans, _ := reader.ReadString('\n')
|
|
ans = strings.TrimSpace(ans)
|
|
if ans == "" { ans = def }
|
|
switch ans {
|
|
case "2":
|
|
mode = "safe_blocking"
|
|
case "3":
|
|
mode = "block"
|
|
case "4":
|
|
mode = "off"
|
|
default:
|
|
mode = "monitoring"
|
|
}
|
|
|
|
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, mode); 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
|
|
}
|