feat: Go binary — preflight, state, tunnel over TLS:443

Single-binary wallarm deployment manager:
- shared/ — system detection, validation, connectivity (port of wallarm-lib.sh)
- preflight/ — mandatory checks on every start (OS, arch, disk, memory, cloud)
- state/ — ~/.wallarm/state.json persistence (nodes, deployment type)
- tunnel/ — reverse SSH tunnel over TLS:443 via Zoraxy edge proxy
- cmd/wallarm/main.go — auto-detect state, route to wizard or dashboard

wallarm-docker.sh wrapper delegates to existing ct-* scripts for now.
This commit is contained in:
admin 2026-08-01 09:25:22 +00:00
parent 62ddaaa9c5
commit 1a2a0fbd7b
11 changed files with 2469 additions and 1631 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ Thumbs.db
# Local notes (excluded from sync) # Local notes (excluded from sync)
notes/ notes/
wallarm

161
cmd/wallarm/main.go Normal file
View file

@ -0,0 +1,161 @@
// wallarm — single-binary Wallarm deployment manager.
//
// On every start: run preflight checks → detect existing deployment → route to wizard or dashboard.
//
// Commands:
//
// wallarm Auto-detect state, show wizard or dashboard
// wallarm --tunnel Start reverse SSH tunnel over TLS:443
// wallarm --help Show help
package main
import (
"flag"
"fmt"
"os"
"git.sechpoint.app/customer-engineering/wallarm/internal/preflight"
"git.sechpoint.app/customer-engineering/wallarm/internal/state"
"git.sechpoint.app/customer-engineering/wallarm/internal/tunnel"
)
// Embedded tunnel key — set at build time with:
//
// go build -ldflags "-X main.tunnelKey=$(cat ~/.wallarm/tunnel_key)"
var tunnelKey string
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, `wallarm Wallarm Deployment Manager
Usage:
wallarm Start interactive deployment wizard/dashboard
wallarm --tunnel Start reverse SSH tunnel to sechpoint.app
wallarm --help Show this help
On first run, wallarm checks system readiness, then guides you through
deployment. On subsequent runs, it shows your existing deployments.
`)
}
help := flag.Bool("help", false, "Show help")
tunnelFlag := flag.Bool("tunnel", false, "Start reverse SSH tunnel")
flag.Parse()
if *help {
flag.Usage()
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)
}
return
}
// ── Default: preflight → detect state → route ────────────────
fmt.Println("═══ Wallarm Deployment Manager ═══")
fmt.Println()
// 1. Preflight checks (always run on start)
fmt.Println("Running preflight checks...")
result := preflight.Run()
printPreflight(result)
if !result.Passed {
fmt.Println("\n❌ Preflight checks failed. Fix the issues above and re-run.")
os.Exit(1)
}
fmt.Println("✅ Preflight checks passed.")
fmt.Println()
// 2. Detect existing deployment
if state.HasDeployment() {
showDashboard()
} else {
showWizard(result)
}
}
func printPreflight(r preflight.Result) {
for _, c := range r.Checks {
marker := "✅"
desc := ""
if !c.Passed {
marker = "❌"
desc = " — " + c.Detail
} else if c.Warning {
marker = "⚠️"
desc = " — " + c.Detail
} else {
desc = " — " + c.Detail
}
fmt.Printf(" %s %s%s\n", marker, c.Name, desc)
}
}
func showDashboard() {
fmt.Println("📊 Existing deployment detected.")
fmt.Println()
s, err := state.Load()
if err != nil || s == nil {
fmt.Println("Could not load state. Starting fresh.")
showWizard(preflight.Result{Passed: true})
return
}
fmt.Printf("Deployment type: %s\n", s.DeploymentType)
fmt.Printf("Cloud region: %s (%s)\n", s.CloudRegion, s.APIHost)
fmt.Println()
fmt.Println("Nodes:")
for i, n := range s.Nodes {
status := "●"
if n.Status != "running" {
status = "○"
}
fmt.Printf(" %s %d. %s — %s", status, i+1, n.Name, n.Status)
if n.Address != "" {
fmt.Printf(" (%s)", n.Address)
}
if n.Port != 0 {
fmt.Printf(" :%d", n.Port)
}
fmt.Println()
}
fmt.Println()
fmt.Println("[a]dd node [c]onfigure [r]emove node [t]unnel [q]uit")
fmt.Println()
fmt.Println("Dashboard actions coming soon — use the existing bash scripts for configuration.")
}
func showWizard(r preflight.Result) {
fmt.Println("No existing deployment found. Starting setup wizard...")
fmt.Println()
fmt.Println("Choose deployment type:")
fmt.Println(" 1) Docker — Wallarm node as a container")
fmt.Println(" 2) Native — Wallarm node directly on this OS (no Docker)")
fmt.Println()
fmt.Println("Wizard UI coming soon — use the bash scripts in deploy/ for now.")
fmt.Println()
if r.USReachable || r.EUReachable {
fmt.Print("Cloud reachable: ")
if r.USReachable {
fmt.Print("US ✓ ")
}
if r.EUReachable {
fmt.Print("EU ✓")
}
fmt.Println()
}
}

