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:
parent
62ddaaa9c5
commit
1a2a0fbd7b
11 changed files with 2469 additions and 1631 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
161
cmd/wallarm/main.go
Normal 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
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
1698
docker/wallarm-docker.sh
Executable file → Normal file
File diff suppressed because it is too large
Load diff
7
go.mod
Normal file
7
go.mod
Normal 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
6
go.sum
Normal 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=
|
||||||
169
internal/preflight/preflight.go
Normal file
169
internal/preflight/preflight.go
Normal 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
174
internal/shared/shared.go
Normal 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
83
internal/state/state.go
Normal 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
146
internal/tunnel/tunnel.go
Normal 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)
|
||||||
|
}
|
||||||
2
setup.sh
2
setup.sh
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue