2970 lines
94 KiB
Go
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:]))
|
|
}
|