1653
docker/wallarm-ct-deploy.sh Executable file

File diff suppressed because it is too large Load diff

1698
docker/wallarm-docker.sh Executable file → Normal file

File diff suppressed because it is too large Load diff

7
go.mod Normal file
View file

@ -0,0 +1,7 @@
module git.sechpoint.app/customer-engineering/wallarm
go 1.24
require golang.org/x/crypto v0.36.0
require golang.org/x/sys v0.31.0 // indirect

6
go.sum Normal file
View file

@ -0,0 +1,6 @@
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=

View file

@ -0,0 +1,169 @@
// Package preflight runs system readiness checks before any deployment.
// It is called on every binary start and returns a structured report.
package preflight
import (
"fmt"
"os"
"git.sechpoint.app/customer-engineering/wallarm/internal/shared"
)
// Result holds the outcome of all preflight checks.
type Result struct {
Passed bool `json:"passed"`
Checks []Check `json:"checks"`
USReachable bool `json:"us_reachable"`
EUReachable bool `json:"eu_reachable"`
}
// Check represents a single preflight check.
type Check struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Detail string `json:"detail,omitempty"`
Warning bool `json:"warning,omitempty"`
}
// Cloud endpoints (from wallarm-lib.sh)
var euEndpoints = []string{
"api.wallarm.com:443",
"node-data0.eu1.wallarm.com:443",
"node-data1.eu1.wallarm.com:443",
}
var usEndpoints = []string{
"us1.api.wallarm.com:443",
"node-data0.us1.wallarm.com:443",
"node-data1.us1.wallarm.com:443",
}
// Run executes all preflight checks and returns the result.
func Run() Result {
r := Result{Passed: true}
// 1. Root check
if os.Geteuid() != 0 {
r.Checks = append(r.Checks, Check{
Name: "root", Passed: false, Detail: "must run as root for package installation and system config",
})
r.Passed = false
} else {
r.Checks = append(r.Checks, Check{Name: "root", Passed: true})
}
// 2. Init system
initSys := shared.InitSystem()
if initSys != "systemd" {
r.Checks = append(r.Checks, Check{
Name: "init", Passed: false, Detail: fmt.Sprintf("requires systemd, detected: %s", initSys),
})
r.Passed = false
} else {
r.Checks = append(r.Checks, Check{Name: "init", Passed: true, Detail: "systemd"})
}
// 3. Architecture
arch := shared.Arch()
supported := arch == "x86_64" || arch == "aarch64"
r.Checks = append(r.Checks, Check{
Name: "arch", Passed: supported, Detail: arch,
})
if !supported {
r.Passed = false
}
// 4. OS
id, ver := shared.OSInfo()
r.Checks = append(r.Checks, Check{Name: "os", Passed: true, Detail: id + " " + ver})
// 5. Required commands
requiredCmds := []string{"curl", "systemctl", "sed", "mkdir", "rm"}
for _, cmd := range requiredCmds {
ok := shared.CommandExists(cmd)
r.Checks = append(r.Checks, Check{Name: "cmd:" + cmd, Passed: ok})
if !ok {
r.Passed = false
}
}
// 6. Installer reachability
installerOk := shared.HTTPHead("https://repo.wallarm.com")
r.Checks = append(r.Checks, Check{
Name: "installer_reachable", Passed: installerOk,
Detail: "repo.wallarm.com",
})
if !installerOk {
r.Checks[len(r.Checks)-1].Warning = true
}
// 7. Cloud endpoints
r.USReachable = checkEndpoints(usEndpoints)
r.EUReachable = checkEndpoints(euEndpoints)
r.Checks = append(r.Checks, Check{
Name: "cloud:US", Passed: r.USReachable,
Detail: fmt.Sprintf("%d/%d reachable", countReachable(usEndpoints), len(usEndpoints)),
})
r.Checks = append(r.Checks, Check{
Name: "cloud:EU", Passed: r.EUReachable,
Detail: fmt.Sprintf("%d/%d reachable", countReachable(euEndpoints), len(euEndpoints)),
})
if !r.USReachable && !r.EUReachable {
r.Passed = false
}
// 8. Disk space (>= 2GB)
free, err := shared.FreeDiskMB("/opt")
if err == nil && free < 2048 {
r.Checks = append(r.Checks, Check{
Name: "disk", Passed: false,
Detail: fmt.Sprintf("%d MB free (need >= 2048 MB)", free),
})
r.Passed = false
} else {
r.Checks = append(r.Checks, Check{Name: "disk", Passed: true, Detail: fmt.Sprintf("%d MB free", free)})
}
// 9. Memory (>= 2GB, warning only)
mem, err := shared.FreeMemoryMB()
if err == nil && mem < 2048 {
r.Checks = append(r.Checks, Check{
Name: "memory", Passed: true, Warning: true,
Detail: fmt.Sprintf("%d MB (2GB+ recommended)", mem),
})
} else {
r.Checks = append(r.Checks, Check{Name: "memory", Passed: true, Detail: fmt.Sprintf("%d MB", mem)})
}
return r
}
func checkEndpoints(endpoints []string) bool {
for _, ep := range endpoints {
host, _ := splitHostPort(ep)
if shared.TCPConnect(host, 443) {
return true
}
}
return false
}
func countReachable(endpoints []string) int {
n := 0
for _, ep := range endpoints {
host, _ := splitHostPort(ep)
if shared.TCPConnect(host, 443) {
n++
}
}
return n
}
func splitHostPort(addr string) (string, string) {
for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' {
return addr[:i], addr[i+1:]
}
}
return addr, ""
}

174
internal/shared/shared.go Normal file
View file

@ -0,0 +1,174 @@
// Package shared provides validation, connectivity, and system detection
// utilities ported from the bash wallarm-lib.sh library.
package shared
import (
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
)
// ─── System Detection ────────────────────────────────────────────────
// InitSystem returns the detected init system: systemd, openrc, sysvinit, upstart, or unknown.
func InitSystem() string {
if runtime.GOOS == "darwin" {
return "darwin"
}
if _, err := exec.LookPath("systemctl"); err == nil {
return "systemd"
}
if _, err := os.Stat("/sbin/openrc-run"); err == nil {
return "openrc"
}
if _, err := os.Stat("/etc/init.d"); err == nil {
return "sysvinit"
}
if _, err := os.Stat("/sbin/upstart"); err == nil {
return "upstart"
}
return "unknown"
}
// OSInfo returns (os_id, version_id) from /etc/os-release.
func OSInfo() (id, version string) {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return strings.ToLower(runtime.GOOS), "unknown"
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "ID=") {
id = strings.Trim(strings.TrimPrefix(line, "ID="), `"`)
}
if strings.HasPrefix(line, "VERSION_ID=") {
version = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), `"`)
}
}
if id == "" {
id = strings.ToLower(runtime.GOOS)
}
return id, version
}
// Arch returns the normalized architecture: x86_64, aarch64, or armhf.
func Arch() string {
switch runtime.GOARCH {
case "amd64":
return "x86_64"
case "arm64":
return "aarch64"
case "arm":
return "armhf"
default:
return runtime.GOARCH
}
}
// ─── Validation ──────────────────────────────────────────────────────
// ValidateIP checks whether s is a valid IPv4 address.
func ValidateIP(s string) error {
parts := strings.Split(s, ".")
if len(parts) != 4 {
return fmt.Errorf("invalid IPv4: %s", s)
}
for _, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 || n > 255 {
return fmt.Errorf("invalid IPv4 octet: %s", p)
}
}
return nil
}
// ValidateCIDR checks whether s is a valid IPv4 address with optional /prefix.
func ValidateCIDR(s string) error {
ip := s
prefix := ""
if idx := strings.IndexByte(s, '/'); idx != -1 {
ip, prefix = s[:idx], s[idx+1:]
}
if err := ValidateIP(ip); err != nil {
return err
}
if prefix != "" {
n, err := strconv.Atoi(prefix)
if err != nil || n < 0 || n > 32 {
return fmt.Errorf("invalid CIDR prefix: %s", prefix)
}
}
return nil
}
// CommandExists returns true if cmd is in PATH or in common system directories.
func CommandExists(cmd string) bool {
if _, err := exec.LookPath(cmd); err == nil {
return true
}
for _, dir := range []string{"/usr/sbin", "/sbin", "/usr/local/sbin", "/usr/bin", "/bin", "/usr/local/bin"} {
if _, err := os.Stat(dir + "/" + cmd); err == nil {
return true
}
}
return false
}
// ─── Resource Checks ─────────────────────────────────────────────────
// FreeDiskMB returns available disk space in MB for the given path.
func FreeDiskMB(path string) (int64, error) {
cmd := exec.Command("df", "-k", path)
out, err := cmd.Output()
if err != nil {
return 0, err
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0, fmt.Errorf("unexpected df output")
}
fields := strings.Fields(lines[1])
if len(fields) < 4 {
return 0, fmt.Errorf("unexpected df fields")
}
kb, err := strconv.ParseInt(fields[3], 10, 64)
if err != nil {
return 0, err
}
return kb / 1024, nil // convert KB to MB
}
// FreeMemoryMB returns available memory in MB.
func FreeMemoryMB() (int64, error) {
cmd := exec.Command("free", "-m")
out, err := cmd.Output()
if err != nil {
return 0, err
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0, fmt.Errorf("unexpected free output")
}
fields := strings.Fields(lines[1])
if len(fields) < 2 {
return 0, fmt.Errorf("unexpected free fields")
}
return strconv.ParseInt(fields[1], 10, 64)
}
// ─── Connectivity ────────────────────────────────────────────────────
// TCPConnect tests whether host:port accepts a TCP connection.
func TCPConnect(host string, port int) bool {
cmd := exec.Command("timeout", "5", "bash", "-c",
fmt.Sprintf("echo >/dev/tcp/%s/%d 2>/dev/null", host, port))
return cmd.Run() == nil
}
// HTTPHead returns true if the URL returns a successful status.
func HTTPHead(url string) bool {
cmd := exec.Command("curl", "-fsSL", "--connect-timeout", "10", url)
return cmd.Run() == nil
}

