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 ") 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 ") 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 [flags]") return 2 } selector = fs.Arg(0) } if strings.TrimSpace(selector) == "" { fmt.Fprintln(a.err, "usage: pxmon cluster set-auth [flags]") return 2 } if fs.NArg() > 1 { fmt.Fprintln(a.err, "usage: pxmon cluster set-auth [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 ` 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 -- ") return 2 } if len(rest) > 0 && rest[0] == "--" { rest = rest[1:] } if len(rest) == 0 { fmt.Fprintln(a.err, "usage: pxmon cluster exec -- ") 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 [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 [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 --gateway --table ") return 2 } selector = fs.Arg(0) } if fs.NArg() > 1 || strings.TrimSpace(selector) == "" { fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel enable --gateway --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 --gateway --table
") return 2 } selector = fs.Arg(0) } if fs.NArg() > 1 || strings.TrimSpace(selector) == "" { fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel disable --gateway --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 ") return 2 } selector = fs.Arg(0) } if fs.NArg() > 1 || strings.TrimSpace(selector) == "" { fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel status ") 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 --gateway --table
-- ") 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 --gateway --table
-- ") 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 --allow ") return 2 } selector = fs.Arg(0) } if fs.NArg() > 1 || strings.TrimSpace(selector) == "" { fmt.Fprintln(a.err, "usage: pxmon cluster repo-tunnel gateway-setup --allow ") 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 [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 [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 ") 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] ") 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 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 [--password ] [--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 [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:])) }