diff --git a/.gitignore b/.gitignore index 5a83cff..7d3d065 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # binary -netssh +/netssh dist/ # Go test artifacts diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go new file mode 100644 index 0000000..6683681 --- /dev/null +++ b/cmd/netssh/main.go @@ -0,0 +1,334 @@ +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" + 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{ + "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) +} + +// runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary. +func runSSHWrapper(args []string) { + cfg, err := config.Load() + if err != nil { + fatalf("config: %v", err) + } + + 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, err := config.Load() + if err != nil { + fatalf("config: %v", err) + } + + 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(searchCmd(), cacheCmd()) + return root +} + +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) +}