83
internal/state/state.go Normal file
View file

@ -0,0 +1,83 @@
// Package state manages the persistent deployment state file (~/.wallarm/state.json).
package state
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// StateDir is where wallarm stores its state.
const StateDir = ".wallarm"
// State represents the persistent deployment state.
type State struct {
DeploymentType string `json:"deployment_type,omitempty"` // docker or native
CloudRegion string `json:"cloud_region,omitempty"` // US or EU
APIHost string `json:"api_host,omitempty"` // e.g., api.wallarm.com
Nodes []Node `json:"nodes"`
}
// Node represents a single deployed Wallarm node (docker container or native systemd unit).
type Node struct {
Name string `json:"name"`
Type string `json:"type"` // docker or native
Address string `json:"address,omitempty"` // listen address for native
Port int `json:"port,omitempty"` // ingress port for docker
UpstreamIP string `json:"upstream_ip,omitempty"` // docker
UpstreamPort int `json:"upstream_port,omitempty"` // docker
Status string `json:"status"` // running, stopped, unknown
CreatedAt string `json:"created_at"`
}
// Path returns the full path to the state file.
func Path() string {
home, err := os.UserHomeDir()
if err != nil {
home = "/root"
}
return filepath.Join(home, StateDir, "state.json")
}
// Load reads and parses the state file. Returns nil if the file doesn't exist.
func Load() (*State, error) {
p := Path()
data, err := os.ReadFile(p)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read state: %w", err)
}
var s State
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parse state: %w", err)
}
return &s, nil
}
// Save writes the state to disk, creating directories as needed.
func Save(s *State) error {
p := Path()
if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
return fmt.Errorf("create state dir: %w", err)
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("marshal state: %w", err)
}
if err := os.WriteFile(p, data, 0600); err != nil {
return fmt.Errorf("write state: %w", err)
}
return nil
}
// HasDeployment returns true if a state file exists with at least one node.
func HasDeployment() bool {
s, err := Load()
if err != nil || s == nil {
return false
}
return len(s.Nodes) > 0
}

146
internal/tunnel/tunnel.go Normal file
View file

