Files
2026-06-16 21:52:10 +04:00

2970 lines
94 KiB
Go

package cli
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"text/tabwriter"
"time"
"golang.org/x/term"
"pxmon/internal/cluster"
"pxmon/internal/history"
)
type App struct {
out io.Writer
err io.Writer
}
type multiFlag []string
func (m *multiFlag) String() string {
if m == nil {
return ""
}
return strings.Join(*m, ",")
}
func (m *multiFlag) Set(v string) error {
*m = append(*m, v)
return nil
}
func New(out, err io.Writer) *App {
return &App{out: out, err: err}
}
func (a *App) Run(args []string) int {
root := flag.NewFlagSet("pxmon", flag.ContinueOnError)
root.SetOutput(a.err)
root.Usage = func() {
a.printRootHelp()
}
configPath := root.String("config", "", "Path to encrypted cluster registry file")
jsonOut := root.Bool("json", false, "Output as JSON")
if err := root.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
return a.runRootCommand(root.Args(), *configPath, *jsonOut, true)
}
func (a *App) runRootCommand(rest []string, configPath string, jsonOut bool, allowShell bool) int {
if len(rest) == 0 {
if allowShell && term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) {
return a.runTUI(nil, configPath, jsonOut)
}
a.printRootHelp()
return 0
}
switch rest[0] {
case "help", "--help", "-h":
a.printRootHelp()
return 0
case "locker":
return a.runLocker(rest[1:], configPath, jsonOut)
case "cluster":
if code, ok := a.guardLocker(configPath, rest[0]); ok {
return code
}
return a.runCluster(rest[1:], configPath, jsonOut)
case "shell":
if code, ok := a.guardLocker(configPath, rest[0]); ok {
return code
}
return a.runTUI(rest[1:], configPath, jsonOut)
case "tui":
if code, ok := a.guardLocker(configPath, rest[0]); ok {
return code
}
return a.runTUI(rest[1:], configPath, jsonOut)
case "clusters":
if code, ok := a.guardLocker(configPath, rest[0]); ok {
return code
}
return a.runClusters(rest[1:], configPath, jsonOut)
case "network":
if code, ok := a.guardLocker(configPath, rest[0]); ok {
return code
}
return a.runNetwork(rest[1:], configPath, jsonOut)
case "bot":
if code, ok := a.guardLocker(configPath, rest[0]); ok {
return code
}
return a.runBot(rest[1:], configPath, jsonOut)
case "config":
if code, ok := a.guardLocker(configPath, rest[0]); ok {
return code
}
return a.runConfig(rest[1:], configPath)
case "export":
if code, ok := a.guardLocker(configPath, rest[0]); ok {
return code
}
return a.runConfigExport(rest[1:], configPath)
case "import":
if code, ok := a.guardLocker(configPath, rest[0]); ok {
return code
}
return a.runConfigImport(rest[1:], configPath)
case "explain":
if code, ok := a.guardLocker(configPath, rest[0]); ok {
return code
}
return a.runExplain(rest[1:])
default:
fmt.Fprintf(a.err, "unknown command %q\n\n", rest[0])
a.printRootHelp()
return 2
}
}
func (a *App) guardLocker(configPath, command string) (int, bool) {
store, err := cluster.NewStore(configPath)
if err != nil {
fmt.Fprintf(a.err, "init config store: %v\n", err)
return 1, true
}
svc := cluster.NewService(store)
locked, _, err := svc.IsLocked()
if err != nil {
fmt.Fprintf(a.err, "locker check failed: %v\n", err)
return 1, true
}
if !locked {
return 0, false
}
svc.AuditLocker("locker_blocked_command", command)
fmt.Fprintf(a.err, "locker is enabled: command %q is blocked until unlock\n", command)
fmt.Fprintln(a.err, "Run: pxmon locker unlock")
return 1, true
}
func (a *App) runTUI(args []string, configPath string, jsonOut bool) int {
return a.runDashboard("tui", args, configPath, jsonOut, "overview")
}
func (a *App) runNetwork(args []string, configPath string, jsonOut bool) int {
return a.runDashboard("network", args, configPath, jsonOut, "network")
}
func (a *App) runClusters(args []string, configPath string, jsonOut bool) int {
return a.runDashboard("clusters", args, configPath, jsonOut, "clusters")
}
func (a *App) runDashboard(name string, args []string, configPath string, jsonOut bool, view string) int {
if jsonOut {
fmt.Fprintln(a.err, "--json is not supported for interactive TUI mode")
return 2
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(a.err)
interval := fs.Duration("interval", 2*time.Second, "Refresh interval")
iface := fs.String("iface", "", "Initial network interface")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if fs.NArg() > 1 {
fmt.Fprintf(a.err, "usage: pxmon %s [name-or-id]\n", name)
return 2
}
selector := ""
if fs.NArg() == 1 {
selector = fs.Arg(0)
}
store, err := cluster.NewStore(configPath)
if err != nil {
fmt.Fprintf(a.err, "init config store: %v\n", err)
return 1
}
svc := cluster.NewService(store)
policy, err := svc.GetAlertPolicy(selector)
if err != nil {
if errors.Is(err, cluster.ErrNoActiveCluster) && strings.TrimSpace(selector) == "" {
policy = cluster.AlertPolicy{
CPUWarnPercent: 85,
RAMWarnPercent: 90,
SwapWarnPercent: 80,
DiskWarnPercent: 90,
NetWarnMbps: 300,
}
} else {
fmt.Fprintf(a.err, "load alert policy: %v\n", err)
return 1
}
}
return a.runClusterMonitor(svc, selector, MonitorOptions{
Interval: *interval,
InitialIface: strings.TrimSpace(*iface),
AlertPolicy: policy,
InitialView: view,
})
}
func (a *App) runCluster(args []string, configPath string, jsonOut bool) int {
if len(args) == 0 {
a.printClusterHelp()
return 0
}
store, err := cluster.NewStore(configPath)
if err != nil {
fmt.Fprintf(a.err, "init config store: %v\n", err)
return 1
}
svc := cluster.NewService(store)
svc.AttachNetworkStore(history.NewNetworkStore(svc.DataDir()))
sub := args[0]
rest := args[1:]
switch sub {
case "connect", "add":
return a.runClusterConnect(svc, rest, jsonOut)
case "list", "ls":
return a.runClusterList(svc, jsonOut)
case "show", "get":
return a.runClusterShow(svc, rest, jsonOut)
case "current":
return a.runClusterCurrent(svc, jsonOut)
case "use":
return a.runClusterUse(svc, rest)
case "disconnect", "remove", "rm":
return a.runClusterDisconnect(svc, rest)
case "set-auth", "auth", "password", "passwd":
return a.runClusterSetAuth(svc, rest)
case "openssh", "ssh":
return a.runClusterOpenSSH(svc, rest)
case "exec", "run":
return a.runClusterExec(svc, rest, jsonOut)
case "ping", "check":
return a.runClusterPing(svc, rest, jsonOut)
case "bootstrap":
return a.runClusterBootstrap(svc, rest, jsonOut)
case "agent":
return a.runClusterAgent(svc, rest, jsonOut)
case "stats":
return a.runClusterStats(svc, rest, jsonOut)
case "usage":
return a.runClusterUsage(svc, rest, jsonOut)
case "slo", "availability":
return a.runClusterSLO(svc, rest, jsonOut)
case "traffic":
return a.runClusterTraffic(svc, rest, jsonOut)
case "graph":
return a.runClusterGraph(svc, rest, jsonOut)
case "p95":
return a.runClusterP95(svc, rest, jsonOut)
case "alert", "alerts":
return a.runClusterAlert(svc, rest, jsonOut)
case "alert-routing", "routing":
return a.runClusterAlertRouting(svc, rest, jsonOut)
case "alert-vm", "vm-alert":
return a.runClusterVMAlert(svc, rest, jsonOut)
case "software", "plugins":
return a.runClusterSoftware(svc, rest, jsonOut)
case "tag", "tags":
return a.runClusterTag(svc, rest, jsonOut)
case "kvm-tag", "vm-tag":
return a.runClusterKVMTag(svc, rest, jsonOut)
case "change-history", "changes":
return a.runClusterChangeHistory(svc, rest, jsonOut)
case "drift":
return a.runClusterDrift(svc, rest, jsonOut)
case "runbook":
return a.runClusterRunbook(svc, rest, jsonOut)
case "runbook-trigger":
return a.runClusterRunbookTrigger(svc, rest, jsonOut)
case "schedule", "scheduler":
return a.runClusterSchedule(svc, rest, jsonOut)
case "report":
return a.runClusterReport(svc, rest, jsonOut)
case "backup":
return a.runClusterBackup(svc, rest, jsonOut)
case "repo-tunnel", "repo", "repo-tunneling":
return a.runClusterRepoTunnel(svc, rest, jsonOut)
case "capacity":
return a.runClusterCapacity(svc, rest, jsonOut)
case "help", "--help", "-h":
a.printClusterHelp()
return 0
default:
fmt.Fprintf(a.err, "unknown cluster command %q\n\n", sub)
a.printClusterHelp()
return 2
}
}
func (a *App) runClusterConnect(svc *cluster.Service, args []string, jsonOut bool) int {
fs := flag.NewFlagSet("cluster connect", flag.ContinueOnError)
fs.SetOutput(a.err)
name := fs.String("name", "", "Cluster name")
host := fs.String("host", "", "SSH host or IP")
port := fs.Int("port", 22, "SSH port")
user := fs.String("user", "", "SSH user")
transport := fs.String("type", "direct", "Network transport to the agent: direct|ipfabric (ipfabric tunnels HTTP over the SSH connection)")
auth := fs.String("auth", "key", "SSH auth method: key|password")
password := fs.String("password", "", "SSH password")
storePassword := fs.Bool("store-password", false, "Persist password in encrypted local store")
keyPath := fs.String("key-path", "", "SSH private key path (for auth=key)")
keyPassphrase := fs.String("key-passphrase", "", "SSH private key passphrase")
keyPassphraseFile := fs.String("key-passphrase-file", "", "File containing SSH private key passphrase")
storeKeyPass := fs.Bool("store-key-passphrase", false, "Persist key passphrase in encrypted local store")
storeKeyPassFile := fs.Bool("store-key-passphrase-file", false, "Persist key passphrase file path in encrypted local store")
insecureHostKey := fs.Bool("insecure-host-key", false, "Disable SSH host-key verification")
skipCheck := fs.Bool("skip-check", false, "Skip SSH connectivity check during connect")
allowUnreachable := fs.Bool("allow-unreachable", false, "Save even if SSH check fails")
force := fs.Bool("force", false, "Overwrite existing cluster with same name")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 0 {
fmt.Fprintf(a.err, "unexpected argument(s): %s\n", strings.Join(fs.Args(), " "))
return 2
}
resolvedKeyPassphrase, err := readSecretValueFileFlag("key passphrase", *keyPassphrase, *keyPassphraseFile)
if err != nil {
fmt.Fprintf(a.err, "connect cluster: %v\n", err)
return 2
}
authMethod := cluster.AuthMethod(strings.ToLower(strings.TrimSpace(*auth)))
transportMode, tErr := parseTransportFlag(*transport)
if tErr != nil {
fmt.Fprintf(a.err, "connect cluster: %v\n", tErr)
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
c, probe, err := svc.Connect(ctx, cluster.ConnectOptions{
Name: *name,
Host: *host,
Port: *port,
User: *user,
Transport: transportMode,
AuthMethod: authMethod,
Password: *password,
StorePassword: *storePassword,
KeyPath: *keyPath,
KeyPassphrase: resolvedKeyPassphrase,
KeyPassphraseFile: *keyPassphraseFile,
StoreKeyPassphrase: *storeKeyPass,
StoreKeyPassphraseFile: *storeKeyPassFile,
InsecureHostKey: *insecureHostKey,
SkipCheck: *skipCheck,
AllowUnreachable: *allowUnreachable,
Force: *force,
})
if err != nil {
fmt.Fprintf(a.err, "connect cluster: %v\n", err)
return 1
}
if jsonOut {
c = sanitizeCluster(c, false)
_ = writeJSON(a.out, map[string]any{
"cluster": c,
"probe": probe,
})
return 0
}
fmt.Fprintf(a.out, "Connected %q -> %s@%s:%d\n", c.Name, c.User, c.Host, c.Port)
if c.InsecureHostKey {
fmt.Fprintln(a.err, "warning: insecure host-key mode is ON (MITM risk). Use only in trusted/private networks.")
}
if !probe.CheckedAt.IsZero() {
if probe.Reachable {
fmt.Fprintf(a.out, "SSH probe: OK (%dms)\n", probe.LatencyMS)
} else {
fmt.Fprintf(a.out, "SSH probe: FAILED %s\n", probe.Error)
}
}
fmt.Fprintf(a.out, "Active cluster: %s\n", c.Name)
return 0
}
func (a *App) runClusterList(svc *cluster.Service, jsonOut bool) int {
clusters, activeID, err := svc.List()
if err != nil {
fmt.Fprintf(a.err, "list clusters: %v\n", err)
return 1
}
if jsonOut {
items := make([]map[string]any, 0, len(clusters))
for _, c := range clusters {
items = append(items, map[string]any{
"cluster": sanitizeCluster(c, false),
"repo_tunnel": c.RepoTunnel,
})
}
_ = writeJSON(a.out, map[string]any{
"active_cluster_id": activeID,
"clusters": items,
})
return 0
}
if len(clusters) == 0 {
fmt.Fprintln(a.out, "No clusters connected yet.")
return 0
}
expectedVersion := svc.ExpectedAgentVersion()
type probeResult struct {
reachable bool
sshReachable bool
agentReachable bool
versionMismatch bool
version string
}
probes := make([]probeResult, len(clusters))
var wg sync.WaitGroup
for i := range clusters {
wg.Add(1)
go func(i int) {
defer wg.Done()
c := clusters[i]
res := probeResult{version: strings.TrimSpace(c.Agent.Version)}
// Node reachability is based on SSH/TCP socket availability and is
// independent from agent health/version.
addr := net.JoinHostPort(c.Host, fmt.Sprintf("%d", c.Port))
conn, dialErr := net.DialTimeout("tcp", addr, 2*time.Second)
if dialErr == nil {
res.sshReachable = true
res.reachable = true
_ = conn.Close()
}
if c.Agent.Installed {
ctx, cancel := context.WithTimeout(context.Background(), 2500*time.Millisecond)
ping, pingErr := svc.PingAgent(ctx, c.ID)
cancel()
if pingErr == nil && ping.Reachable && ping.StatusCode < 400 {
res.agentReachable = true
if strings.TrimSpace(ping.Version) != "" {
res.version = strings.TrimSpace(ping.Version)
}
}
if strings.TrimSpace(expectedVersion) != "" && strings.TrimSpace(res.version) != "" && res.version != expectedVersion {
res.versionMismatch = true
}
}
probes[i] = res
}(i)
}
wg.Wait()
type listRow struct {
state string
stateColor string
name string
target string
auth string
agentPlain string
agentColor string
software string
softwareColor string
repoGW string
repoGWColor string
tags string
updated string
id string
}
rows := make([]listRow, 0, len(clusters))
versionWarnings := make([]string, 0, len(clusters))
for i, c := range clusters {
probe := probes[i]
isActive := c.ID == activeID
state := "DOWN"
stateColor := "crit"
if probe.reachable {
state = "UP"
stateColor = "ok"
}
auth := string(c.AuthMethod)
if c.InsecureHostKey {
auth += "(insecure-host-key)"
}
if c.Transport == cluster.TransportIPFabric {
auth += "(ipfabric)"
}
agentStatus := "none"
agentColor := "warn"
if c.Agent.Installed {
version := probe.version
if version == "" {
version = "unknown"
}
agentState := "down"
if probe.agentReachable {
agentState = "up"
}
if probe.versionMismatch {
agentState = "mismatch"
}
agentStatus = fmt.Sprintf("%s v%s", agentState, shortVersion(version))
if probe.versionMismatch {
agentStatus += "!=" + shortVersion(expectedVersion)
}
switch {
case probe.versionMismatch:
agentColor = "warn"
case probe.agentReachable:
agentColor = "ok"
default:
agentColor = "crit"
}
}
software := c.Software.Summary()
softwareColor := "ok"
switch software {
case "-", "":
software = "-"
softwareColor = "crit"
case "none":
softwareColor = "warn"
}
repoGW := "-"
repoGWColor := "dim"
if c.RepoTunnel.Enabled {
repoGW = repoTunnelDisplay(c.RepoTunnel.Proxy)
repoGWColor = "ok"
}
rows = append(rows, listRow{
state: state,
stateColor: stateColor,
name: ternary(isActive, "*"+c.Name, c.Name),
target: fmt.Sprintf("%s@%s:%d", c.User, c.Host, c.Port),
auth: auth,
agentPlain: agentStatus,
agentColor: agentColor,
software: software,
softwareColor: softwareColor,
repoGW: repoGW,
repoGWColor: repoGWColor,
tags: ternary(len(c.Tags) == 0, "-", strings.Join(c.Tags, ",")),
updated: c.UpdatedAt.Local().Format("2006-01-02 15:04:05"),
id: c.ID,
})
if c.Agent.Installed && strings.TrimSpace(probe.version) != "" {
if ok, latest, known := svc.CompareAgentVersion(probe.version); known && !ok {
versionWarnings = append(versionWarnings,
fmt.Sprintf("%s: agent %s is outdated (latest known: %s), some features may not work",
c.Name, probe.version, latest))
} else if probe.versionMismatch {
versionWarnings = append(versionWarnings,
fmt.Sprintf("%s: agent %s differs from local %s, some features may not work",
c.Name, probe.version, expectedVersion))
}
}
}
wState := maxLen("STATE", func(r listRow) string { return r.state }, rows)
wName := maxLen("NAME", func(r listRow) string { return r.name }, rows)
wTarget := maxLen("TARGET", func(r listRow) string { return r.target }, rows)
wAuth := maxLen("AUTH", func(r listRow) string { return r.auth }, rows)
wAgent := maxLen("AGENT", func(r listRow) string { return r.agentPlain }, rows)
wSoftware := maxLen("SOFTWARE", func(r listRow) string { return r.software }, rows)
wRepoGW := maxLen("REPO-GW", func(r listRow) string { return r.repoGW }, rows)
wTags := maxLen("TAGS", func(r listRow) string { return r.tags }, rows)
wUpdated := maxLen("UPDATED", func(r listRow) string { return r.updated }, rows)
wState = clamp(wState, 5, 6)
wName = clamp(wName, 6, 16)
wTarget = clamp(wTarget, 12, 28)
wAuth = clamp(wAuth, 3, 22)
wAgent = clamp(wAgent, 8, 32)
wSoftware = clamp(wSoftware, 4, 14)
wRepoGW = clamp(wRepoGW, 7, 22)
wTags = clamp(wTags, 4, 16)
wUpdated = clamp(wUpdated, 19, 19)
if term.IsTerminal(int(os.Stdout.Fd())) {
if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 {
sep := 2 * 8
for {
total := wState + wName + wTarget + wAuth + wAgent + wSoftware + wRepoGW + wTags + wUpdated + sep
if total <= width {
break
}
shrunk := false
shrunk = shrinkOne(&wAgent, 8) || shrunk
shrunk = shrinkOne(&wTarget, 12) || shrunk
shrunk = shrinkOne(&wName, 6) || shrunk
shrunk = shrinkOne(&wAuth, 3) || shrunk
shrunk = shrinkOne(&wRepoGW, 7) || shrunk
shrunk = shrinkOne(&wTags, 4) || shrunk
shrunk = shrinkOne(&wSoftware, 4) || shrunk
shrunk = shrinkOne(&wUpdated, 10) || shrunk
if !shrunk {
break
}
}
}
}
header := strings.Join([]string{
colorizeCell("STATE", wState, "header"),
colorizeCell("NAME", wName, "header"),
colorizeCell("TARGET", wTarget, "header"),
colorizeCell("AUTH", wAuth, "header"),
colorizeCell("AGENT", wAgent, "header"),
colorizeCell("SOFTWARE", wSoftware, "header"),
colorizeCell("REPO-GW", wRepoGW, "header"),
colorizeCell("TAGS", wTags, "header"),
colorizeCell("UPDATED", wUpdated, "header"),
colorHeader("ID"),
}, " ")
fmt.Fprintln(a.out, header)
for _, r := range rows {
nameColor := "value"
if strings.HasPrefix(r.name, "*") {
nameColor = "accent"
}
line := strings.Join([]string{
colorizeCell(r.state, wState, r.stateColor),
colorizeCell(r.name, wName, nameColor),
colorizeCell(r.target, wTarget, "blue"),
colorizeCell(r.auth, wAuth, "magenta"),
colorizeCell(r.agentPlain, wAgent, r.agentColor),
colorizeCell(r.software, wSoftware, r.softwareColor),
colorizeCell(r.repoGW, wRepoGW, r.repoGWColor),
colorizeCell(r.tags, wTags, "dim"),
colorizeCell(r.updated, wUpdated, "dim"),
colorMuted(r.id),
}, " ")
fmt.Fprintln(a.out, line)
}
if len(versionWarnings) > 0 {
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, colorWarn("Warnings:"))
for _, w := range versionWarnings {
fmt.Fprintf(a.out, " %s %s\n", colorWarn("!"), colorWarn(w))
}
}
return 0
}
func (a *App) runClusterShow(svc *cluster.Service, args []string, jsonOut bool) int {
fs := flag.NewFlagSet("cluster show", flag.ContinueOnError)
fs.SetOutput(a.err)
if err := fs.Parse(args); err != nil {
return 2
}
selector := ""
if fs.NArg() > 0 {
selector = fs.Arg(0)
}
c, err := svc.Get(selector)
if err != nil {
if errors.Is(err, cluster.ErrNoActiveCluster) {
fmt.Fprintln(a.err, "no active cluster")
return 1
}
fmt.Fprintf(a.err, "show cluster: %v\n", err)
return 1
}
c = sanitizeCluster(c, false)
if jsonOut {
_ = writeJSON(a.out, c)
return 0
}
hostKey := ternary(c.InsecureHostKey, "insecure", "strict")
hostKeyColored := colorOK(hostKey)
if c.InsecureHostKey {
hostKeyColored = colorWarn(hostKey)
}
agentStatusText := ternary(c.Agent.Installed, "installed", "not installed")
agentStatusColored := colorWarn(agentStatusText)
if c.Agent.Installed {
agentStatusColored = colorOK(agentStatusText)
}
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Name: "), colorAccent(c.Name))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("ID: "), colorMuted(c.ID))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Host: "), colorBlue(c.Host))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Port: "), colorBlue(fmt.Sprintf("%d", c.Port)))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("User: "), colorValue(c.User))
transportText := string(c.Transport)
if strings.TrimSpace(transportText) == "" {
transportText = string(cluster.TransportDirect)
}
transportColored := colorInfo(transportText)
if c.Transport == cluster.TransportIPFabric {
transportColored = colorWarn(transportText + " (agent tunneled via SSH)")
}
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Transport: "), transportColored)
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Auth: "), colorMagenta(string(c.AuthMethod)))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Password: "), colorDim(printableSecret(c.Password)))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Key path: "), colorInfo(emptyFallback(c.KeyPath, "(empty)")))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Key phrase: "), colorDim(printableSecret(c.KeyPassphrase)))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Phrase file:"), colorInfo(emptyFallback(c.KeyPassphraseFile, "(empty)")))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Host key: "), hostKeyColored)
fmt.Fprintf(a.out, "%s %s / %s / %s / %s / %s\n",
colorLabel("Alerts: "),
colorWarn(fmt.Sprintf("CPU %.1f%%", c.Alerts.CPUWarnPercent)),
colorWarn(fmt.Sprintf("RAM %.1f%%", c.Alerts.RAMWarnPercent)),
colorWarn(fmt.Sprintf("Swap %.1f%%", c.Alerts.SwapWarnPercent)),
colorWarn(fmt.Sprintf("Disk %.1f%%", c.Alerts.DiskWarnPercent)),
colorWarn(fmt.Sprintf("Net %.1f Mbps", c.Alerts.NetWarnMbps)),
)
fmt.Fprintf(a.out, "%s %s / %s / %s\n",
colorLabel("VM alerts: "),
colorWarn(fmt.Sprintf("enabled=%t", c.VMAlerts.Enabled)),
colorWarn(fmt.Sprintf("warn_on_shutoff=%t", c.VMAlerts.WarnOnShutoff)),
colorWarn(fmt.Sprintf("min_running=%d", c.VMAlerts.MinRunning)),
)
fmt.Fprintf(a.out, "%s %s / %s\n",
colorLabel("Routing: "),
colorWarn(fmt.Sprintf("critical_immediate=%t", c.AlertRouting.CriticalImmediate)),
colorWarn(fmt.Sprintf("warning_batch=%dm", c.AlertRouting.WarningBatchMins)),
)
fmt.Fprintf(a.out, "%s %s / %s / %s\n",
colorLabel("RB trigger: "),
colorWarn(fmt.Sprintf("enabled=%t", c.RunbookTrigger.Enabled)),
colorWarn(fmt.Sprintf("runbook=%s", emptyFallback(c.RunbookTrigger.RunbookID, "-"))),
colorWarn(fmt.Sprintf("cooldown=%dm", c.RunbookTrigger.CooldownMins)),
)
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Tags: "), colorInfo(emptyFallback(strings.Join(c.Tags, ","), "-")))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Agent: "), agentStatusColored)
if c.Agent.Installed {
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Agent port: "), colorBlue(fmt.Sprintf("%d", c.Agent.Port)))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Agent token:"), colorDim(printableSecret(c.Agent.Token)))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Agent reqsec:"), colorDim(printableSecret(c.Agent.RequestSecret)))
tlsMode := ternary(c.Agent.TLSEnabled, "enabled", "disabled")
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Agent TLS: "), colorValue(tlsMode))
if c.Agent.TLSEnabled {
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Agent TLS fp:"), colorInfo(emptyFallback(c.Agent.TLSFingerprint, "-")))
}
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Agent bin: "), colorInfo(c.Agent.RemoteBinary))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Agent conf: "), colorInfo(c.Agent.RemoteConfig))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Agent log: "), colorInfo(c.Agent.RemoteLog))
}
softwareSummary := c.Software.Summary()
softwareColored := colorOK(softwareSummary)
if softwareSummary == "-" || softwareSummary == "" {
softwareColored = colorCrit("-")
} else if softwareSummary == "none" {
softwareColored = colorWarn(softwareSummary)
}
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Software: "), softwareColored)
if !c.Software.DetectedAt.IsZero() {
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Soft scan: "), colorDim(c.Software.DetectedAt.Format(time.RFC3339)))
}
if len(c.Software.Versions) > 0 {
keys := make([]string, 0, len(c.Software.Versions))
for k := range c.Software.Versions {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(a.out, "%s %s\n", colorLabel(fmt.Sprintf("Soft %-6s:", k)), colorInfo(c.Software.Versions[k]))
}
}
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Created: "), colorDim(c.CreatedAt.Format(time.RFC3339)))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Updated: "), colorDim(c.UpdatedAt.Format(time.RFC3339)))
return 0
}
func (a *App) runClusterCurrent(svc *cluster.Service, jsonOut bool) int {
c, err := svc.Current()
if err != nil {
fmt.Fprintln(a.err, "no active cluster")
return 1
}
c = sanitizeCluster(c, false)
if jsonOut {
_ = writeJSON(a.out, c)
return 0
}
fmt.Fprintf(a.out, "%s %s%s%s%s%s\n",
colorAccent(c.Name),
colorMuted("("),
colorValue(c.User),
colorMuted("@"),
colorBlue(fmt.Sprintf("%s:%d", c.Host, c.Port)),
colorMuted(")"),
)
return 0
}
func (a *App) runClusterUse(svc *cluster.Service, args []string) int {
if len(args) != 1 {
fmt.Fprintln(a.err, "usage: pxmon cluster use <name-or-id>")
return 2
}
c, err := svc.Use(args[0])
if err != nil {
fmt.Fprintf(a.err, "set active cluster: %v\n", err)
return 1
}
fmt.Fprintf(a.out, "Active cluster is now %q\n", c.Name)
return 0
}
func (a *App) runClusterDisconnect(svc *cluster.Service, args []string) int {
if len(args) != 1 {
fmt.Fprintln(a.err, "usage: pxmon cluster disconnect <name-or-id>")
return 2
}
c, err := svc.Disconnect(args[0])
if err != nil {
fmt.Fprintf(a.err, "disconnect cluster: %v\n", err)
return 1
}
fmt.Fprintf(a.out, "Disconnected cluster %q\n", c.Name)
return 0
}
func (a *App) runClusterSetAuth(svc *cluster.Service, args []string) int {
fs := flag.NewFlagSet("cluster set-auth", flag.ContinueOnError)
fs.SetOutput(a.err)
auth := fs.String("auth", "", "SSH auth method: key|password (optional, keeps current if empty)")
password := fs.String("password", "", "New SSH password")
storePassword := fs.Bool("store-password", false, "Persist password in encrypted local store")
clearPassword := fs.Bool("clear-password", false, "Remove stored password")
keyPath := fs.String("key-path", "", "New SSH private key path")
clearKeyPath := fs.Bool("clear-key-path", false, "Remove stored key path")
keyPass := fs.String("key-passphrase", "", "New key passphrase")
keyPassFile := fs.String("key-passphrase-file", "", "File containing new key passphrase")
storeKeyPass := fs.Bool("store-key-passphrase", false, "Persist key passphrase in encrypted local store")
storeKeyPassFile := fs.Bool("store-key-passphrase-file", false, "Persist key passphrase file path in encrypted local store")
clearKeyPass := fs.Bool("clear-key-passphrase", false, "Remove stored key passphrase")
insecure := fs.String("insecure-host-key", "", "Override host-key verification: on|off (empty keeps current)")
transport := fs.String("type", "", "Network transport: direct|ipfabric (empty keeps current)")
selector, parseArgs := splitLeadingSelector(args)
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster set-auth <name-or-id> [flags]")
return 2
}
selector = fs.Arg(0)
}
if strings.TrimSpace(selector) == "" {
fmt.Fprintln(a.err, "usage: pxmon cluster set-auth <name-or-id> [flags]")
return 2
}
if fs.NArg() > 1 {
fmt.Fprintln(a.err, "usage: pxmon cluster set-auth <name-or-id> [flags]")
return 2
}
opts := cluster.UpdateAuthOptions{}
if strings.TrimSpace(*auth) != "" {
opts.AuthMethod = cluster.AuthMethod(strings.ToLower(strings.TrimSpace(*auth)))
}
// Track flag presence to distinguish "unset" from "set to empty".
seen := map[string]bool{}
fs.Visit(func(f *flag.Flag) { seen[f.Name] = true })
resolvedKeyPass, err := readSecretValueFileFlag("key passphrase", *keyPass, *keyPassFile)
if err != nil {
fmt.Fprintf(a.err, "set auth: %v\n", err)
return 2
}
if seen["password"] || *storePassword {
opts.SetPassword = true
opts.Password = *password
opts.StorePassword = *storePassword
}
if *clearPassword {
opts.ClearPassword = true
}
if seen["key-path"] || *clearKeyPath {
opts.SetKeyPath = true
if *clearKeyPath {
opts.KeyPath = ""
} else {
opts.KeyPath = *keyPath
}
}
if seen["key-passphrase"] || seen["key-passphrase-file"] || *storeKeyPass {
opts.SetKeyPassphrase = true
opts.KeyPassphrase = resolvedKeyPass
opts.StoreKeyPassphrase = *storeKeyPass
}
if seen["key-passphrase-file"] || *storeKeyPassFile {
opts.SetKeyPassphraseFile = true
opts.KeyPassphraseFile = *keyPassFile
opts.StoreKeyPassphraseFile = *storeKeyPassFile
}
if *clearKeyPass {
opts.ClearKeyPassphrase = true
}
switch strings.ToLower(strings.TrimSpace(*insecure)) {
case "":
case "on", "true", "yes", "1":
opts.SetInsecureHostKey = true
opts.InsecureHostKey = true
case "off", "false", "no", "0":
opts.SetInsecureHostKey = true
opts.InsecureHostKey = false
default:
fmt.Fprintf(a.err, "invalid value for --insecure-host-key: %q (use on|off)\n", *insecure)
return 2
}
if strings.TrimSpace(*transport) != "" {
mode, tErr := parseTransportFlag(*transport)
if tErr != nil {
fmt.Fprintf(a.err, "%v\n", tErr)
return 2
}
opts.SetTransport = true
opts.Transport = mode
}
c, err := svc.UpdateAuth(selector, opts)
if err != nil {
fmt.Fprintf(a.err, "set-auth: %v\n", err)
return 1
}
fmt.Fprintf(a.out, "%s %s %s %s %s %s\n",
colorLabel("Updated auth for:"),
colorAccent(c.Name),
colorLabel("method:"),
colorValue(string(c.AuthMethod)),
colorLabel("stored:"),
colorValue(authStoredSummary(c)),
)
if c.InsecureHostKey {
fmt.Fprintln(a.err, "warning: insecure host-key mode is ON (MITM risk). Use --insecure-host-key off to restore strict verification.")
}
return 0
}
func parseTransportFlag(raw string) (cluster.TransportMode, error) {
v := strings.ToLower(strings.TrimSpace(raw))
switch v {
case "", "direct":
return cluster.TransportDirect, nil
case "ipfabric", "ip-fabric", "ip_fabric":
return cluster.TransportIPFabric, nil
default:
return "", fmt.Errorf("invalid --type %q (allowed: direct, ipfabric)", raw)
}
}
func readSecretValueFileFlag(label, inlineValue, filePath string) (string, error) {
if strings.TrimSpace(filePath) == "" {
return inlineValue, nil
}
if inlineValue != "" {
return "", fmt.Errorf("--%s and --%s-file cannot be used together", strings.ReplaceAll(label, " ", "-"), strings.ReplaceAll(label, " ", "-"))
}
expanded, err := expandCLIPath(filePath)
if err != nil {
return "", fmt.Errorf("read %s file: %w", label, err)
}
data, err := os.ReadFile(expanded)
if err != nil {
return "", fmt.Errorf("read %s file: %w", label, err)
}
value := strings.TrimRight(string(data), "\r\n")
if value == "" {
return "", fmt.Errorf("%s file is empty", label)
}
return value, nil
}
func expandCLIPath(path string) (string, error) {
p := strings.TrimSpace(path)
if p == "" {
return "", errors.New("empty path")
}
if strings.HasPrefix(p, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
if p == "~" {
p = home
} else {
p = filepath.Join(home, strings.TrimPrefix(p, "~/"))
}
}
return p, nil
}
func authStoredSummary(c cluster.Cluster) string {
parts := []string{}
if strings.TrimSpace(c.Password) != "" {
parts = append(parts, "password")
}
if strings.TrimSpace(c.KeyPath) != "" {
parts = append(parts, "key-path")
}
if strings.TrimSpace(c.KeyPassphrase) != "" {
parts = append(parts, "key-passphrase")
}
if strings.TrimSpace(c.KeyPassphraseFile) != "" {
parts = append(parts, "key-passphrase-file")
}
if len(parts) == 0 {
return "none"
}
return strings.Join(parts, ",")
}
func repoTunnelDisplay(proxy string) string {
v := strings.TrimSpace(proxy)
v = strings.TrimPrefix(v, "http://")
v = strings.TrimPrefix(v, "https://")
return emptyFallback(v, "enabled")
}
func (a *App) runClusterOpenSSH(svc *cluster.Service, args []string) int {
selector := ""
if len(args) > 0 {
first := strings.TrimSpace(args[0])
if first != "" && !strings.HasPrefix(first, "-") {
selector = first
}
}
c, err := svc.Get(selector)
if err != nil {
if errors.Is(err, cluster.ErrNoActiveCluster) {
fmt.Fprintln(a.err, "no active cluster; pass name or run `cluster use <name>` first")
return 1
}
fmt.Fprintf(a.err, "openssh: %v\n", err)
return 1
}
stdinFd := int(os.Stdin.Fd())
if !term.IsTerminal(stdinFd) {
fmt.Fprintln(a.err, "openssh: stdin is not a terminal; interactive shell requires a tty")
return 1
}
fmt.Fprintf(a.out, "%s %s\r\n",
colorLabel("Opening SSH:"),
colorAccent(fmt.Sprintf("%s@%s:%d", c.User, c.Host, c.Port)),
)
width, height, err := term.GetSize(stdinFd)
if err != nil || width <= 0 || height <= 0 {
width, height = 120, 32
}
oldState, err := term.MakeRaw(stdinFd)
if err != nil {
fmt.Fprintf(a.err, "openssh: raw mode: %v\n", err)
return 1
}
defer func() { _ = term.Restore(stdinFd, oldState) }()
resizeCh := make(chan cluster.InteractiveShellSize, 4)
resizeCh <- cluster.InteractiveShellSize{Width: width, Height: height}
sigCh := installWinchHandler(stdinFd, resizeCh)
defer closeWinchHandler(sigCh, resizeCh)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
opts := cluster.InteractiveShellOptions{
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
Term: os.Getenv("TERM"),
Width: width,
Height: height,
Resize: resizeCh,
}
if err := svc.OpenInteractiveShell(ctx, c.ID, opts); err != nil {
_ = term.Restore(stdinFd, oldState)
fmt.Fprintf(a.err, "\r\nopenssh: %v\r\n", err)
return 1
}
return 0
}
func (a *App) runClusterExec(svc *cluster.Service, args []string, jsonOut bool) int {
selector, rest := splitLeadingSelector(args)
if strings.TrimSpace(selector) == "" {
fmt.Fprintln(a.err, "usage: pxmon cluster exec <name-or-id> -- <command>")
return 2
}
if len(rest) > 0 && rest[0] == "--" {
rest = rest[1:]
}
if len(rest) == 0 {
fmt.Fprintln(a.err, "usage: pxmon cluster exec <name-or-id> -- <command>")
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := svc.RunRemoteShell(ctx, selector, strings.Join(rest, " "))
if err != nil {
fmt.Fprintf(a.err, "exec: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{"output": out})
} else {
fmt.Fprint(a.out, out)
}
return 0
}
func (a *App) runClusterPing(svc *cluster.Service, args []string, jsonOut bool) int {
fs := flag.NewFlagSet("cluster ping", flag.ContinueOnError)
fs.SetOutput(a.err)
password := fs.String("password", "", "Password override for SSH auth")
keyPassphrase := fs.String("key-passphrase", "", "Key passphrase override for SSH auth")
keyPassphraseFile := fs.String("key-passphrase-file", "", "File containing key passphrase override for SSH auth")
agent := fs.Bool("agent", false, "Ping pxmon-agent instead of SSH")
selector, parseArgs := splitLeadingSelector(args)
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster ping [name-or-id] [--agent]")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 {
fmt.Fprintln(a.err, "usage: pxmon cluster ping [name-or-id] [--agent]")
return 2
}
resolvedKeyPassphrase, err := readSecretValueFileFlag("key passphrase", *keyPassphrase, *keyPassphraseFile)
if err != nil {
fmt.Fprintf(a.err, "ssh ping: %v\n", err)
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
if *agent {
result, err := svc.PingAgent(ctx, selector)
if err != nil {
fmt.Fprintf(a.err, "agent ping: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, result)
return 0
}
if result.Reachable && result.StatusCode < 400 {
fmt.Fprintf(a.out, "%s %s %s\n",
colorOK("AGENT OK"),
colorBlue(result.Endpoint),
colorMuted(fmt.Sprintf("[%d]", result.StatusCode)),
)
return 0
}
fmt.Fprintf(a.err, "%s %s\n",
colorCrit("AGENT FAILED"),
colorWarn(emptyFallback(result.Error, "unknown error")),
)
return 1
}
result, err := svc.ProbeSSH(ctx, selector, *password, resolvedKeyPassphrase)
if err != nil {
fmt.Fprintf(a.err, "ssh ping: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, result)
return 0
}
if result.Reachable {
fmt.Fprintf(a.out, "%s %s %s\n",
colorOK("SSH OK"),
colorBlue(result.Address),
colorMuted(fmt.Sprintf("(%dms)", result.LatencyMS)),
)
return 0
}
fmt.Fprintf(a.err, "%s %s\n", colorCrit("SSH FAILED"), colorWarn(result.Error))
return 1
}
func (a *App) runClusterBootstrap(svc *cluster.Service, args []string, jsonOut bool) int {
fs := flag.NewFlagSet("cluster bootstrap", flag.ContinueOnError)
fs.SetOutput(a.err)
password := fs.String("password", "", "Password override for SSH auth")
keyPassphrase := fs.String("key-passphrase", "", "Key passphrase override for SSH auth")
keyPassphraseFile := fs.String("key-passphrase-file", "", "File containing key passphrase override for SSH auth")
listen := fs.String("listen", "0.0.0.0:19090", "Agent listen address on remote node")
port := fs.Int("port", 0, "Agent port override")
agentBin := fs.String("agent-bin", "", "Local path to prebuilt pxmon-agent binary")
rotateToken := fs.Bool("rotate-token", false, "Rotate API token while bootstrapping")
allowProbeFail := fs.Bool("allow-agent-probe-fail", false, "Do not fail command if post-install agent probe fails")
selector, parseArgs := splitLeadingSelector(args)
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster bootstrap [name-or-id]")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 {
fmt.Fprintln(a.err, "usage: pxmon cluster bootstrap [name-or-id]")
return 2
}
resolvedKeyPassphrase, err := readSecretValueFileFlag("key passphrase", *keyPassphrase, *keyPassphraseFile)
if err != nil {
fmt.Fprintf(a.err, "bootstrap cluster: %v\n", err)
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
c, result, err := svc.BootstrapAgent(ctx, cluster.BootstrapOptions{
Selector: selector,
Password: *password,
KeyPassphrase: resolvedKeyPassphrase,
ListenAddress: *listen,
AgentPort: *port,
LocalAgentBin: *agentBin,
RotateToken: *rotateToken,
AllowAgentProbe: *allowProbeFail,
})
if err != nil {
fmt.Fprintf(a.err, "bootstrap cluster: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{
"cluster": sanitizeCluster(c, false),
"result": result,
})
return 0
}
fmt.Fprintf(a.out, "Bootstrap complete for %q\n", c.Name)
fmt.Fprintf(a.out, "Remote runtime: %s/%s\n", result.RemoteOS, result.RemoteArch)
fmt.Fprintf(a.out, "Remote PID: %s\n", result.PID)
if result.AgentPing.Reachable && result.AgentPing.StatusCode < 400 {
fmt.Fprintf(a.out, "Agent ping: OK %s [%d]\n", result.AgentPing.Endpoint, result.AgentPing.StatusCode)
} else {
fmt.Fprintf(a.out, "Agent ping: FAILED %s\n", emptyFallback(result.AgentPing.Error, "unknown error"))
}
_ = svc.AppendChange("agent.bootstrap", c.Name, fmt.Sprintf("version=%s", c.Agent.Version))
return 0
}
func (a *App) runClusterAgent(svc *cluster.Service, args []string, jsonOut bool) int {
if len(args) == 0 {
fmt.Fprintln(a.err, "usage: pxmon cluster agent <status|update|versions> [name-or-id]")
return 2
}
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "status":
fs := flag.NewFlagSet("cluster agent status", flag.ContinueOnError)
fs.SetOutput(a.err)
timeout := fs.Duration("timeout", 1500*time.Millisecond, "Per-node ping timeout")
selector, parseArgs := splitLeadingSelector(args[1:])
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster agent status [name-or-id]")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 {
fmt.Fprintln(a.err, "usage: pxmon cluster agent status [name-or-id]")
return 2
}
expectedVersion := svc.ExpectedAgentVersion()
if strings.TrimSpace(selector) != "" {
c, err := svc.Get(selector)
if err != nil {
fmt.Fprintf(a.err, "agent status: %v\n", err)
return 1
}
status, code := buildAgentStatusRow(svc, c, expectedVersion, *timeout)
if jsonOut {
_ = writeJSON(a.out, status)
return 0
}
if code != 0 {
fmt.Fprintf(a.err, "%s\n", statusErrorText(status))
return code
}
printAgentStatusTable(a.out, []map[string]any{status})
return 0
}
clusters, _, err := svc.List()
if err != nil {
fmt.Fprintf(a.err, "agent status: %v\n", err)
return 1
}
items := make([]map[string]any, 0, len(clusters))
failed := false
for _, c := range clusters {
row, code := buildAgentStatusRow(svc, c, expectedVersion, *timeout)
if code != 0 {
failed = true
}
items = append(items, row)
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{
"expected_version": expectedVersion,
"items": items,
})
if failed {
return 1
}
return 0
}
printAgentStatusTable(a.out, items)
if failed {
return 1
}
return 0
case "adopt-auth", "sync-auth", "repair-auth":
fs := flag.NewFlagSet("cluster agent adopt-auth", flag.ContinueOnError)
fs.SetOutput(a.err)
selector, parseArgs := splitLeadingSelector(args[1:])
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster agent adopt-auth [name-or-id]")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 {
fmt.Fprintln(a.err, "usage: pxmon cluster agent adopt-auth [name-or-id]")
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
c, ping, err := svc.AdoptAgentAuth(ctx, selector)
if err != nil {
fmt.Fprintf(a.err, "agent adopt-auth: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{
"cluster": sanitizeCluster(c, false),
"ping": ping,
})
return 0
}
fmt.Fprintf(a.out, "Adopted agent auth for %q\n", c.Name)
if ping.Reachable && ping.StatusCode < 400 {
fmt.Fprintf(a.out, "Agent ping: OK %s [%d]\n", ping.Endpoint, ping.StatusCode)
} else {
fmt.Fprintf(a.out, "Agent ping: FAILED %s\n", emptyFallback(ping.Error, "unknown error"))
}
return 0
case "versions", "version-list":
items := svc.AgentVersions()
if jsonOut {
_ = writeJSON(a.out, map[string]any{"items": items})
return 0
}
if len(items) == 0 {
fmt.Fprintln(a.out, "(no known versions)")
return 0
}
for _, it := range items {
fmt.Fprintf(a.out, "%s", it.Version)
if strings.TrimSpace(it.ReleasedAt) != "" {
fmt.Fprintf(a.out, " (%s)", it.ReleasedAt)
}
fmt.Fprintln(a.out)
for _, feat := range it.Features {
fmt.Fprintf(a.out, " - %s\n", feat)
}
}
return 0
case "update", "upgrade", "reinstall":
fs := flag.NewFlagSet("cluster agent update", flag.ContinueOnError)
fs.SetOutput(a.err)
password := fs.String("password", "", "Password override for SSH auth")
keyPassphrase := fs.String("key-passphrase", "", "Key passphrase override for SSH auth")
keyPassphraseFile := fs.String("key-passphrase-file", "", "File containing key passphrase override for SSH auth")
listen := fs.String("listen", "0.0.0.0:19090", "Agent listen address on remote node")
port := fs.Int("port", 0, "Agent port override")
agentBin := fs.String("agent-bin", "", "Local path to prebuilt pxmon-agent binary")
rotateToken := fs.Bool("rotate-token", false, "Rotate API token during update")
allowProbeFail := fs.Bool("allow-agent-probe-fail", false, "Do not fail command if post-update probe fails")
restartBot := fs.Bool("restart-bot", true, "Restart local telegram bot daemon after update (if enabled)")
selector, parseArgs := splitLeadingSelector(args[1:])
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster agent update [name-or-id]")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 {
fmt.Fprintln(a.err, "usage: pxmon cluster agent update [name-or-id]")
return 2
}
resolvedKeyPassphrase, err := readSecretValueFileFlag("key passphrase", *keyPassphrase, *keyPassphraseFile)
if err != nil {
fmt.Fprintf(a.err, "agent update: %v\n", err)
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
c, result, err := svc.BootstrapAgent(ctx, cluster.BootstrapOptions{
Selector: selector,
Password: *password,
KeyPassphrase: resolvedKeyPassphrase,
ListenAddress: *listen,
AgentPort: *port,
LocalAgentBin: *agentBin,
RotateToken: *rotateToken,
AllowAgentProbe: *allowProbeFail,
})
if err != nil {
fmt.Fprintf(a.err, "agent update: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{
"cluster": sanitizeCluster(c, false),
"result": result,
})
return 0
}
fmt.Fprintf(a.out, "Agent updated for %q\n", c.Name)
fmt.Fprintf(a.out, "Remote runtime: %s/%s\n", result.RemoteOS, result.RemoteArch)
fmt.Fprintf(a.out, "PID: %s\n", result.PID)
fmt.Fprintf(a.out, "Version: node=%s local=%s\n", emptyFallback(c.Agent.Version, "unknown"), svc.ExpectedAgentVersion())
if result.AgentPing.Reachable && result.AgentPing.StatusCode < 400 {
fmt.Fprintf(a.out, "Agent ping: OK %s [%d]\n", result.AgentPing.Endpoint, result.AgentPing.StatusCode)
} else {
fmt.Fprintf(a.out, "Agent ping: FAILED %s\n", emptyFallback(result.AgentPing.Error, "unknown error"))
}
if *restartBot {
if tgCfg, tgErr := svc.GetTelegram(); tgErr == nil && tgCfg.Enabled {
if pid, _, rbErr := restartTelegramBotDaemon(svc, svc.ConfigPath(), telegramBotDefaultPoll); rbErr != nil {
fmt.Fprintf(a.err, "agent update: warning: failed to restart telegram bot daemon: %v\n", rbErr)
} else if !jsonOut {
fmt.Fprintf(a.out, "Telegram bot daemon restarted (pid %d)\n", pid)
}
}
}
_ = svc.AppendChange("agent.update", c.Name, fmt.Sprintf("version=%s", c.Agent.Version))
return 0
default:
fmt.Fprintf(a.err, "unknown agent subcommand %q\n", args[0])
fmt.Fprintln(a.err, "usage: pxmon cluster agent <status|update|versions> [name-or-id]")
return 2
}
}
func (a *App) runClusterRepoTunnel(svc *cluster.Service, args []string, jsonOut bool) int {
if len(args) == 0 {
a.printRepoTunnelHelp()
return 0
}
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "enable", "on":
fs := flag.NewFlagSet("cluster repo-tunnel enable", flag.ContinueOnError)
fs.SetOutput(a.err)
gateway := fs.String("gateway", "", "Repo gateway proxy endpoint host[:port] (default port 3128)")
gatewayIP := fs.String("gateway-ip", "", "Resolved gateway IP for ip rule; skips DNS on node")
table := fs.Int("table", 0, "Routing table used for gateway egress (required)")
priority := fs.Int("priority", 0, "Optional ip rule priority")
manager := fs.String("manager", "auto", "Package manager: auto|apt|dnf|yum")
noRule := fs.Bool("no-rule", false, "Only configure package proxy; do not add ip rule")
selector, parseArgs := splitLeadingSelector(args[1:])
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel enable <node> --gateway <ip:port> --table <table>")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 || strings.TrimSpace(selector) == "" {
fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel enable <node> --gateway <ip:port> --table <table>")
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
out, err := svc.RepoTunnelEnable(ctx, selector, cluster.RepoTunnelOptions{
Gateway: *gateway,
GatewayIP: *gatewayIP,
Table: *table,
Priority: *priority,
PackageManager: *manager,
NoRule: *noRule,
})
if err != nil {
fmt.Fprintf(a.err, "repo-tunnel enable: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{"output": out})
} else {
fmt.Fprint(a.out, out)
}
return 0
case "disable", "off":
fs := flag.NewFlagSet("cluster repo-tunnel disable", flag.ContinueOnError)
fs.SetOutput(a.err)
gateway := fs.String("gateway", "", "Repo gateway proxy endpoint host[:port]")
gatewayIP := fs.String("gateway-ip", "", "Resolved gateway IP for ip rule; skips DNS on node")
table := fs.Int("table", 0, "Routing table used for gateway egress")
noRule := fs.Bool("no-rule", false, "Only remove package proxy; do not delete ip rule")
selector, parseArgs := splitLeadingSelector(args[1:])
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel disable <node> --gateway <ip:port> --table <table>")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 || strings.TrimSpace(selector) == "" {
fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel disable <node> --gateway <ip:port> --table <table>")
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
out, err := svc.RepoTunnelDisable(ctx, selector, cluster.RepoTunnelOptions{
Gateway: *gateway,
GatewayIP: *gatewayIP,
Table: *table,
NoRule: *noRule,
})
if err != nil {
fmt.Fprintf(a.err, "repo-tunnel disable: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{"output": out})
} else {
fmt.Fprint(a.out, out)
}
return 0
case "status":
fs := flag.NewFlagSet("cluster repo-tunnel status", flag.ContinueOnError)
fs.SetOutput(a.err)
selector, parseArgs := splitLeadingSelector(args[1:])
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel status <node>")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 || strings.TrimSpace(selector) == "" {
fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel status <node>")
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
out, err := svc.RepoTunnelStatus(ctx, selector)
if err != nil {
fmt.Fprintf(a.err, "repo-tunnel status: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{"output": out})
} else {
fmt.Fprint(a.out, out)
}
return 0
case "install", "run":
fs := flag.NewFlagSet("cluster repo-tunnel install", flag.ContinueOnError)
fs.SetOutput(a.err)
gateway := fs.String("gateway", "", "Repo gateway proxy endpoint host[:port] (default port 3128)")
gatewayIP := fs.String("gateway-ip", "", "Resolved gateway IP for ip rule; skips DNS on node")
table := fs.Int("table", 0, "Routing table used for gateway egress (required)")
priority := fs.Int("priority", 0, "Optional ip rule priority")
manager := fs.String("manager", "auto", "Package manager: auto|apt|dnf|yum")
noRule := fs.Bool("no-rule", false, "Only configure package proxy; do not add ip rule")
keep := fs.Bool("keep-enabled", false, "Keep repo tunnel enabled after command finishes")
selector, parseArgs := splitLeadingSelector(args[1:])
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if strings.TrimSpace(selector) == "" && fs.NArg() > 0 {
selector = fs.Arg(0)
}
if strings.TrimSpace(selector) == "" {
fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel install <node> --gateway <ip:port> --table <table> -- <command>")
return 2
}
cmdArgs := fs.Args()
if len(cmdArgs) > 0 && cmdArgs[0] == selector {
cmdArgs = cmdArgs[1:]
}
if len(cmdArgs) == 0 {
fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel install <node> --gateway <ip:port> --table <table> -- <command>")
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
out, err := svc.RepoTunnelInstall(ctx, selector, cluster.RepoTunnelOptions{
Gateway: *gateway,
GatewayIP: *gatewayIP,
Table: *table,
Priority: *priority,
PackageManager: *manager,
Command: strings.Join(cmdArgs, " "),
KeepEnabled: *keep,
NoRule: *noRule,
})
if err != nil {
fmt.Fprintf(a.err, "repo-tunnel install: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{"output": out})
} else {
fmt.Fprint(a.out, out)
}
return 0
case "gateway-script":
fs := flag.NewFlagSet("cluster repo-tunnel gateway-script", flag.ContinueOnError)
fs.SetOutput(a.err)
port := fs.Int("port", 3128, "Squid listen port")
allows := multiFlag{}
fs.Var(&allows, "allow", "Allowed node source CIDR/IP (repeatable)")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
script, err := cluster.RepoTunnelGatewayScript(*port, allows)
if err != nil {
fmt.Fprintf(a.err, "repo-tunnel gateway-script: %v\n", err)
return 1
}
fmt.Fprintln(a.out, script)
return 0
case "gateway-setup":
fs := flag.NewFlagSet("cluster repo-tunnel gateway-setup", flag.ContinueOnError)
fs.SetOutput(a.err)
port := fs.Int("port", 3128, "Squid listen port")
allows := multiFlag{}
fs.Var(&allows, "allow", "Allowed node source CIDR/IP (repeatable)")
selector, parseArgs := splitLeadingSelector(args[1:])
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel gateway-setup <gateway-node> --allow <node-ip/cidr>")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 || strings.TrimSpace(selector) == "" {
fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel gateway-setup <gateway-node> --allow <node-ip/cidr>")
return 2
}
script, err := cluster.RepoTunnelGatewayScript(*port, allows)
if err != nil {
fmt.Fprintf(a.err, "repo-tunnel gateway-setup: %v\n", err)
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
out, err := svc.RunRemoteShell(ctx, selector, script)
if err != nil {
fmt.Fprintf(a.err, "repo-tunnel gateway-setup: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{"output": out})
} else {
fmt.Fprint(a.out, out)
}
return 0
case "help", "--help", "-h":
a.printRepoTunnelHelp()
return 0
default:
fmt.Fprintf(a.err, "unknown repo-tunnel command %q\n\n", args[0])
a.printRepoTunnelHelp()
return 2
}
}
func (a *App) printRepoTunnelHelp() {
fmt.Fprintln(a.out, "pxmon cluster repo-tunnel commands:")
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, " gateway-script Print a squid setup script for a gateway VM")
fmt.Fprintln(a.out, " gateway-setup Install/configure squid on a managed gateway VM")
fmt.Fprintln(a.out, " enable Add ip rule and package proxy config on an ipfabric node")
fmt.Fprintln(a.out, " install Enable tunnel, run package command, then disable it")
fmt.Fprintln(a.out, " disable Remove package proxy config and matching ip rule")
fmt.Fprintln(a.out, " status Show repo tunnel config and ip rules")
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, "Examples:")
fmt.Fprintln(a.out, " pxmon cluster repo-tunnel gateway-script --allow 198.51.100.20/32 --port 3128")
fmt.Fprintln(a.out, " pxmon cluster repo-tunnel gateway-setup repo-vm --allow 198.51.100.20/32 --port 3128")
fmt.Fprintln(a.out, " pxmon cluster repo-tunnel enable edge-node-1 --gateway 203.0.113.10:3128 --table 1010 --manager dnf")
fmt.Fprintln(a.out, " pxmon cluster repo-tunnel install edge-node-1 --gateway 203.0.113.10:3128 --table 1010 -- dnf install -y curl jq")
fmt.Fprintln(a.out, " pxmon cluster repo-tunnel disable edge-node-1 --gateway 203.0.113.10:3128 --table 1010")
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, "Notes:")
fmt.Fprintln(a.out, " --table is intentionally required because ipfabric routing tables differ between nodes.")
fmt.Fprintln(a.out, " Use --gateway-ip if the node cannot resolve the gateway hostname before proxy is enabled.")
}
func buildAgentStatusRow(svc *cluster.Service, c cluster.Cluster, expectedVersion string, timeout time.Duration) (map[string]any, int) {
row := map[string]any{
"cluster": c.Name,
"id": c.ID,
"installed": c.Agent.Installed,
"expected_version": expectedVersion,
"node_version": strings.TrimSpace(c.Agent.Version),
"status": "NOT_INSTALLED",
"online": false,
"error": "",
}
if !c.Agent.Installed {
return row, 1
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
ping, err := svc.PingAgent(ctx, c.ID)
if err != nil {
row["status"] = "OFFLINE"
row["error"] = err.Error()
return row, 1
}
row["online"] = ping.Reachable && ping.StatusCode < 400
if strings.TrimSpace(ping.Version) != "" {
row["node_version"] = strings.TrimSpace(ping.Version)
}
row["endpoint"] = ping.Endpoint
row["http_status"] = ping.StatusCode
nodeVersion := strings.TrimSpace(fmt.Sprintf("%v", row["node_version"]))
if !ping.Reachable || ping.StatusCode >= 400 {
row["status"] = "OFFLINE"
if strings.TrimSpace(ping.Error) != "" {
row["error"] = ping.Error
}
return row, 1
}
if nodeVersion == "" {
row["status"] = "UNKNOWN_VERSION"
return row, 1
}
if strings.TrimSpace(expectedVersion) == "" || nodeVersion == expectedVersion {
row["status"] = "OK"
return row, 0
}
row["status"] = "MISMATCH"
row["error"] = fmt.Sprintf("node=%s local=%s", nodeVersion, expectedVersion)
return row, 1
}
func statusErrorText(row map[string]any) string {
clusterName := strings.TrimSpace(fmt.Sprintf("%v", row["cluster"]))
status := strings.TrimSpace(fmt.Sprintf("%v", row["status"]))
errText := strings.TrimSpace(fmt.Sprintf("%v", row["error"]))
if errText == "" {
errText = status
}
return fmt.Sprintf("%s: %s", clusterName, errText)
}
func printAgentStatusTable(w io.Writer, items []map[string]any) {
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
colorHeader("CLUSTER"),
colorHeader("STATUS"),
colorHeader("ONLINE"),
colorHeader("NODE_VERSION"),
colorHeader("LOCAL_VERSION"),
colorHeader("DETAIL"),
)
for _, row := range items {
clusterName := strings.TrimSpace(fmt.Sprintf("%v", row["cluster"]))
status := strings.TrimSpace(fmt.Sprintf("%v", row["status"]))
online := strings.TrimSpace(fmt.Sprintf("%v", row["online"]))
nodeVersion := strings.TrimSpace(fmt.Sprintf("%v", row["node_version"]))
localVersion := strings.TrimSpace(fmt.Sprintf("%v", row["expected_version"]))
detail := strings.TrimSpace(fmt.Sprintf("%v", row["error"]))
if detail == "" {
detail = "-"
}
if nodeVersion == "" {
nodeVersion = "-"
}
if localVersion == "" {
localVersion = "-"
}
renderStatus := status
switch status {
case "OK":
renderStatus = colorOK(status)
case "MISMATCH", "UNKNOWN_VERSION":
renderStatus = colorWarn(status)
default:
renderStatus = colorCrit(status)
}
onlineColored := colorCrit(online)
if online == "true" {
onlineColored = colorOK(online)
}
detailColored := colorDim(detail)
if detail != "-" && status != "OK" {
detailColored = colorWarn(detail)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
colorAccent(clusterName),
renderStatus,
onlineColored,
colorInfo(nodeVersion),
colorBlue(localVersion),
detailColored,
)
}
_ = tw.Flush()
}
func (a *App) runClusterStats(svc *cluster.Service, args []string, jsonOut bool) int {
fs := flag.NewFlagSet("cluster stats", flag.ContinueOnError)
fs.SetOutput(a.err)
once := fs.Bool("once", false, "Print one snapshot and exit")
interval := fs.Duration("interval", 2*time.Second, "Refresh interval for interactive mode")
iface := fs.String("iface", "", "Initial network interface for monitor view")
cpuWarn := fs.Float64("cpu-warn", 0, "Override CPU warning threshold percent")
ramWarn := fs.Float64("ram-warn", 0, "Override RAM warning threshold percent")
swapWarn := fs.Float64("swap-warn", 0, "Override swap warning threshold percent")
diskWarn := fs.Float64("disk-warn", 0, "Override disk warning threshold percent")
netWarn := fs.Float64("net-warn-mbps", 0, "Override network warning threshold Mbps")
selector, parseArgs := splitLeadingSelector(args)
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster stats [name-or-id]")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 {
fmt.Fprintln(a.err, "usage: pxmon cluster stats [name-or-id]")
return 2
}
policy, err := svc.GetAlertPolicy(selector)
if err != nil {
if errors.Is(err, cluster.ErrNoActiveCluster) {
fmt.Fprintln(a.err, "no active cluster")
return 1
}
fmt.Fprintf(a.err, "load alert policy: %v\n", err)
return 1
}
if *cpuWarn > 0 {
policy.CPUWarnPercent = *cpuWarn
}
if *ramWarn > 0 {
policy.RAMWarnPercent = *ramWarn
}
if *swapWarn > 0 {
policy.SwapWarnPercent = *swapWarn
}
if *diskWarn > 0 {
policy.DiskWarnPercent = *diskWarn
}
if *netWarn > 0 {
policy.NetWarnMbps = *netWarn
}
if jsonOut || *once {
return a.runClusterStatsOnce(svc, selector, jsonOut)
}
return a.runClusterMonitor(svc, selector, MonitorOptions{
Interval: *interval,
InitialIface: strings.TrimSpace(*iface),
AlertPolicy: policy,
InitialView: "overview",
})
}
func (a *App) runClusterStatsOnce(svc *cluster.Service, selector string, jsonOut bool) int {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
stats, err := svc.AgentStatsTyped(ctx, selector)
if err != nil {
fmt.Fprintf(a.err, "cluster stats: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, stats)
return 0
}
printStatsSnapshot(a.out, stats)
return 0
}
func (a *App) runClusterSoftware(svc *cluster.Service, args []string, jsonOut bool) int {
if len(args) == 0 {
fmt.Fprintln(a.err, "usage: pxmon cluster software <show|scan> [name-or-id]")
return 2
}
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "show":
selector := ""
if len(args) > 1 {
selector = args[1]
}
c, err := svc.Get(selector)
if err != nil {
fmt.Fprintf(a.err, "software show: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, c.Software)
return 0
}
detected := "never"
detectedColor := colorWarn
if !c.Software.DetectedAt.IsZero() {
detected = c.Software.DetectedAt.Format(time.RFC3339)
detectedColor = colorDim
}
summary := c.Software.Summary()
summaryColored := colorOK(summary)
if summary == "-" || summary == "" {
summaryColored = colorCrit("-")
} else if summary == "none" {
summaryColored = colorWarn(summary)
}
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Cluster: "), colorAccent(c.Name))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Detected:"), detectedColor(detected))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Support: "), summaryColored)
if len(c.Software.Versions) > 0 {
keys := make([]string, 0, len(c.Software.Versions))
for k := range c.Software.Versions {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(a.out, "%s %s\n", colorLabel(fmt.Sprintf("Version %-4s:", k)), colorInfo(c.Software.Versions[k]))
}
}
return 0
case "scan", "refresh":
fs := flag.NewFlagSet("cluster software scan", flag.ContinueOnError)
fs.SetOutput(a.err)
password := fs.String("password", "", "Password override for SSH auth")
keyPassphrase := fs.String("key-passphrase", "", "Key passphrase override for SSH auth")
keyPassphraseFile := fs.String("key-passphrase-file", "", "File containing key passphrase override for SSH auth")
selector, parseArgs := splitLeadingSelector(args[1:])
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster software scan [name-or-id]")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 {
fmt.Fprintln(a.err, "usage: pxmon cluster software scan [name-or-id]")
return 2
}
resolvedKeyPassphrase, err := readSecretValueFileFlag("key passphrase", *keyPassphrase, *keyPassphraseFile)
if err != nil {
fmt.Fprintf(a.err, "software scan: %v\n", err)
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
c, info, err := svc.SoftwareScan(ctx, selector, *password, resolvedKeyPassphrase)
if err != nil {
fmt.Fprintf(a.err, "software scan: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{
"cluster": c.Name,
"software": info,
})
return 0
}
fmt.Fprintf(a.out, "Software scan complete for %q\n", c.Name)
fmt.Fprintf(a.out, "Support: %s\n", info.Summary())
return 0
default:
fmt.Fprintf(a.err, "unknown software subcommand %q\n", args[0])
return 2
}
}
func (a *App) runClusterAlert(svc *cluster.Service, args []string, jsonOut bool) int {
if len(args) == 0 {
fmt.Fprintln(a.err, "usage: pxmon cluster alert <show|set> [name-or-id]")
return 2
}
switch args[0] {
case "show":
selector := ""
if len(args) > 1 {
selector = args[1]
}
policy, err := svc.GetAlertPolicy(selector)
if err != nil {
fmt.Fprintf(a.err, "alert show: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, policy)
return 0
}
fmt.Fprintf(a.out, "%s %s\n", colorLabel("CPU warn: "), colorWarn(fmt.Sprintf("%.1f%%", policy.CPUWarnPercent)))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("RAM warn: "), colorWarn(fmt.Sprintf("%.1f%%", policy.RAMWarnPercent)))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Swap warn:"), colorWarn(fmt.Sprintf("%.1f%%", policy.SwapWarnPercent)))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Disk warn:"), colorWarn(fmt.Sprintf("%.1f%%", policy.DiskWarnPercent)))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Net warn: "), colorWarn(fmt.Sprintf("%.1f Mbps", policy.NetWarnMbps)))
if policy.NetSustainEnabled {
iface := strings.TrimSpace(policy.NetSustainIface)
if iface == "" {
iface = "(filtered)"
}
include := "-"
exclude := "-"
if len(policy.NetSustainInclude) > 0 {
include = strings.Join(policy.NetSustainInclude, ",")
}
if len(policy.NetSustainExclude) > 0 {
exclude = strings.Join(policy.NetSustainExclude, ",")
}
fmt.Fprintf(a.out, "%s enabled (iface=%s threshold=%.1f Mbps window=%d min cooldown=%d min)\n",
colorLabel("Net sustain:"),
iface,
policy.NetSustainMbps,
policy.NetSustainMinutes,
policy.NetSustainCooldownMins,
)
fmt.Fprintf(a.out, "%s include=%s exclude=%s\n", colorLabel("Net filter: "), include, exclude)
} else {
fmt.Fprintf(a.out, "%s disabled\n", colorLabel("Net sustain:"))
}
return 0
case "set":
fs := flag.NewFlagSet("cluster alert set", flag.ContinueOnError)
fs.SetOutput(a.err)
cpuWarn := fs.Float64("cpu", 0, "CPU warning threshold percent")
ramWarn := fs.Float64("ram", 0, "RAM warning threshold percent")
swapWarn := fs.Float64("swap", 0, "Swap warning threshold percent")
diskWarn := fs.Float64("disk", 0, "Disk warning threshold percent")
netWarn := fs.Float64("net-mbps", 0, "Network warning threshold Mbps")
netSustainEnabled := fs.Bool("net-sustain-enabled", false, "Enable sustained network threshold alert")
netSustainIface := fs.String("net-sustain-iface", "", "Interface name for sustained network alert (default: auto)")
netSustainInclude := fs.String("net-sustain-include", "", "CSV substring filter: monitor only matching interfaces (e.g. net0,vm)")
netSustainExclude := fs.String("net-sustain-exclude", "", "CSV substring filter: skip matching interfaces")
netSustainMbps := fs.Float64("net-sustain-mbps", 0, "Sustained network threshold Mbps")
netSustainMins := fs.Int("net-sustain-mins", 0, "Sustained network evaluation window in minutes")
netSustainCooldown := fs.Int("net-sustain-cooldown-mins", 0, "Cooldown between sustained network notifications")
selector, parseArgs := splitLeadingSelector(args[1:])
if err := fs.Parse(parseArgs); err != nil {
return 2
}
if fs.NArg() > 0 {
if selector != "" {
fmt.Fprintln(a.err, "usage: pxmon cluster alert set [name-or-id] [--cpu ...]")
return 2
}
selector = fs.Arg(0)
}
if fs.NArg() > 1 {
fmt.Fprintln(a.err, "usage: pxmon cluster alert set [name-or-id] [--cpu ...]")
return 2
}
policy, err := svc.GetAlertPolicy(selector)
if err != nil {
fmt.Fprintf(a.err, "alert set: %v\n", err)
return 1
}
if *cpuWarn > 0 {
policy.CPUWarnPercent = *cpuWarn
}
if *ramWarn > 0 {
policy.RAMWarnPercent = *ramWarn
}
if *swapWarn > 0 {
policy.SwapWarnPercent = *swapWarn
}
if *diskWarn > 0 {
policy.DiskWarnPercent = *diskWarn
}
if *netWarn > 0 {
policy.NetWarnMbps = *netWarn
}
seenSustainEnabled := false
seenSustainFields := false
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "net-sustain-enabled":
seenSustainEnabled = true
policy.NetSustainEnabled = *netSustainEnabled
case "net-sustain-iface":
seenSustainFields = true
policy.NetSustainIface = strings.TrimSpace(*netSustainIface)
case "net-sustain-include":
seenSustainFields = true
policy.NetSustainInclude = parseTagsCSV(*netSustainInclude)
case "net-sustain-exclude":
seenSustainFields = true
policy.NetSustainExclude = parseTagsCSV(*netSustainExclude)
case "net-sustain-mbps":
seenSustainFields = true
if *netSustainMbps > 0 {
policy.NetSustainMbps = *netSustainMbps
}
case "net-sustain-mins":
seenSustainFields = true
if *netSustainMins > 0 {
policy.NetSustainMinutes = *netSustainMins
}
case "net-sustain-cooldown-mins":
seenSustainFields = true
if *netSustainCooldown > 0 {
policy.NetSustainCooldownMins = *netSustainCooldown
}
}
})
if seenSustainFields && !seenSustainEnabled {
policy.NetSustainEnabled = true
}
c, err := svc.SetAlertPolicy(selector, policy)
if err != nil {
fmt.Fprintf(a.err, "alert set: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{
"cluster": c.Name,
"policy": c.Alerts,
})
return 0
}
fmt.Fprintf(a.out, "Updated alerts for %q\n", c.Name)
fmt.Fprintf(a.out, "CPU %.1f%%, RAM %.1f%%, Swap %.1f%%, Disk %.1f%%, Net %.1f Mbps\n",
c.Alerts.CPUWarnPercent,
c.Alerts.RAMWarnPercent,
c.Alerts.SwapWarnPercent,
c.Alerts.DiskWarnPercent,
c.Alerts.NetWarnMbps,
)
if c.Alerts.NetSustainEnabled {
iface := strings.TrimSpace(c.Alerts.NetSustainIface)
if iface == "" {
iface = "(filtered)"
}
fmt.Fprintf(a.out, "Net sustain: enabled (iface=%s threshold=%.1f Mbps window=%d min cooldown=%d min)\n",
iface,
c.Alerts.NetSustainMbps,
c.Alerts.NetSustainMinutes,
c.Alerts.NetSustainCooldownMins,
)
include := "-"
exclude := "-"
if len(c.Alerts.NetSustainInclude) > 0 {
include = strings.Join(c.Alerts.NetSustainInclude, ",")
}
if len(c.Alerts.NetSustainExclude) > 0 {
exclude = strings.Join(c.Alerts.NetSustainExclude, ",")
}
fmt.Fprintf(a.out, "Net filter: include=%s exclude=%s\n", include, exclude)
} else {
fmt.Fprintln(a.out, "Net sustain: disabled")
}
return 0
default:
fmt.Fprintf(a.err, "unknown alert subcommand %q\n", args[0])
return 2
}
}
func (a *App) runLocker(args []string, configPath string, jsonOut bool) int {
store, err := cluster.NewStore(configPath)
if err != nil {
fmt.Fprintf(a.err, "init config store: %v\n", err)
return 1
}
svc := cluster.NewService(store)
if len(args) == 0 {
args = []string{"status"}
}
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "status":
cfg, err := svc.GetLocker()
if err != nil {
fmt.Fprintf(a.err, "locker status: %v\n", err)
return 1
}
locked, expiresAt, err := svc.IsLocked()
if err != nil {
fmt.Fprintf(a.err, "locker status: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{
"enabled": cfg.Enabled,
"password_set": strings.TrimSpace(cfg.PasswordHash) != "",
"locked": locked,
"session_until": expiresAt,
})
return 0
}
boolColor := func(v bool) string {
if v {
return colorOK(fmt.Sprintf("%t", v))
}
return colorCrit(fmt.Sprintf("%t", v))
}
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Enabled: "), boolColor(cfg.Enabled))
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Password set:"), boolColor(strings.TrimSpace(cfg.PasswordHash) != ""))
lockedColored := colorOK(fmt.Sprintf("%t", locked))
if locked {
lockedColored = colorWarn(fmt.Sprintf("%t", locked))
}
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Locked: "), lockedColored)
if !expiresAt.IsZero() {
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Session until:"), colorInfo(expiresAt.Format(time.RFC3339)))
}
return 0
case "set":
fs := flag.NewFlagSet("locker set", flag.ContinueOnError)
fs.SetOutput(a.err)
password := fs.String("password", "", "Locker password")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
pass := strings.TrimSpace(*password)
if pass == "" {
var readErr error
pass, readErr = readPasswordPrompt("Enter locker password: ")
if readErr != nil {
fmt.Fprintf(a.err, "locker set: %v\n", readErr)
return 1
}
}
cfg, err := svc.SetLockerPassword(pass)
if err != nil {
fmt.Fprintf(a.err, "locker set: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{
"enabled": cfg.Enabled,
"password_set": strings.TrimSpace(cfg.PasswordHash) != "",
})
return 0
}
fmt.Fprintln(a.out, "Locker password set. Locker is enabled.")
return 0
case "unlock":
fs := flag.NewFlagSet("locker unlock", flag.ContinueOnError)
fs.SetOutput(a.err)
password := fs.String("password", "", "Locker password")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
pass := strings.TrimSpace(*password)
if pass == "" {
var readErr error
pass, readErr = readPasswordPrompt("Enter locker password: ")
if readErr != nil {
fmt.Fprintf(a.err, "locker unlock: %v\n", readErr)
return 1
}
}
if err := svc.UnlockLocker(pass); err != nil {
fmt.Fprintf(a.err, "locker unlock: %v\n", err)
return 1
}
if !jsonOut {
fmt.Fprintln(a.out, "Unlocked for 6 hours.")
}
return 0
case "lock":
if err := svc.LockNow(); err != nil {
fmt.Fprintf(a.err, "locker lock: %v\n", err)
return 1
}
if !jsonOut {
fmt.Fprintln(a.out, "Locked.")
}
return 0
case "disable":
if _, err := svc.SetLockerEnabled(false); err != nil {
fmt.Fprintf(a.err, "locker disable: %v\n", err)
return 1
}
if !jsonOut {
fmt.Fprintln(a.out, "Locker disabled.")
}
return 0
case "logs":
fs := flag.NewFlagSet("locker logs", flag.ContinueOnError)
fs.SetOutput(a.err)
tail := fs.Int("tail", 200, "Number of last log lines")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintln(a.err, "usage: pxmon locker logs [--tail 200]")
return 2
}
lines, err := readLastLines(store.LockerAuditPath(), *tail)
if err != nil {
fmt.Fprintf(a.err, "locker logs: %v\n", err)
return 1
}
if jsonOut {
_ = writeJSON(a.out, map[string]any{
"path": store.LockerAuditPath(),
"lines": lines,
})
return 0
}
fmt.Fprintf(a.out, "Log file: %s\n", store.LockerAuditPath())
if len(lines) == 0 {
fmt.Fprintln(a.out, "(log is empty)")
return 0
}
for _, line := range lines {
fmt.Fprintln(a.out, line)
}
return 0
default:
fmt.Fprintln(a.err, "usage: pxmon locker <status|set|unlock|lock|disable|logs>")
return 2
}
}
func readPasswordPrompt(prompt string) (string, error) {
fmt.Fprint(os.Stdout, prompt)
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
return "", errors.New("password not provided and stdin is not a terminal")
}
raw, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stdout)
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}
func (a *App) printRootHelp() {
fmt.Fprintln(a.out, "PXmon (Phylex Monitor) - SSH cluster manager + node agent bootstrap")
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, "Usage:")
fmt.Fprintln(a.out, " pxmon [--config path] [--json] <command>")
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, "Commands:")
fmt.Fprintln(a.out, " cluster Manage SSH clusters/nodes")
fmt.Fprintln(a.out, " tui Bubble Tea realtime dashboard")
fmt.Fprintln(a.out, " clusters Bubble Tea cluster-overview dashboard")
fmt.Fprintln(a.out, " network Bubble Tea network-focused dashboard")
fmt.Fprintln(a.out, " bot Bot integrations (Telegram)")
fmt.Fprintln(a.out, " locker Global CLI/TUI lock controls")
fmt.Fprintln(a.out, " config Export/import the full registry (clusters + settings)")
fmt.Fprintln(a.out, " explain Fast command discovery with text filter")
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, "Tip: run `pxmon` without arguments in a real terminal to open TUI directly.")
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, "Run 'pxmon cluster help' for cluster subcommands.")
}
func (a *App) printClusterHelp() {
fmt.Fprintln(a.out, "pxmon cluster commands:")
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, " connect Register SSH node")
fmt.Fprintln(a.out, " list List nodes")
fmt.Fprintln(a.out, " current Show active node")
fmt.Fprintln(a.out, " show Show node details")
fmt.Fprintln(a.out, " use Set active node")
fmt.Fprintln(a.out, " ping Check SSH or agent connectivity")
fmt.Fprintln(a.out, " bootstrap Build and install pxmon-agent over SSH")
fmt.Fprintln(a.out, " agent Agent operations (status/update/upgrade)")
fmt.Fprintln(a.out, " stats Interactive realtime monitor (or --once)")
fmt.Fprintln(a.out, " p95 P95 usage for specific interface and period")
fmt.Fprintln(a.out, " alert Show/set alert thresholds")
fmt.Fprintln(a.out, " alert-routing Show/set alert delivery routing")
fmt.Fprintln(a.out, " alert-vm Show/set/check VM state alert rules")
fmt.Fprintln(a.out, " tag Add/remove/list cluster tags")
fmt.Fprintln(a.out, " kvm-tag Add/remove/list tags for specific KVM VM")
fmt.Fprintln(a.out, " drift Detect config/runtime drift")
fmt.Fprintln(a.out, " capacity Capacity forecast from disk usage history")
fmt.Fprintln(a.out, " slo Availability SLO report for cluster/VM")
fmt.Fprintln(a.out, " report Export cluster report (json/csv)")
fmt.Fprintln(a.out, " backup Archive selected paths and upload to SFTP/S3")
fmt.Fprintln(a.out, " repo-tunnel Configure temporary package repo access through a gateway VM")
fmt.Fprintln(a.out, " runbook List/show/run step-by-step scenarios")
fmt.Fprintln(a.out, " runbook-trigger Configure auto-triggered runbook on alerts")
fmt.Fprintln(a.out, " schedule Manage and run scheduled tasks")
fmt.Fprintln(a.out, " change-history Show applied change log")
fmt.Fprintln(a.out, " software Show/scan software plugin support (bird/frr/kvm/lxc/lxd)")
fmt.Fprintln(a.out, " disconnect Remove node")
fmt.Fprintln(a.out, " set-auth Change stored password or auth method (aliases: auth, password, passwd)")
fmt.Fprintln(a.out, " openssh Open interactive SSH shell to node (alias: ssh)")
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, "Examples:")
fmt.Fprintln(a.out, " pxmon cluster connect --name eu-1 --host 10.0.0.10 --port 22 --user root --auth key --key-path ~/.ssh/id_ed25519 --key-passphrase-file ~/.ssh/id_ed25519.pxmonpassphrase")
fmt.Fprintln(a.out, " pxmon cluster connect --name vm-host-3 --host 198.51.100.30 --user root --type ipfabric --auth password --password 'example-pass' --store-password --insecure-host-key")
fmt.Fprintln(a.out, " pxmon cluster set-auth eu-1 --auth password --password 'example-pass' --store-password")
fmt.Fprintln(a.out, " pxmon cluster set-auth eu-1 --key-path ~/.ssh/id_ed25519 --key-passphrase-file ~/Desktop/passphrase.txt --store-key-passphrase")
fmt.Fprintln(a.out, " pxmon cluster set-auth vm-host-3 --type ipfabric")
fmt.Fprintln(a.out, " pxmon cluster bootstrap eu-1")
fmt.Fprintln(a.out, " pxmon cluster agent status")
fmt.Fprintln(a.out, " pxmon cluster agent update eu-1")
fmt.Fprintln(a.out, " pxmon cluster agent update eu-1 --restart-bot=true")
fmt.Fprintln(a.out, " pxmon cluster ping eu-1")
fmt.Fprintln(a.out, " pxmon cluster ping eu-1 --agent")
fmt.Fprintln(a.out, " pxmon cluster stats eu-1")
fmt.Fprintln(a.out, " pxmon cluster p95 eu-1 --iface eth0 --range 30d --graph")
fmt.Fprintln(a.out, " pxmon cluster alert set eu-1 --net-mbps 300 --ram 90 --disk 90")
fmt.Fprintln(a.out, " pxmon cluster alert set eu-1 --net-sustain-enabled=true --net-sustain-mbps 500 --net-sustain-mins 60 --net-sustain-include net0 --net-sustain-exclude backup")
fmt.Fprintln(a.out, " pxmon cluster alert-routing set eu-1 --critical-immediate=true --warning-batch-mins 5")
fmt.Fprintln(a.out, " pxmon cluster alert-vm set eu-1 --enabled --warn-on-shutoff --min-running 100")
fmt.Fprintln(a.out, " pxmon cluster slo eu-1 --range 30d")
fmt.Fprintln(a.out, " pxmon cluster capacity forecast eu-1 --range 30d")
fmt.Fprintln(a.out, " pxmon cluster tag add eu-1 --tags prod,billing")
fmt.Fprintln(a.out, " pxmon cluster kvm-tag add eu-1 --vm vm123 --tags critical")
fmt.Fprintln(a.out, " pxmon cluster drift eu-1")
fmt.Fprintln(a.out, " pxmon cluster drift baseline set eu-1")
fmt.Fprintln(a.out, " pxmon cluster drift ack eu-1 --kind baseline_software --for 24h")
fmt.Fprintln(a.out, " pxmon cluster report export --format json --out ./cluster-report.json")
fmt.Fprintln(a.out, " pxmon cluster backup target add --name b2 --type s3 --s3-endpoint s3.example.net --s3-bucket backups --s3-access-key AKIA... --s3-secret-key ...")
fmt.Fprintln(a.out, " pxmon cluster backup target test b2")
fmt.Fprintln(a.out, " pxmon cluster backup plan add --name vm-images --cluster eu-1 --target b2 --path /var/lib/libvirt/images --every 6h")
fmt.Fprintln(a.out, " pxmon cluster backup run vm-images")
fmt.Fprintln(a.out, " pxmon cluster repo-tunnel install eu-1 --gateway 203.0.113.10:3128 --table 1010 -- dnf install -y curl jq")
fmt.Fprintln(a.out, " pxmon cluster runbook run vm-health-check")
fmt.Fprintln(a.out, " pxmon cluster runbook add --edit")
fmt.Fprintln(a.out, " pxmon cluster runbook-trigger set eu-1 --enabled --runbook-id vm-health-check --cooldown-mins 30")
fmt.Fprintln(a.out, " pxmon cluster runbook add --id custom-1 --name 'Custom' --step 'Check agent|cluster agent status' --step 'Drift|cluster drift'")
fmt.Fprintln(a.out, " pxmon cluster schedule add --edit")
fmt.Fprintln(a.out, " pxmon cluster schedule add --name audit --cmd 'cluster drift eu-1' --every 30m")
fmt.Fprintln(a.out, " pxmon cluster schedule add --name mk --cluster eu-1 --mode shell --cmd 'mkdir -p /tmp/test' --every 10m")
fmt.Fprintln(a.out, " pxmon cluster schedule start --interval 30s")
fmt.Fprintln(a.out, " pxmon cluster software scan eu-1")
fmt.Fprintln(a.out, " pxmon cluster openssh eu-1")
}
func (a *App) runConfig(args []string, configPath string) int {
if len(args) == 0 {
a.printConfigHelp()
return 0
}
sub := args[0]
rest := args[1:]
switch sub {
case "export":
return a.runConfigExport(rest, configPath)
case "import":
return a.runConfigImport(rest, configPath)
case "help", "--help", "-h":
a.printConfigHelp()
return 0
default:
fmt.Fprintf(a.err, "unknown config command %q\n\n", sub)
a.printConfigHelp()
return 2
}
}
func (a *App) runConfigExport(args []string, configPath string) int {
fs := flag.NewFlagSet("config export", flag.ContinueOnError)
fs.SetOutput(a.err)
password := fs.String("password", "", "Passphrase used to encrypt the export bundle")
out := fs.String("out", "", "Output file path (defaults to <arg> or pxmon-export.enc)")
if err := fs.Parse(args); err != nil {
return 2
}
outPath := strings.TrimSpace(*out)
if outPath == "" && fs.NArg() > 0 {
outPath = strings.TrimSpace(fs.Arg(0))
}
if outPath == "" {
outPath = "pxmon-export.enc"
}
pw := strings.TrimSpace(*password)
if pw == "" {
if !a.canPromptInteractive() {
fmt.Fprintln(a.err, "export: --password is required when running from TUI/non-interactive session")
return 2
}
entered, err := promptPassword(a.out, "Export passphrase: ")
if err != nil {
fmt.Fprintf(a.err, "export: %v\n", err)
return 1
}
confirm, err := promptPassword(a.out, "Confirm passphrase: ")
if err != nil {
fmt.Fprintf(a.err, "export: %v\n", err)
return 1
}
if entered != confirm {
fmt.Fprintln(a.err, "export: passphrases do not match")
return 1
}
pw = entered
}
if pw == "" {
fmt.Fprintln(a.err, "export: empty passphrase is not allowed")
return 1
}
store, err := cluster.NewStore(configPath)
if err != nil {
fmt.Fprintf(a.err, "init config store: %v\n", err)
return 1
}
svc := cluster.NewService(store)
if err := svc.Export(outPath, pw); err != nil {
fmt.Fprintf(a.err, "export: %v\n", err)
return 1
}
abs, _ := filepath.Abs(outPath)
fmt.Fprintf(a.out, "%s %s\n",
colorLabel("Exported bundle:"),
colorAccent(abs),
)
fmt.Fprintln(a.out, colorDim("keep the passphrase safe — without it the bundle cannot be restored"))
return 0
}
func (a *App) runConfigImport(args []string, configPath string) int {
fs := flag.NewFlagSet("config import", flag.ContinueOnError)
fs.SetOutput(a.err)
password := fs.String("password", "", "Passphrase used when the bundle was exported")
in := fs.String("in", "", "Input bundle path")
replace := fs.Bool("replace", false, "Wipe the local registry and replace it with the bundle")
if err := fs.Parse(args); err != nil {
return 2
}
inPath := strings.TrimSpace(*in)
if inPath == "" && fs.NArg() > 0 {
inPath = strings.TrimSpace(fs.Arg(0))
}
if inPath == "" {
fmt.Fprintln(a.err, "usage: pxmon config import <path> [--password <pw>] [--replace]")
return 2
}
pw := strings.TrimSpace(*password)
if pw == "" {
if !a.canPromptInteractive() {
fmt.Fprintln(a.err, "import: --password is required when running from TUI/non-interactive session")
return 2
}
entered, err := promptPassword(a.out, "Import passphrase: ")
if err != nil {
fmt.Fprintf(a.err, "import: %v\n", err)
return 1
}
pw = entered
}
if pw == "" {
fmt.Fprintln(a.err, "import: empty passphrase is not allowed")
return 1
}
store, err := cluster.NewStore(configPath)
if err != nil {
fmt.Fprintf(a.err, "init config store: %v\n", err)
return 1
}
svc := cluster.NewService(store)
mode := cluster.ImportModeMerge
if *replace {
mode = cluster.ImportModeReplace
}
report, err := svc.Import(inPath, pw, mode)
if err != nil {
fmt.Fprintf(a.err, "import: %v\n", err)
return 1
}
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Import mode:"), colorValue(string(report.Mode)))
fmt.Fprintf(a.out, "%s %s %s %s %s %s\n",
colorLabel("added:"), colorValue(fmt.Sprintf("%d", report.Added)),
colorLabel("replaced:"), colorValue(fmt.Sprintf("%d", report.Replaced)),
colorLabel("total:"), colorValue(fmt.Sprintf("%d", report.TotalAfter)),
)
if report.TelegramApplied {
fmt.Fprintln(a.out, colorLabel("telegram:")+" "+colorValue("applied"))
}
if report.LockerApplied {
fmt.Fprintln(a.out, colorLabel("locker:")+" "+colorValue("applied"))
}
return 0
}
func (a *App) printConfigHelp() {
fmt.Fprintln(a.out, "Usage: pxmon config <command> [options]")
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, "Commands:")
fmt.Fprintln(a.out, " export Save all clusters, settings and credentials to a passphrase-encrypted bundle")
fmt.Fprintln(a.out, " import Load a previously exported bundle (merge by default, --replace to wipe first)")
fmt.Fprintln(a.out)
fmt.Fprintln(a.out, "Examples:")
fmt.Fprintln(a.out, " pxmon config export ./backup.enc")
fmt.Fprintln(a.out, " pxmon config export --out ~/Desktop/pxmon.enc --password 'example-passphrase'")
fmt.Fprintln(a.out, " pxmon config import ./backup.enc")
fmt.Fprintln(a.out, " pxmon config import ./backup.enc --replace")
}
// canPromptInteractive reports whether the current App instance can safely
// read a password from the user. When running inside the TUI console, the
// App is constructed with buffered out/err writers, so prompting would
// deadlock on os.Stdin that bubbletea already owns.
func (a *App) canPromptInteractive() bool {
if a == nil {
return false
}
if a.out != os.Stdout {
return false
}
return term.IsTerminal(int(os.Stdin.Fd()))
}
func promptPassword(out io.Writer, prompt string) (string, error) {
fmt.Fprint(out, prompt)
stdinFd := int(os.Stdin.Fd())
if term.IsTerminal(stdinFd) {
b, err := term.ReadPassword(stdinFd)
fmt.Fprintln(out)
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
// Non-interactive fallback: read a line from stdin.
buf := make([]byte, 4096)
n, err := os.Stdin.Read(buf)
if err != nil && n == 0 {
return "", err
}
return strings.TrimSpace(string(buf[:n])), nil
}
func sanitizeCluster(c cluster.Cluster, revealSecrets bool) cluster.Cluster {
if revealSecrets {
return c
}
c.Password = maskSecret(c.Password)
c.KeyPassphrase = maskSecret(c.KeyPassphrase)
c.Agent.Token = maskSecret(c.Agent.Token)
c.Agent.RequestSecret = maskSecret(c.Agent.RequestSecret)
return c
}
func printableSecret(v string) string {
if v == "" {
return "(empty)"
}
return v
}
func emptyFallback(v, fallback string) string {
if strings.TrimSpace(v) == "" {
return fallback
}
return v
}
func writeJSON(w io.Writer, v any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(v)
}
func maskSecret(secret string) string {
if secret == "" {
return ""
}
return "***"
}
func ternary(cond bool, a, b string) string {
if cond {
return a
}
return b
}
func splitLeadingSelector(args []string) (string, []string) {
if len(args) == 0 {
return "", args
}
first := strings.TrimSpace(args[0])
if first == "" || strings.HasPrefix(first, "-") {
return "", args
}
return first, args[1:]
}
func useANSIColor() bool {
if strings.TrimSpace(os.Getenv("NO_COLOR")) != "" {
return false
}
termName := strings.ToLower(strings.TrimSpace(os.Getenv("TERM")))
if termName == "dumb" {
return false
}
return true
}
const (
ansiReset = "\x1b[0m"
ansiBold = "\x1b[1m"
ansiDim = "\x1b[2m"
ansiGreen = "\x1b[32m"
ansiYellow = "\x1b[33m"
ansiRed = "\x1b[31m"
ansiCyan = "\x1b[36m"
ansiBlue = "\x1b[34m"
ansiMagenta = "\x1b[35m"
ansiBrightBlack = "\x1b[90m"
ansiBrightRed = "\x1b[91m"
ansiBrightGreen = "\x1b[92m"
ansiBrightYellow = "\x1b[93m"
ansiBrightBlue = "\x1b[94m"
ansiBrightMagenta = "\x1b[95m"
ansiBrightCyan = "\x1b[96m"
ansiBrightWhite = "\x1b[97m"
ansiBoldCyan = "\x1b[1;36m"
ansiBoldBCyan = "\x1b[1;96m"
ansiBoldBMagenta = "\x1b[1;95m"
ansiBoldBGreen = "\x1b[1;92m"
ansiBoldBYellow = "\x1b[1;93m"
ansiBoldBRed = "\x1b[1;91m"
ansiBoldBBlue = "\x1b[1;94m"
ansiBoldWhite = "\x1b[1;97m"
)
func colorWrap(code, v string) string {
if !useANSIColor() || v == "" {
return v
}
return code + v + ansiReset
}
func colorOK(v string) string { return colorWrap(ansiBoldBGreen, v) }
func colorWarn(v string) string { return colorWrap(ansiBoldBYellow, v) }
func colorCrit(v string) string { return colorWrap(ansiBoldBRed, v) }
func colorHeader(v string) string { return colorWrap(ansiBoldBCyan, v) }
func colorLabel(v string) string { return colorWrap(ansiBoldCyan, v) }
func colorAccent(v string) string { return colorWrap(ansiBoldBMagenta, v) }
func colorInfo(v string) string { return colorWrap(ansiCyan, v) }
func colorBlue(v string) string { return colorWrap(ansiBrightBlue, v) }
func colorMagenta(v string) string { return colorWrap(ansiBrightMagenta, v) }
func colorDim(v string) string { return colorWrap(ansiDim, v) }
func colorMuted(v string) string { return colorWrap(ansiBrightBlack, v) }
func colorValue(v string) string { return colorWrap(ansiBoldWhite, v) }
func shortVersion(v string) string {
s := strings.TrimSpace(v)
if s == "" {
return "unknown"
}
if len(s) > 12 {
return s[:12]
}
return s
}
func fitCell(v string, width int) string {
if width <= 0 {
return ""
}
r := []rune(strings.TrimSpace(v))
if len(r) > width {
if width <= 3 {
return string(r[:width])
}
return string(r[:width-3]) + "..."
}
if len(r) < width {
return string(r) + strings.Repeat(" ", width-len(r))
}
return string(r)
}
func colorizeCell(v string, width int, color string) string {
padded := fitCell(v, width)
switch color {
case "ok":
return colorOK(padded)
case "warn":
return colorWarn(padded)
case "crit":
return colorCrit(padded)
case "header":
return colorHeader(padded)
case "label":
return colorLabel(padded)
case "accent":
return colorAccent(padded)
case "info":
return colorInfo(padded)
case "blue":
return colorBlue(padded)
case "magenta":
return colorMagenta(padded)
case "dim":
return colorDim(padded)
case "muted":
return colorMuted(padded)
case "value":
return colorValue(padded)
default:
return padded
}
}
func clamp(v, minV, maxV int) int {
if v < minV {
return minV
}
if v > maxV {
return maxV
}
return v
}
func shrinkOne(v *int, minV int) bool {
if v == nil || *v <= minV {
return false
}
*v = *v - 1
return true
}
func maxLen[T any](header string, pick func(T) string, rows []T) int {
maxV := len([]rune(strings.TrimSpace(header)))
for _, r := range rows {
n := len([]rune(strings.TrimSpace(pick(r))))
if n > maxV {
maxV = n
}
}
return maxV
}
func Main() {
app := New(os.Stdout, os.Stderr)
os.Exit(app.Run(os.Args[1:]))
}