package main import ( "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "text/tabwriter" "time" tea "charm.land/bubbletea/v2" "github.com/spf13/cobra" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup" internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui" ) // managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode. var managedSubcommands = map[string]bool{ "configure": true, "notes": true, "search": true, "cache": true, "alias": true, "hook": true, "shell-init": true, "completion": true, "__complete": true, "help": true, "version": true, "uninstall": true, "sftp": true, "scp": true, "tunnel": true, "hosts": true, "multi": true, "sync": true, } func main() { args := os.Args[1:] if len(args) == 0 { runTUI() return } if args[0] == "--version" { fmt.Printf("netssh %s\n", buildVersion()) return } if managedSubcommands[args[0]] { if err := rootCmd().Execute(); err != nil { os.Exit(1) } return } runSSHWrapper(args) } // loadConfigOrSetup loads the config and runs the setup wizard if NetBox is not configured. func loadConfigOrSetup() *config.Config { cfg, err := config.Load() if err != nil { fatalf("config: %v", err) } if !cfg.HasHosts() { fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…") if err := setup.RunWizard(cfg); err != nil { fatalf("setup: %v", err) } cfg, err = config.Load() if err != nil { fatalf("config: %v", err) } } return cfg } // runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary. func runSSHWrapper(args []string) { flags, args, err := internalssh.ExtractWrapperFlags(args) if err != nil { fatalf("%v", err) } runClient(internalssh.SSH, flags, args) } // destination is the NetBox host named in the arguments of a client. type destination struct { host string user string dests []int // argument indices naming the host; several only for scp } func findDestination(client internalssh.Client, args []string) (*destination, error) { if client.Name == internalssh.SCP.Name { operands, err := client.Operands(args) if err != nil || len(operands) == 0 { return nil, err } dest := &destination{host: operands[0].Host} for _, operand := range operands { dest.dests = append(dest.dests, operand.Idx) if operand.User != "" { dest.user = operand.User } } return dest, nil } parsed := client.Parse(args) if parsed == nil { return nil, nil } return &destination{host: parsed.Host, user: parsed.User, dests: []int{parsed.DestIdx}}, nil } // runClient resolves the NetBox host in args and execs ssh, sftp, or scp. func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args []string) { cfg := loadConfigOrSetup() client = client.WithInstances(instanceNames(cfg)) dest, err := findDestination(client, args) if err != nil { fatalf("%v", err) } if dest == nil { if len(flags.Forwards) > 0 { fatalf("--forward needs a destination host") } // No destination found — pass arguments straight through. execClient(client, internalssh.Result{Args: args}, flags) return } host := resolveTarget(cfg, dest.host) user, port := hostDefaults(cfg, host) // Inject the default user if none was given on the command line. if user != "" && dest.user == "" && !client.HasUserFlag(args) { args = prependArgs(client.UserArgs(user), args, dest.dests) } // Inject the default port if none was given on the command line. if port > 0 && !client.HasPortFlag(args) { args = prependArgs(client.PortArgs(strconv.Itoa(port)), args, dest.dests) } connectHost(cfg, client, flags, host, dest.dests, args) } // hostDefaults returns the login user and port for host: those of a static // host if set, else ssh.default_user and ssh.default_port. func hostDefaults(cfg *config.Config, host resolvedHost) (string, int) { user, port := cfg.SSH.DefaultUser, cfg.SSH.DefaultPort if host.Kind == config.StaticKind { if static := cfg.StaticHost(host.Name); static != nil { if static.User != "" { user = static.User } if static.Port > 0 { port = static.Port } } } return user, port } // instanceNames lists the named NetBox instances, which may prefix hostnames. func instanceNames(cfg *config.Config) []string { names := make([]string, 0, len(cfg.NetBoxes)) for _, source := range cfg.NetBoxes { names = append(names, source.Name) } return names } // prependArgs puts extra before args and shifts the destination indices. func prependArgs(extra, args []string, dests []int) []string { for i := range dests { dests[i] += len(extra) } return append(append([]string(nil), extra...), args...) } // resolvedHost is a NetBox host with the address to connect to. type resolvedHost struct { Name string // cache/selector name, may carry an instance prefix Kind string IP string Tags []string } // resolveTarget finds host in the cache (also by shortcut) or NetBox and // resolves its address. It exits on failure. func resolveTarget(cfg *config.Config, host string) resolvedHost { c := newCache(cfg) _ = c.Load() maybeBackgroundRefresh(cfg, c) source, _ := netbox.SplitTarget(host) useCache := len(cfg.NetBoxes) == 0 || source != "" used := func(entry cache.Entry) resolvedHost { c.MarkUsed(entry.Name) _ = c.Save() return resolvedHost{Name: entry.Name, Kind: entry.Kind, IP: entry.IP, Tags: entry.Tags} } // Static hosts from the config win and need no NetBox. if source == "" { var normalize func(string) string if cfg.Shortcuts.Enabled() { normalize = normalizeTarget(cfg.Shortcuts, false) } entry, found, err := c.GetStatic(host, normalize) if err != nil { fatalf("%v", err) } if found { return used(entry) } } // Cache hit with a fresh TTL — connect directly without querying NetBox. if entry, fresh := c.Get(host); fresh && useCache { return used(entry) } // Shortcut cache lookup: scan entries whose normalized name matches the input. // If a stale match is found, use the canonical name for the NetBox re-fetch below. lookupHost := host shortcutsEnabled := cfg.Shortcuts.Enabled() if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache { normalize := normalizeTarget(cfg.Shortcuts, source != "") entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize) if err != nil { fatalf("%v", err) } if found { if fresh { return used(entry) } // Stale shortcut match — use canonical name for NetBox re-fetch. lookupHost = entry.Name } } if !cfg.HasNetBox() { fatalf("host %q is not in hosts: and no NetBox is configured (netssh configure)", host) } nbClient := newNetBoxClient(cfg) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains) if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled { entry, found, _, cacheErr := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, false)) if cacheErr != nil { fatalf("%v", cacheErr) } if found { target, err = findSSHHost(ctx, nbClient, entry.Name, cfg.Shortcuts.Domains) } } if err != nil { fatalf("NetBox: %v", err) } if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind { return used(cached) } chain, err := resolver.New(cfg.Resolver) if err != nil { fatalf("resolver: %v", err) } ip, err := chain.Resolve(ctx, &target, nbClient) if err != nil { fatalf("IP resolution for %q: %v", host, err) } entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster} c.Upsert(entry) return used(entry) } // findSSHHost uses the paginated, ambiguity-checking lookup for connection // targets. Only a genuine missing host permits trying configured suffixes. func findSSHHost(ctx context.Context, client *netbox.Client, name string, domains []string) (netbox.HostEntry, error) { target, err := client.FindHost(ctx, name, "") if !errors.Is(err, netbox.ErrHostNotFound) || strings.Contains(name, ".") { return target, err } for _, domain := range domains { domain = strings.TrimPrefix(strings.TrimSpace(domain), ".") if domain == "" { continue } target, err = client.FindHost(ctx, name+"."+domain, "") if !errors.Is(err, netbox.ErrHostNotFound) { return target, err } } return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name) } // connectHost applies the SSH options configured for the host and execs the client. func connectHost(cfg *config.Config, client internalssh.Client, flags internalssh.WrapperFlags, host resolvedHost, dests []int, args []string) { opts := hostOptions(cfg, host) if len(flags.Forwards) > 0 { forwardArgs, err := internalssh.ForwardArgs(flags.Forwards, opts.Forwards) if err != nil { fatalf("%v", err) } flags.ForwardArgs = forwardArgs } if flags.Tunnel && len(flags.ForwardArgs) == 0 && !client.HasForwardFlag(args) { available := "none configured (ssh.forwards)" if names := forwardNames(opts); len(names) > 0 { available = strings.Join(names, ", ") } fatalf("name a forward or a spec such as 8080:localhost:80; forwards for %s: %s", host.Name, available) } _, name := netbox.SplitTarget(host.Name) execClient(client, client.Apply(args, dests, internalssh.Host{Name: name, IP: host.IP}, opts, flags), flags) } // hostOptions merges the global, instance, and rule SSH options for host. func hostOptions(cfg *config.Config, host resolvedHost) config.SSHOptions { instance, name := netbox.SplitTarget(host.Name) return cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: host.Kind, Tags: host.Tags}) } // execClient replaces the process with the client, or prints the command for --dry-run. func execClient(client internalssh.Client, result internalssh.Result, flags internalssh.WrapperFlags) { if flags.DryRun { fmt.Println(internalssh.Quote(append([]string{client.Name}, result.Args...))) return } if result.Notice != "" { fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice) } if err := client.Exec(result.Args); err != nil { fatalf("%v", err) } } // runTUI starts the interactive host search. func runTUI() { cfg := loadConfigOrSetup() c := newCache(cfg) _ = c.Load() maybeBackgroundRefresh(cfg, c) var nbClient *netbox.Client if cfg.HasNetBox() { nbClient = newNetBoxClient(cfg) } m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort) m.SetNotes(notes.New(cfg.NetBox.URL, nbClient)) m.SetForwards(func(name, kind string, tags []string) []string { return forwardNames(hostOptions(cfg, resolvedHost{Name: name, Kind: kind, Tags: tags})) }) p := tea.NewProgram(m) final, err := p.Run() if err != nil { fatalf("TUI: %v", err) } tuiModel, ok := final.(*tui.Model) if !ok { return } host := tuiModel.Selected() if host == nil { return } if host.Action == tui.ActionMulti { if err := runMulti(cfg, host.Targets, multiFlags{}); err != nil { fatalf("%v", err) } return } // Revalidate stale or live TUI results through the configured resolver chain. var tags []string if cached, fresh := c.Get(host.Name); fresh && cached.Kind == host.Kind { host.IP = cached.IP tags = cached.Tags } else { if nbClient == nil { fatalf("cannot resolve %q: NetBox is not configured", host.Name) } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() target, err := nbClient.FindHost(ctx, host.Name, host.Kind) if err != nil { fatalf("NetBox: %v", err) } chain, err := resolver.New(cfg.Resolver) if err != nil { fatalf("resolver: %v", err) } host.IP, err = chain.Resolve(ctx, &target, nbClient) if err != nil { fatalf("IP resolution: %v", err) } c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster}) tags = target.Tags } fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP) c.MarkUsed(host.Name) _ = c.Save() client, flags, args := tuiCommand(cfg, host) connectHost(cfg, client, flags, resolvedHost{Name: host.Name, Kind: host.Kind, IP: host.IP, Tags: tags}, []int{len(args) - 1}, args) } // tuiCommand builds the client and arguments for the action chosen in the TUI. func tuiCommand(cfg *config.Config, host *tui.SelectedHost) (internalssh.Client, internalssh.WrapperFlags, []string) { client := internalssh.SSH var flags internalssh.WrapperFlags switch host.Action { case tui.ActionSFTP: client = internalssh.SFTP case tui.ActionTunnel: flags = internalssh.WrapperFlags{Forwards: host.Forwards, Tunnel: true} } var args []string // User override from TUI edit mode takes priority, then static host and config defaults. defaultUser, defaultPort := hostDefaults(cfg, resolvedHost{Name: host.Name, Kind: host.Kind}) user := host.User if user == "" { user = defaultUser } if user != "" { args = append(args, client.UserArgs(user)...) } port := host.Port if port == "" && defaultPort > 0 { port = strconv.Itoa(defaultPort) } if port != "" { args = append(args, client.PortArgs(port)...) } // Apply replaces the destination; a bare name cannot be mistaken for // host:path by sftp, unlike an IPv6 address. _, name := netbox.SplitTarget(host.Name) return client, flags, append(args, name) } // forwardNames lists the forwards configured for a host, sorted. func forwardNames(opts config.SSHOptions) []string { names := make([]string, 0, len(opts.Forwards)) for name := range opts.Forwards { names = append(names, name) } sort.Strings(names) return names } // --- cobra root + subcommands --- func rootCmd() *cobra.Command { root := &cobra.Command{ Use: "netssh", Short: "SSH wrapper with NetBox hostname resolution", DisableFlagParsing: true, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { if len(args) > 0 && args[len(args)-1] == "--forward" { cfg, err := config.Load() if err != nil { return nil, cobra.ShellCompDirectiveError } return filterPrefix(allForwardNames(cfg), toComplete, nil), cobra.ShellCompDirectiveNoFileComp } hosts := completeHosts(toComplete) completions := make([]cobra.Completion, 0, len(hosts)) for _, host := range hosts { completions = append(completions, cobra.Completion(host)) } // Subcommands at the end, after all hostnames. for _, sub := range cmd.Commands() { if sub.IsAvailableCommand() && strings.HasPrefix(sub.Name(), toComplete) { completions = append(completions, cobra.Completion(sub.Name()+"\t"+sub.Short)) } } return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveKeepOrder }, } root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd(), multiCmd(), syncCmd()) // cobra builds the "completion" command lazily; force init so we can extend it. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { if cmd.Name() == "completion" { cmd.AddCommand(completionInstallCmd(root)) // Cobra's zsh script spawns a subshell per line and sends every host // through _describe, which hangs with thousands of cached hosts. for _, sub := range cmd.Commands() { if sub.Name() == "zsh" { sub.RunE = func(cmd *cobra.Command, args []string) error { _, err := io.WriteString(cmd.OutOrStdout(), hook.ZshCompletionScript) return err } sub.Run = nil } } break } } return root } func completionInstallCmd(root *cobra.Command) *cobra.Command { var shell string cmd := &cobra.Command{ Use: "install", Short: "Install shell completion for the current user (no sudo required)", RunE: func(cmd *cobra.Command, args []string) error { if shell == "" { shell = filepath.Base(os.Getenv("SHELL")) } var ( dir string file string gen func() ([]byte, error) note string ) switch shell { case "bash": dir = filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions") file = filepath.Join(dir, "netssh") gen = func() ([]byte, error) { var buf strings.Builder err := root.GenBashCompletionV2(&buf, true) return []byte(buf.String()), err } note = "Reload your shell or run: source " + file case "zsh": dir = filepath.Join(os.Getenv("HOME"), ".zfunc") file = filepath.Join(dir, "_netssh") gen = func() ([]byte, error) { return []byte(hook.ZshCompletionScript), nil } note = hook.ZshCompletionNote(dir) case "fish": dir = filepath.Join(hook.FishConfigDir(), "completions") file = filepath.Join(dir, "netssh.fish") gen = func() ([]byte, error) { var buf strings.Builder err := root.GenFishCompletion(&buf, true) return []byte(buf.String()), err } note = "Reload your shell or start a new fish session." default: return fmt.Errorf("unsupported shell %q — use --shell bash|zsh|fish", shell) } script, err := gen() if err != nil { return fmt.Errorf("generating completion: %w", err) } if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("creating %s: %w", dir, err) } if err := os.WriteFile(file, script, 0o644); err != nil { return fmt.Errorf("writing %s: %w", file, err) } cfg, err := config.Load() if err != nil { return err } if err := hook.SyncCommandCompletion(cfg.Shell.Alias); err != nil { return err } fmt.Printf("Completion installed → %s\n%s\n", file, note) if cfg.Shell.Alias != "" { fmt.Printf("Completion also enabled for alias %q.\n", cfg.Shell.Alias) } return nil }, } cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish") return cmd } func configureCmd() *cobra.Command { return &cobra.Command{ Use: "configure", Short: "Interactively configure netssh", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } return setup.RunWizard(cfg) }, } } func searchCmd() *cobra.Command { return &cobra.Command{ Use: "search ", Short: "Print hostnames from the cache (used for shell completion)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } c := newCache(cfg) if err := c.Load(); err != nil { return err } q := "" if len(args) > 0 { q = args[0] } for _, e := range c.Search(q) { fmt.Println(e.Name) } return nil }, } } func cacheCmd() *cobra.Command { cmd := &cobra.Command{ Use: "cache", Short: "Manage the local host cache", } cmd.AddCommand(cacheListCmd(), cacheClearCmd(), cacheRefreshCmd()) return cmd } func cacheListCmd() *cobra.Command { return &cobra.Command{ Use: "list", Short: "Show all cached entries", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } c := newCache(cfg) if err := c.Load(); err != nil { return err } entries := c.All() sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "NAME\tIP\tKIND\tCACHED AT") for _, e := range entries { fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.Name, e.IP, e.Kind, e.CachedAt.Format(time.RFC3339)) } return w.Flush() }, } } func cacheClearCmd() *cobra.Command { return &cobra.Command{ Use: "clear", Short: "Remove all cached entries", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } c := newCache(cfg) c.Clear() if err := c.Save(); err != nil { return err } fmt.Println("Cache cleared.") return nil }, } } func cacheRefreshCmd() *cobra.Command { var filterTag, filterKind, filterInstance string cmd := &cobra.Command{ Use: "refresh", Short: "Re-fetch all known hosts from NetBox", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } if !cfg.HasNetBox() { return fmt.Errorf("netbox.url is not configured") } nbClient := newNetBoxClient(cfg) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind} query := "" if filterInstance != "" { if !nbClient.MultipleSources() { return fmt.Errorf("--instance requires named netboxes configuration") } query = filterInstance + "/" } entries, err := nbClient.SearchAll(ctx, query, opts) if err != nil { return fmt.Errorf("NetBox: %w", err) } c := newCache(cfg) _ = c.Load() chain, err := resolver.New(cfg.Resolver) if err != nil { return fmt.Errorf("resolver: %w", err) } written := 0 nameCounts := make(map[string]int) for _, e := range entries { nameCounts[strings.ToLower(e.Target())]++ } for i := range entries { e := &entries[i] if nameCounts[strings.ToLower(e.Target())] > 1 { c.Delete(e.Target()) fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name) continue } ip, err := chain.Resolve(ctx, e, nbClient) if err != nil { // Do not turn a failed configured strategy into a fresh // primary-IP cache hit that bypasses the resolver. if ctx.Err() != nil { return ctx.Err() } c.Delete(e.Target()) fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err) continue } c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags, Cluster: e.Cluster}) written++ } if err := c.Save(); err != nil { return err } if filterTag == "" && filterKind == "" && filterInstance == "" { if err := c.SetRefreshed(); err != nil { fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err) } } if aliasEntries := buildAliasEntries(c, *cfg); len(aliasEntries) > 0 { if err := hook.WriteAliasFiles(aliasEntries); err != nil { fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err) } } fmt.Printf("%d entries written to cache.\n", written) return nil }, } cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)") cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm") cmd.Flags().StringVar(&filterInstance, "instance", "", "Refresh only this named NetBox instance") return cmd } // buildAliasEntries computes the sorted, deduplicated alias list from the cache. func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry { return hook.BuildAliasEntries(c.All(), cfg) } func aliasCmd() *cobra.Command { var shell, test string cmd := &cobra.Command{ Use: "alias", Short: "Print shell aliases for all cached hosts", Long: `Print shell alias definitions for all cached hosts. The alias name is the shortened form derived from the configured shortcuts (domain suffixes stripped, rewrite rules applied, hyphens optionally stripped). Check a rule with: netssh alias --test fsn1-bx1.example.com Source the output in your shell profile: bash/zsh: eval "$(netssh alias)" fish: netssh alias --shell fish | source Or use in a script: source <(netssh alias)`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } c := newCache(cfg) if err := c.Load(); err != nil { return err } if test != "" { printShortcutTest(os.Stdout, test, c.All(), cfg.Shortcuts) return nil } if shell == "" { shell = filepath.Base(os.Getenv("SHELL")) } entries := buildAliasEntries(c, *cfg) if len(entries) == 0 { fmt.Fprintln(os.Stderr, "netssh: cache is empty — run 'netssh cache refresh' first") return nil } switch shell { case "fish": fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(entries)) for _, e := range entries { fmt.Println(e.Definition("fish")) } default: fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries)) for _, e := range entries { fmt.Println(e.Definition("bash")) } } return nil }, } cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)") cmd.Flags().StringVar(&test, "test", "", "Show the shortcut for a hostname and the cached hosts it matches") return cmd } // printShortcutTest helps writing shortcut rules: it shows what a name is // shortened to and which cached hosts share that shortcut. func printShortcutTest(w io.Writer, name string, entries []cache.Entry, sc config.ShortcutsConfig) { normalize := normalizeTarget(sc, strings.Contains(name, "/")) short := normalize(name) fmt.Fprintf(w, "%s → %s\n", name, short) var matches []string for _, e := range entries { if normalize(e.Name) == short { matches = append(matches, e.Name) } } sort.Strings(matches) switch len(matches) { case 0: fmt.Fprintln(w, "no cached host has this shortcut") case 1: fmt.Fprintf(w, "matches %s\n", matches[0]) default: fmt.Fprintf(w, "ambiguous, matches %d hosts:\n", len(matches)) for _, m := range matches { fmt.Fprintf(w, " %s\n", m) } } } // shellInitCmd is called at shell startup to update alias files and trigger a // background cache refresh when stale. Intentionally silent — never disrupts the prompt. func shellInitCmd() *cobra.Command { return &cobra.Command{ Use: "shell-init", Short: "Regenerate alias files and trigger a background cache refresh if stale", Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return nil } c := newCache(cfg) _ = c.Load() // Regenerate alias files from the current cache every shell start // so aliases are always up-to-date even before a refresh completes. _ = hook.WriteAliasFiles(buildAliasEntries(c, *cfg)) maybeBackgroundRefresh(cfg, c) return nil }, } } func hookCmd() *cobra.Command { cmd := &cobra.Command{ Use: "hook", Short: "Manage shell hooks for automatic cache refresh at shell startup", } cmd.AddCommand(hookInstallCmd(), hookUninstallCmd()) return cmd } func hookInstallCmd() *cobra.Command { var shell string cmd := &cobra.Command{ Use: "install", Short: "Add netssh shell-init to your shell profile", Long: `Adds a marked block (# >>> netssh >>> … # <<< netssh <<<) to your shell profile that runs 'netssh shell-init' on every new shell session and, for zsh, registers the completion. Unmarked entries from older versions are replaced. shell-init checks whether the cache is older than 24 hours and, if so, starts a background refresh — no delay to your prompt. After installation, reload your profile or open a new shell.`, RunE: func(cmd *cobra.Command, args []string) error { if shell == "" { shell = filepath.Base(os.Getenv("SHELL")) } profile, err := hook.ProfilePath(shell) if err != nil { return err } // The managed block also sources the aliases (bash/zsh) and registers // zsh completion; fish uses conf.d auto-sourcing instead. installed, err := hook.Install(profile, shell) if err != nil { return err } if !installed { fmt.Printf("Hook already installed in %s\n", profile) } else { fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile)) } // Generate the aliases file immediately so it is ready on the next shell start. cfg, cfgErr := config.Load() if cfgErr == nil && cfg.HasNetBox() { c := newCache(cfg) if _ = c.Load(); true { if entries := buildAliasEntries(c, *cfg); len(entries) > 0 { if err := hook.WriteAliasFiles(entries); err != nil { fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err) } else { fmt.Printf("%d aliases written to %s\n", len(entries), hook.AliasesPath()) } } else { fmt.Println("Cache is empty — run 'netssh cache refresh' to populate aliases.") } } } return nil }, } cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish") return cmd } func hookUninstallCmd() *cobra.Command { var shell string cmd := &cobra.Command{ Use: "uninstall", Short: "Remove netssh shell-init from your shell profile", RunE: func(cmd *cobra.Command, args []string) error { if shell == "" { shell = filepath.Base(os.Getenv("SHELL")) } profile, err := hook.ProfilePath(shell) if err != nil { return err } removed, err := hook.Uninstall(profile) if err != nil { return err } if !removed { fmt.Printf("No hook found in %s\n", profile) return nil } fmt.Printf("Hook removed from %s\n", profile) return nil }, } cmd.Flags().StringVar(&shell, "shell", "", "Shell to uninstall from (default: $SHELL). Supported: bash, zsh, fish") return cmd } // maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache // has not been fully refreshed within the last 24 hours. func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) { // netssh multi checks once and starts its panes with NETSSH_NO_REFRESH. if !cfg.HasNetBox() || os.Getenv("NETSSH_NO_REFRESH") != "" { return } if !c.NeedsRefresh(24 * time.Hour) { return } self, err := os.Executable() if err != nil { return } cmd := exec.Command(self, "cache", "refresh") cmd.Stdout = nil cmd.Stderr = nil cmd.Stdin = nil _ = cmd.Start() // fire and forget — becomes orphan after parent execs ssh } func fatalf(format string, args ...any) { fmt.Fprintf(os.Stderr, "netssh: "+format+"\n", args...) os.Exit(1) }