@ -0,0 +1,146 @@
// Package tunnel provides a reverse SSH tunnel over TLS:443 via a Zoraxy edge proxy.
// It opens an outbound TLS connection to the configured jumphost, authenticates
// via SSH, and establishes a reverse port forward so you can reach the target VM
// through sechpoint.app.
package tunnel
import (
"crypto/tls"
"fmt"
"io"
"net"
"os"
"os/signal"
"time"
"golang.org/x/crypto/ssh"
)
// Config holds the tunnel connection parameters.
type Config struct {
Jumphost string // e.g., "ssh.sechpoint.app:443"
RemotePort int // Port on the jumphost that forwards to target's SSH
LocalSSHPort int // SSH port on the target VM (usually 22)
User string // SSH user on the jumphost
KeyPath string // Path to private key for authentication
KeyBytes []byte // Raw private key bytes (takes precedence over KeyPath)
}
// DefaultConfig returns a Config with sensible defaults.
func DefaultConfig() Config {
return Config{
Jumphost: "ssh.sechpoint.app:443",
RemotePort: 9042,
LocalSSHPort: 22,
User: "wallarm-tunnel",
}
}
// Start opens a reverse SSH tunnel over TLS and keeps it alive.
// It blocks until SIGINT or connection failure.
func Start(cfg Config) error {
// Load the private key
var signer ssh.Signer
if len(cfg.KeyBytes) > 0 {
var err error
signer, err = ssh.ParsePrivateKey(cfg.KeyBytes)
if err != nil {
return fmt.Errorf("parse embedded key: %w", err)
}
} else {
keyBytes, err := os.ReadFile(cfg.KeyPath)
if err != nil {
return fmt.Errorf("read key %s: %w", cfg.KeyPath, err)
}
signer, err = ssh.ParsePrivateKey(keyBytes)
if err != nil {
return fmt.Errorf("parse key: %w", err)
}
}
sshConfig := &ssh.ClientConfig{
User: cfg.User,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return nil // Accept all host keys (trusted infrastructure)
},
Timeout: 10 * time.Second,
}
// TLS dial to the Zoraxy edge (port 443)
tlsConn, err := tls.Dial("tcp", cfg.Jumphost, &tls.Config{
InsecureSkipVerify: false,
})
if err != nil {
return fmt.Errorf("TLS dial %s: %w", cfg.Jumphost, err)
}
// SSH over TLS
sshConn, chans, reqs, err := ssh.NewClientConn(tlsConn, cfg.Jumphost, sshConfig)
if err != nil {
tlsConn.Close()
return fmt.Errorf("SSH handshake: %w", err)
}
client := ssh.NewClient(sshConn, chans, reqs)
defer client.Close()
// Request reverse port forward: jumphost:RemotePort -> localhost:LocalSSHPort
remoteAddr := fmt.Sprintf("0.0.0.0:%d", cfg.RemotePort)
localAddr := fmt.Sprintf("localhost:%d", cfg.LocalSSHPort)
listener, err := client.Listen("tcp", remoteAddr)
if err != nil {
return fmt.Errorf("remote listen %s: %w", remoteAddr, err)
}
defer listener.Close()
fmt.Printf("Tunnel established: %s -> %s\n", remoteAddr, localAddr)
fmt.Printf("Connect: ssh -p %d root@%s\n", cfg.RemotePort, cfg.Jumphost)
// Handle incoming connections on the remote listener
go func() {
for {
remoteConn, err := listener.Accept()
if err != nil {
return
}
go forwardConnection(remoteConn, localAddr)
}
}()
// Keep alive until signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
// Heartbeat every 30s
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-sigCh:
fmt.Println("\nTunnel closed.")
return nil
case <-ticker.C:
_, _, err := client.SendRequest("keepalive@wallarm", true, nil)
if err != nil {
return fmt.Errorf("keepalive failed: %w", err)
}
}
}
}
func forwardConnection(remoteConn net.Conn, localAddr string) {
defer remoteConn.Close()
localConn, err := net.DialTimeout("tcp", localAddr, 10*time.Second)
if err != nil {
return
}
defer localConn.Close()
go func() {
io.Copy(localConn, remoteConn)
remoteConn.Close()
}()
io.Copy(remoteConn, localConn)
}

View file

@ -169,7 +169,7 @@ for deploy_type in "${DEPLOY_TYPES[@]}"; do
docker) docker)
echo -e "${CYAN}Docker deployment next steps:${NC}" echo -e "${CYAN}Docker deployment next steps:${NC}"
echo -e " 1. Run the preflight check: ${YELLOW}./deploy/wallarm-ct-check.sh${NC}" echo -e " 1. Run the preflight check: ${YELLOW}./deploy/wallarm-ct-check.sh${NC}"
echo -e " 2. Deploy a Wallarm node: ${YELLOW}./deploy/wallarm-docker.sh${NC}" echo -e " 2. Deploy a Wallarm node: ${YELLOW}./deploy/wallarm-ct-deploy.sh${NC}"
echo -e " 3. Reconfigure existing node: ${YELLOW}./deploy/wallarm-ct-reconfigure.sh${NC}" echo -e " 3. Reconfigure existing node: ${YELLOW}./deploy/wallarm-ct-reconfigure.sh${NC}"
echo -e " 4. Uninstall a node: ${YELLOW}./deploy/wallarm-ct-uninstall.sh${NC}" echo -e " 4. Uninstall a node: ${YELLOW}./deploy/wallarm-ct-uninstall.sh${NC}"
echo echo