package main import ( "context" "fmt" "os" "sort" "strings" "text/tabwriter" "time" tea "github.com/charmbracelet/bubbletea" "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/netbox" "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, "search": true, "cache": true, "completion": true, "__complete": true, "help": true, } func main() { args := os.Args[1:] if len(args) == 0 { runTUI() 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.NetBox.URL == "" { 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) { cfg := loadConfigOrSetup() parsed := internalssh.Parse(args) if parsed == nil { // No destination found — pass arguments straight through to ssh. if err := internalssh.Exec(args); err != nil { fatalf("%v", err) } return } // Inject the configured default user if none was given on the command line. if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) { args = append([]string{"-l", cfg.SSH.DefaultUser}, args...) parsed.DestIdx += 2 } c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) _ = c.Load() // Cache hit with a fresh TTL — connect directly without querying NetBox. if entry, fresh := c.Get(parsed.Host); fresh { connect(entry.IP, parsed, args) return } if cfg.NetBox.URL == "" { fatalf("netbox.url is not configured (~/.config/netssh.yaml)") } nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() entries, err := nbClient.Search(ctx, parsed.Host) if err != nil { fatalf("NetBox search failed: %v", err) } var target *netbox.HostEntry for i, e := range entries { if strings.EqualFold(e.Name, parsed.Host) { target = &entries[i] break } } if target == nil { fatalf("host %q not found in NetBox", parsed.Host) } 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", parsed.Host, err) } c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags}) _ = c.Save() connect(ip, parsed, args) } func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) { newArgs := internalssh.ReplaceHost(originalArgs, parsed.DestIdx, ip) if err := internalssh.Exec(newArgs); err != nil { fatalf("%v", err) } } // runTUI starts the interactive host search. func runTUI() { cfg := loadConfigOrSetup() c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) _ = c.Load() var nbClient *netbox.Client if cfg.NetBox.URL != "" { nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token) } m := tui.New(nbClient, c) p := tea.NewProgram(m, tea.WithAltScreen()) 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 } fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP) var sshArgs []string if cfg.SSH.DefaultUser != "" { sshArgs = append(sshArgs, "-l", cfg.SSH.DefaultUser) } sshArgs = append(sshArgs, host.IP) if err := internalssh.Exec(sshArgs); err != nil { fatalf("%v", err) } } // --- 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) { cfg, err := config.Load() if err != nil { return nil, cobra.ShellCompDirectiveError } c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) _ = c.Load() entries := c.Search(toComplete) names := make([]cobra.Completion, len(entries)) for i, e := range entries { names[i] = cobra.Completion(e.Name) } return names, cobra.ShellCompDirectiveNoFileComp }, } // cobra automatically adds a "completion" subcommand root.AddCommand(configureCmd(), searchCmd(), cacheCmd()) return root } func configureCmd() *cobra.Command { return &cobra.Command{ Use: "configure", Short: "Interactively configure netssh", RunE: func(cmd *cobra.Command, args []string) error { cfg, _ := config.Load() if cfg == nil { cfg = &config.Config{} } 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 := cache.New(cfg.Cache.Path, cfg.Cache.TTL) 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 := cache.New(cfg.Cache.Path, cfg.Cache.TTL) 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 := cache.New(cfg.Cache.Path, cfg.Cache.TTL) c.Clear() if err := c.Save(); err != nil { return err } fmt.Println("Cache cleared.") return nil }, } } func cacheRefreshCmd() *cobra.Command { return &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.NetBox.URL == "" { return fmt.Errorf("netbox.url is not configured") } nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // An empty query returns up to 50 entries per type. entries, err := nbClient.Search(ctx, "") if err != nil { return fmt.Errorf("NetBox: %w", err) } c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) _ = c.Load() chain, _ := resolver.New(cfg.Resolver) for i := range entries { e := &entries[i] ip := e.PrimaryIP4 if ip == "" && chain != nil { resolved, err := chain.Resolve(ctx, e, nbClient) if err == nil { ip = resolved } } if ip != "" { c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags}) } } if err := c.Save(); err != nil { return err } fmt.Printf("%d entries written to cache.\n", len(entries)) return nil }, } } func fatalf(format string, args ...any) { fmt.Fprintf(os.Stderr, "netssh: "+format+"\n", args...) os.Exit(1) }