From 114f4ee06d454864d15fb69e4604b0b11cc1eb3b Mon Sep 17 00:00:00 2001 From: Christopher Jones Date: Sat, 21 Feb 2026 22:15:20 -0500 Subject: [PATCH] feat(host): add prune and rename subcommands host prune: DNS-resolve all configured hosts, detect NXDOMAIN/timeout entries and in-file duplicates, present interactive multi-select for removal with backup before writes. host rename: detect CNAME changes across hosts, update Host alias and HostName to match the new canonical name while keeping the old alias. Merges into existing entries when the target host already exists. Shared DNS resolution in dns.go uses a 20-worker concurrent pool with retry on timeout. Includes deduplication and duplicate-detection helpers. --- cmd/nssh/main.go | 2 + docs/examples/help/host.txt | 2 + docs/examples/help/host/prune.txt | 10 + docs/examples/help/host/rename.txt | 10 + internal/cli/host/dns.go | 221 +++++++++++++++++ internal/cli/host/prune.go | 219 +++++++++++++++++ internal/cli/host/rename.go | 378 +++++++++++++++++++++++++++++ 7 files changed, 842 insertions(+) create mode 100644 docs/examples/help/host/prune.txt create mode 100644 docs/examples/help/host/rename.txt create mode 100644 internal/cli/host/dns.go create mode 100644 internal/cli/host/prune.go create mode 100644 internal/cli/host/rename.go diff --git a/cmd/nssh/main.go b/cmd/nssh/main.go index f7eb923..d205ac0 100644 --- a/cmd/nssh/main.go +++ b/cmd/nssh/main.go @@ -748,6 +748,8 @@ func newHostCmd() *cobra.Command { cmd.AddCommand(host.NewEditCmd()) cmd.AddCommand(host.NewRemoveCmd()) cmd.AddCommand(host.NewSortCmd()) + cmd.AddCommand(host.NewPruneCmd()) + cmd.AddCommand(host.NewRenameCmd()) ui.ApplyStyledHelpRecursive(cmd) return cmd diff --git a/docs/examples/help/host.txt b/docs/examples/help/host.txt index d05c7dc..c69d412 100644 --- a/docs/examples/help/host.txt +++ b/docs/examples/help/host.txt @@ -7,6 +7,8 @@ $ nssh host --help │ edit Edit host settings │ │ get Show host details │ │ list List all hosts │ +│ prune Remove stale and duplicate hosts │ +│ rename Rename hosts with CNAME changes │ │ rm Remove hosts from config │ │ sort Sort hosts in config │ ╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/docs/examples/help/host/prune.txt b/docs/examples/help/host/prune.txt new file mode 100644 index 0000000..fa6f92b --- /dev/null +++ b/docs/examples/help/host/prune.txt @@ -0,0 +1,10 @@ +$ nssh host prune --help +╭─ Usage ──────────────────────────────────────────────────────────────────────╮ +│ nssh host prune [flags] Remove stale and duplicate hosts │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Global Options ─────────────────────────────────────────────────────────────╮ +│ -e, --explain Print command explanation │ +│ -h, --help Print command help │ +│ -v, --verbose Print debug messages │ +│ -V, --version Print command version │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/docs/examples/help/host/rename.txt b/docs/examples/help/host/rename.txt new file mode 100644 index 0000000..7c95528 --- /dev/null +++ b/docs/examples/help/host/rename.txt @@ -0,0 +1,10 @@ +$ nssh host rename --help +╭─ Usage ──────────────────────────────────────────────────────────────────────╮ +│ nssh host rename [flags] Rename hosts with CNAME changes │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Global Options ─────────────────────────────────────────────────────────────╮ +│ -e, --explain Print command explanation │ +│ -h, --help Print command help │ +│ -v, --verbose Print debug messages │ +│ -V, --version Print command version │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/cli/host/dns.go b/internal/cli/host/dns.go new file mode 100644 index 0000000..b19f7d9 --- /dev/null +++ b/internal/cli/host/dns.go @@ -0,0 +1,221 @@ +package host + +import ( + "context" + "errors" + "net" + "strings" + "sync" + "time" + + "github.com/ntwrknrd/nssh/internal/ssh/sshconfig" +) + +// dnsResult holds the DNS resolution result for a single host. +type dnsResult struct { + host *sshconfig.HostEntry + target string // what we resolved (HostName or Host) + status string // "ok", "nxdomain", "cname", "timeout", "error", "skip" + detail string // CNAME target, error message, or skip reason +} + +// resolveAllHosts resolves DNS for all hosts concurrently. +// Returns results in the same order as input. +func resolveAllHosts(hosts []*sshconfig.HostEntry) []dnsResult { + results := make([]dnsResult, len(hosts)) + + var wg sync.WaitGroup + sem := make(chan struct{}, 20) // 20-worker pool + + for i, h := range hosts { + results[i].host = h + + // Determine what to resolve + target := h.Host + if h.HostName != "" && h.HostName != h.Host { + target = h.HostName + } + results[i].target = target + + // Skip wildcards + if strings.ContainsAny(target, "*?") { + results[i].status = "skip" + results[i].detail = "wildcard pattern" + continue + } + + // Skip IP addresses + if net.ParseIP(target) != nil { + results[i].status = "skip" + results[i].detail = "IP address" + continue + } + + wg.Add(1) + sem <- struct{}{} + go func(idx int, t string) { + defer wg.Done() + defer func() { <-sem }() + results[idx].status, results[idx].detail = resolveOne(t) + }(i, target) + } + + wg.Wait() + return results +} + +// resolveOne performs DNS lookup for a single target with retry on timeout. +// After retries exhaust, persistent timeouts are promoted to "timeout" status +// so they can be surfaced as prune candidates separately from real errors. +func resolveOne(target string) (string, string) { + const maxAttempts = 3 + + for attempt := range maxAttempts { + status, detail := resolveOnce(target) + if status != "error" { + return status, detail + } + // Only retry on timeout errors + if !strings.Contains(detail, "i/o timeout") { + return status, detail + } + if attempt == maxAttempts-1 { + return "timeout", "" + } + time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond) + } + return "timeout", "" // should not happen +} + +func resolveOnce(target string) (string, string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := net.DefaultResolver.LookupHost(ctx, target) + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return "nxdomain", "" + } + return "error", err.Error() + } + + // Check for CNAME + cname, err := net.LookupCNAME(target) + if err == nil { + cname = strings.TrimSuffix(cname, ".") + if !strings.EqualFold(cname, target) { + return "cname", cname + } + } + + return "ok", "" +} + +// dnsSummary holds counts from DNS resolution. +type dnsSummary struct { + ok int + nxdomain int + cname int + timeout int + skip int + errCount int +} + +// deduplicateHosts removes duplicate host entries (same Host name). +// Keeps the first occurrence of each. +func deduplicateHosts(hosts []*sshconfig.HostEntry) []*sshconfig.HostEntry { + seen := make(map[string]bool) + result := make([]*sshconfig.HostEntry, 0, len(hosts)) + for _, h := range hosts { + key := strings.ToLower(h.Host) + if seen[key] { + continue + } + seen[key] = true + result = append(result, h) + } + return result +} + +// findDuplicatesInFile returns host entries that share a Host name +// within the same source file. For each set of N duplicates, returns +// the last N-1 entries (keeping the first as the canonical one). +func findDuplicatesInFile(hosts []*sshconfig.HostEntry) []*sshconfig.HostEntry { + // key: lowercase(host) + "\x00" + sourceFile + type entry struct { + count int + hosts []*sshconfig.HostEntry + } + seen := make(map[string]*entry) + + for _, h := range hosts { + key := strings.ToLower(h.Host) + "\x00" + h.SourceFile + e, ok := seen[key] + if !ok { + e = &entry{} + seen[key] = e + } + e.count++ + e.hosts = append(e.hosts, h) + } + + var dupes []*sshconfig.HostEntry + for _, e := range seen { + if e.count > 1 { + // Skip the first (canonical), return the rest as duplicates + dupes = append(dupes, e.hosts[1:]...) + } + } + return dupes +} + +// removeHostByLines removes exactly one host entry from the list by matching +// its raw Lines slice. This allows removing a specific duplicate without +// removing all entries that share the same Host name. +func removeHostByLines(hosts []*sshconfig.HostEntry, lines []string) []*sshconfig.HostEntry { + removed := false + result := make([]*sshconfig.HostEntry, 0, len(hosts)) + for _, h := range hosts { + if !removed && linesEqual(h.Lines, lines) { + removed = true + continue + } + result = append(result, h) + } + return result +} + +func linesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// summarizeDNS counts results by status. +func summarizeDNS(results []dnsResult) dnsSummary { + var s dnsSummary + for _, r := range results { + switch r.status { + case "ok": + s.ok++ + case "nxdomain": + s.nxdomain++ + case "cname": + s.cname++ + case "timeout": + s.timeout++ + case "skip": + s.skip++ + case "error": + s.errCount++ + } + } + return s +} diff --git a/internal/cli/host/prune.go b/internal/cli/host/prune.go new file mode 100644 index 0000000..523087c --- /dev/null +++ b/internal/cli/host/prune.go @@ -0,0 +1,219 @@ +package host + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/ntwrknrd/nssh/internal/exit" + "github.com/ntwrknrd/nssh/internal/ssh/sshconfig" + "github.com/ntwrknrd/nssh/internal/ui" + "github.com/spf13/cobra" +) + +// NewPruneCmd creates the host prune command. +func NewPruneCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "prune", + Short: "Remove stale and duplicate hosts", + Long: `Detect hosts whose DNS names no longer resolve (NXDOMAIN) or that +appear as duplicates within the same config file, and remove them. + +Runs DNS lookups against all configured hosts, then presents an +interactive selection of stale and duplicate entries to remove.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runPrune() + }, + } + + return cmd +} + +// pruneCandidate is a host flagged for potential removal. +type pruneCandidate struct { + host *sshconfig.HostEntry + reason string // "NXDOMAIN" or "duplicate" +} + +func runPrune() error { + parser := getParser() + + ui.CommandStart("PRUNE HOSTS") + + // Build context mapping upfront (may trigger vault unlock prompt) + fileToContext := buildFileToContext() + + hosts, err := parser.GetAllHosts() + if err != nil { + ui.Error("Failed to get hosts: %s", err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + + if len(hosts) == 0 { + ui.Warning("No hosts configured") + ui.CommandEnd(ui.StatusWarning) + return nil + } + + // Find duplicates within the same file before deduplicating for DNS + dupes := findDuplicatesInFile(hosts) + unique := deduplicateHosts(hosts) + + fmt.Printf(" Checking %d hosts...\n", len(unique)) + fmt.Println() + + results := resolveAllHosts(unique) + summary := summarizeDNS(results) + + // Show summary counts (include duplicate count) + printPruneSummary(summary, len(dupes)) + + // Show error details if any + printDNSErrors(results) + + // Build candidate list: NXDOMAIN + timeout + duplicates + var candidates []pruneCandidate + for _, r := range results { + switch r.status { + case "nxdomain": + candidates = append(candidates, pruneCandidate{host: r.host, reason: "NXDOMAIN"}) + case "timeout": + candidates = append(candidates, pruneCandidate{host: r.host, reason: "timeout"}) + } + } + for _, d := range dupes { + candidates = append(candidates, pruneCandidate{host: d, reason: "duplicate"}) + } + + if len(candidates) == 0 { + fmt.Println() + ui.Info("No stale or duplicate hosts detected") + ui.CommandEnd(ui.StatusNoop) + return nil + } + + // Display table + fmt.Println() + tbl := ui.NewTable("Host", "HostName", "Context", "Reason") + for _, c := range candidates { + ctx := contextForHost(c.host, fileToContext) + hostname := c.host.HostName + if hostname == "" || hostname == c.host.Host { + hostname = "-" + } + tbl.AddRow(c.host.Host, hostname, ctx, c.reason) + } + tbl.Render() + fmt.Println() + + // Multi-select + options := make([]ui.FuzzySelectOption, len(candidates)) + for i, c := range candidates { + options[i] = ui.FuzzySelectOption{ + Label: fmt.Sprintf("%s (%s)", c.host.Host, c.reason), + Value: c.host.Host, + } + } + + indices, err := ui.FuzzySelectMulti("Select hosts to remove", options) + if err != nil { + ui.Abort("Selection failed: %s", err) + ui.CommandEnd(ui.StatusAbort) + return nil + } + if len(indices) == 0 { + ui.Abort("No hosts selected") + ui.CommandEnd(ui.StatusAbort) + return nil + } + + // Group selected hosts by source file, tracking reason + type removal struct { + host *sshconfig.HostEntry + reason string + } + byFile := make(map[string][]removal) + for _, idx := range indices { + c := candidates[idx] + byFile[c.host.SourceFile] = append(byFile[c.host.SourceFile], removal{host: c.host, reason: c.reason}) + } + + // Process each file + ui.SubSection("Removing") + + backedUp := make(map[string]bool) + for path, removals := range byFile { + cfg, err := parser.ParseFile(path) + if err != nil { + for _, rm := range removals { + ui.Error("%s: parse failed: %s", rm.host.Host, err) + } + continue + } + + // Backup once per file + if !backedUp[path] { + if err := createBackup(path, getBackupDir(), getMaxBackups()); err != nil { + ui.Warning("Backup failed for %s: %v", abbreviateHome(path), err) + } + backedUp[path] = true + } + + // Remove hosts + for _, rm := range removals { + if rm.reason == "duplicate" { + // Remove only the duplicate entry, not all copies. + // Match by line number of the Host directive. + cfg.Hosts = removeHostByLines(cfg.Hosts, rm.host.Lines) + } else { + cfg.Hosts = sshconfig.RemoveHost(cfg.Hosts, rm.host.Host) + } + ui.Success("%s", rm.host.Host) + } + + // Write updated config + if err := parser.WriteFile(cfg); err != nil { + ui.Error("Failed to write %s: %s", abbreviateHome(path), err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + + // Clean up empty context + if len(cfg.Hosts) == 0 { + cleanupEmptyContext(path, map[string]string{ + filepath.Base(path): fileToContext[filepath.Base(path)], + }) + } + } + + ui.CommandEnd(ui.StatusSuccess) + return nil +} + +// printPruneSummary prints the DNS summary plus duplicate count. +func printPruneSummary(s dnsSummary, dupeCount int) { + var parts []string + if s.ok > 0 { + parts = append(parts, fmt.Sprintf("[*] %d resolved", s.ok)) + } + if s.nxdomain > 0 { + parts = append(parts, fmt.Sprintf("[!] %d NXDOMAIN", s.nxdomain)) + } + if s.cname > 0 { + parts = append(parts, fmt.Sprintf("[*] %d renamed (CNAME)", s.cname)) + } + if s.timeout > 0 { + parts = append(parts, fmt.Sprintf("[!] %d timeout", s.timeout)) + } + if dupeCount > 0 { + parts = append(parts, fmt.Sprintf("[!] %d duplicates", dupeCount)) + } + if s.skip > 0 { + parts = append(parts, fmt.Sprintf("[*] %d skipped", s.skip)) + } + if s.errCount > 0 { + parts = append(parts, fmt.Sprintf("[!] %d errors", s.errCount)) + } + fmt.Printf(" %s\n", strings.Join(parts, " ")) +} diff --git a/internal/cli/host/rename.go b/internal/cli/host/rename.go new file mode 100644 index 0000000..d97c065 --- /dev/null +++ b/internal/cli/host/rename.go @@ -0,0 +1,378 @@ +package host + +import ( + "fmt" + "path/filepath" + "strings" + + clisession "github.com/ntwrknrd/nssh/internal/cli/session" + "github.com/ntwrknrd/nssh/internal/exit" + "github.com/ntwrknrd/nssh/internal/ssh/sshconfig" + "github.com/ntwrknrd/nssh/internal/ui" + "github.com/ntwrknrd/nssh/internal/vault" + "github.com/spf13/cobra" +) + +// NewRenameCmd creates the host rename command. +func NewRenameCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "rename", + Short: "Rename hosts with CNAME changes", + Long: `Detect hosts whose DNS names have changed via CNAME records and +update the SSH config to match. + +For each renamed host, the old Host alias is kept so existing scripts +and muscle memory continue to work. When the target host already exists, +the old entry is merged as an alias on the existing entry.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runRename() + }, + } + + return cmd +} + +func runRename() error { + parser := getParser() + + ui.CommandStart("RENAME HOSTS") + + // Build context mapping upfront (may trigger vault unlock prompt) + _ = buildFileToContext() + + hosts, err := parser.GetAllHosts() + if err != nil { + ui.Error("Failed to get hosts: %s", err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + + if len(hosts) == 0 { + ui.Warning("No hosts configured") + ui.CommandEnd(ui.StatusWarning) + return nil + } + + hosts = deduplicateHosts(hosts) + + fmt.Printf(" Checking %d hosts...\n", len(hosts)) + fmt.Println() + + results := resolveAllHosts(hosts) + summary := summarizeDNS(results) + + // Show summary counts + printDNSSummary(summary) + + // Show error details if any + printDNSErrors(results) + + // Filter to CNAME results + var cnameResults []dnsResult + for _, r := range results { + if r.status == "cname" { + cnameResults = append(cnameResults, r) + } + } + + if len(cnameResults) == 0 { + fmt.Println() + ui.Info("No renamed hosts detected") + ui.CommandEnd(ui.StatusNoop) + return nil + } + + // Display table + fmt.Println() + tbl := ui.NewTable("Host", "Current HostName", "New HostName") + for _, r := range cnameResults { + tbl.AddRow(r.host.Host, r.target, r.detail) + } + tbl.Render() + fmt.Println() + + // Multi-select + options := make([]ui.FuzzySelectOption, len(cnameResults)) + for i, r := range cnameResults { + options[i] = ui.FuzzySelectOption{ + Label: fmt.Sprintf("%s -> %s", r.host.Host, sshconfig.DeriveHostID(r.detail)), + Value: r.host.Host, + } + } + + indices, err := ui.FuzzySelectMulti("Select hosts to rename", options) + if err != nil { + ui.Abort("Selection failed: %s", err) + ui.CommandEnd(ui.StatusAbort) + return nil + } + if len(indices) == 0 { + ui.Abort("No hosts selected") + ui.CommandEnd(ui.StatusAbort) + return nil + } + + // Build host index for collision detection and merge targets + allHosts, _ := parser.GetAllHosts() + hostByID := make(map[string]*sshconfig.HostEntry) + for _, h := range allHosts { + hostByID[strings.ToLower(h.Host)] = h + } + + // Classify operations: rename (no collision) or merge (target exists) + type renameOp struct { + host *sshconfig.HostEntry + newID string + cnameTarget string + } + type mergeOp struct { + old *sshconfig.HostEntry // entry to remove + target *sshconfig.HostEntry // existing entry to add alias to + alias string // old Host ID to add as alias + } + + byFile := make(map[string][]renameOp) + var merges []mergeOp + + for _, idx := range indices { + r := cnameResults[idx] + newID := sshconfig.DeriveHostID(r.detail) + + if strings.EqualFold(newID, r.host.Host) { + // Same ID -- just update HostName, no collision + byFile[r.host.SourceFile] = append(byFile[r.host.SourceFile], renameOp{ + host: r.host, + newID: newID, + cnameTarget: r.detail, + }) + continue + } + + existing, hasCollision := hostByID[strings.ToLower(newID)] + if !hasCollision { + byFile[r.host.SourceFile] = append(byFile[r.host.SourceFile], renameOp{ + host: r.host, + newID: newID, + cnameTarget: r.detail, + }) + } else { + merges = append(merges, mergeOp{ + old: r.host, + target: existing, + alias: r.host.Host, + }) + } + } + + if len(byFile) == 0 && len(merges) == 0 { + ui.Info("No changes to apply") + ui.CommandEnd(ui.StatusNoop) + return nil + } + + // Process renames + ui.SubSection("Updating") + + backedUp := make(map[string]bool) + backupFile := func(path string) { + if !backedUp[path] { + if err := createBackup(path, getBackupDir(), getMaxBackups()); err != nil { + ui.Warning("Backup failed for %s: %v", abbreviateHome(path), err) + } + backedUp[path] = true + } + } + + for path, ops := range byFile { + cfg, err := parser.ParseFile(path) + if err != nil { + for _, op := range ops { + ui.Error("%s: parse failed: %s", op.host.Host, err) + } + continue + } + + backupFile(path) + + for _, op := range ops { + host := sshconfig.FindHostByPattern(cfg.Hosts, op.host.Host) + if host == nil { + ui.Error("%s: not found in %s", op.host.Host, abbreviateHome(path)) + continue + } + + oldID := host.Host + + // Update Host line: "Host " + for i, line := range host.Lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(trimmed), "host ") { + host.Lines[i] = fmt.Sprintf("Host %s %s\n", op.newID, oldID) + break + } + } + + // Update HostName + updateHostProperty(host, "hostname", op.cnameTarget) + + // Update in-memory fields + host.Host = op.newID + host.Patterns = []string{op.newID, oldID} + host.HostName = op.cnameTarget + + ui.Success("%s -> %s (alias: %s)", oldID, op.newID, oldID) + } + + if err := parser.WriteFile(cfg); err != nil { + ui.Error("Failed to write %s: %s", abbreviateHome(path), err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + } + + // Process merges: add alias to existing entry, remove old entry + if len(merges) > 0 { + // Collect all files we need to touch + // For each merge: target file (add alias) + old file (remove entry) + type fileEdit struct { + addAliases map[string]string // host pattern -> alias to add + removePatterns []string + } + edits := make(map[string]*fileEdit) + + getEdit := func(path string) *fileEdit { + if e, ok := edits[path]; ok { + return e + } + e := &fileEdit{addAliases: make(map[string]string)} + edits[path] = e + return e + } + + for _, m := range merges { + // Add alias to target's file + targetEdit := getEdit(m.target.SourceFile) + targetEdit.addAliases[strings.ToLower(m.target.Host)] = m.alias + + // Remove old entry from its file + oldEdit := getEdit(m.old.SourceFile) + oldEdit.removePatterns = append(oldEdit.removePatterns, m.old.Host) + } + + for path, edit := range edits { + cfg, err := parser.ParseFile(path) + if err != nil { + ui.Error("parse %s: %s", abbreviateHome(path), err) + continue + } + + backupFile(path) + + // Add aliases + for hostPattern, alias := range edit.addAliases { + host := sshconfig.FindHostByPattern(cfg.Hosts, hostPattern) + if host == nil { + continue + } + // Append alias to Host line + for i, line := range host.Lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(trimmed), "host ") { + // Add alias at the end of the Host line + host.Lines[i] = strings.TrimRight(line, "\n") + " " + alias + "\n" + break + } + } + host.Patterns = append(host.Patterns, alias) + } + + // Remove old entries + for _, pattern := range edit.removePatterns { + cfg.Hosts = sshconfig.RemoveHost(cfg.Hosts, pattern) + } + + if err := parser.WriteFile(cfg); err != nil { + ui.Error("Failed to write %s: %s", abbreviateHome(path), err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + } + + for _, m := range merges { + ui.Success("%s merged into %s (alias: %s)", m.old.Host, m.target.Host, m.alias) + } + } + + ui.CommandEnd(ui.StatusSuccess) + return nil +} + +// printDNSSummary prints a one-line summary of DNS resolution results. +func printDNSSummary(s dnsSummary) { + var parts []string + if s.ok > 0 { + parts = append(parts, fmt.Sprintf("[*] %d resolved", s.ok)) + } + if s.nxdomain > 0 { + parts = append(parts, fmt.Sprintf("[!] %d NXDOMAIN", s.nxdomain)) + } + if s.cname > 0 { + parts = append(parts, fmt.Sprintf("[*] %d renamed (CNAME)", s.cname)) + } + if s.timeout > 0 { + parts = append(parts, fmt.Sprintf("[!] %d timeout", s.timeout)) + } + if s.skip > 0 { + parts = append(parts, fmt.Sprintf("[*] %d skipped", s.skip)) + } + if s.errCount > 0 { + parts = append(parts, fmt.Sprintf("[!] %d errors", s.errCount)) + } + fmt.Printf(" %s\n", strings.Join(parts, " ")) +} + +// printDNSErrors lists hosts that had DNS resolution errors. +func printDNSErrors(results []dnsResult) { + var errResults []dnsResult + for _, r := range results { + if r.status == "error" { + errResults = append(errResults, r) + } + } + if len(errResults) == 0 { + return + } + + fmt.Println() + ui.SubSection("DNS Errors", true) + for _, r := range errResults { + ui.Warning("%s: %s", r.host.Host, r.detail) + } +} + +// buildFileToContext creates a mapping from include filename to context name. +func buildFileToContext() map[string]string { + fileToContext := make(map[string]string) + mgr, err := clisession.NewManager(vault.Auto()) + if err != nil { + return fileToContext + } + _ = clisession.TryUnlockIfTTY(mgr) + contexts, err := mgr.ListContexts() + if err != nil { + return fileToContext + } + for _, ctx := range contexts { + fileToContext[ctx.GitIncludeFile] = ctx.Name + } + return fileToContext +} + +// contextForHost returns the context name for a host entry, or the filename. +func contextForHost(host *sshconfig.HostEntry, fileToContext map[string]string) string { + base := filepath.Base(host.SourceFile) + if name, ok := fileToContext[base]; ok { + return name + } + return base +}