Files
Sebastian UnterschützandClaude Opus 5.5 a950a82552 feat: hostname resolver strategy and static hosts
Add the hostname resolver strategy. It connects by the NetBox name
instead of an IP, with resolver.hostname_domain appended to names
without a dot. Its position in the chain decides whether it is always
used or only as a fallback (e.g. [primary_ip, hostname]); names that
are not valid DNS hostnames fall through to the next strategy.

Add static hosts under hosts: for machines that are not in NetBox,
with address, user, port, tags, and their own SSH options. They are
checked before NetBox (by name or shortcut), never expire, survive
cache refresh and clear, and work without any NetBox configured. The
cache overlays them after every load and stores only their last use,
so they appear in completion, aliases, the TUI and its recent list.
SSH options merge global, static host, then rules; rules can match
kind: static. Manage them with netssh hosts list|add|remove and in
netssh configure, which can now also continue without NetBox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 23:46:13 +01:00

143 lines
4.2 KiB
Go

package main
import (
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"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/setup"
)
func hostsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "hosts",
Short: "Manage static hosts that are not in NetBox",
Long: `Static hosts are defined under hosts: in the config. They are found before
NetBox, work offline, and support shortcuts, rules (kind: static), aliases,
completion, sftp, scp, and tunnels like NetBox hosts.`,
RunE: func(cmd *cobra.Command, args []string) error { return listHosts() },
}
cmd.AddCommand(hostsListCmd(), hostsAddCmd(), hostsRemoveCmd())
return cmd
}
func hostsListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List static hosts",
RunE: func(cmd *cobra.Command, args []string) error { return listHosts() },
}
}
func listHosts() error {
cfg, err := config.Load()
if err != nil {
return err
}
if len(cfg.Hosts) == 0 {
fmt.Println("No static hosts. Add one with: netssh hosts add NAME ADDRESS")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tADDRESS\tUSER\tPORT\tTAGS")
for _, host := range cfg.Hosts {
port := ""
if host.Port > 0 {
port = fmt.Sprint(host.Port)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", host.Name, host.Address, host.User, port, strings.Join(host.Tags, ","))
}
return w.Flush()
}
func hostsAddCmd() *cobra.Command {
var host config.StaticHost
var replace bool
cmd := &cobra.Command{
Use: "add NAME ADDRESS",
Short: "Add a static host (or replace one with --replace)",
Example: ` netssh hosts add router 192.168.1.1 --user admin
netssh hosts add backup backup.example.org --port 2222 --tag offsite`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
host.Name, host.Address = args[0], args[1]
if existing := cfg.StaticHost(host.Name); existing != nil {
if !replace {
return fmt.Errorf("static host %q already exists; use --replace to overwrite it", existing.Name)
}
host.SSH = existing.SSH // keep SSH options set in the config or wizard
*existing = host
} else {
cfg.Hosts = append(cfg.Hosts, host)
}
return saveHosts(cfg)
},
}
cmd.Flags().StringVar(&host.User, "user", "", "Login user (default: ssh.default_user)")
cmd.Flags().IntVar(&host.Port, "port", 0, "SSH port (default: ssh.default_port or 22)")
cmd.Flags().StringSliceVar(&host.Tags, "tag", nil, "Tag for ssh.rules (repeatable)")
cmd.Flags().BoolVar(&replace, "replace", false, "Overwrite an existing static host")
return cmd
}
func hostsRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove NAME",
Aliases: []string{"rm"},
Short: "Remove a static host",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
for i, host := range cfg.Hosts {
if strings.EqualFold(host.Name, args[0]) {
cfg.Hosts = append(cfg.Hosts[:i], cfg.Hosts[i+1:]...)
return saveHosts(cfg)
}
}
return fmt.Errorf("no static host %q", args[0])
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
cfg, err := config.Load()
if err != nil || len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var names []string
for _, host := range cfg.Hosts {
if strings.HasPrefix(strings.ToLower(host.Name), strings.ToLower(toComplete)) {
names = append(names, host.Name)
}
}
return names, cobra.ShellCompDirectiveNoFileComp
},
}
}
// saveHosts writes the config and regenerates the shell aliases.
func saveHosts(cfg *config.Config) error {
if err := setup.Save(*cfg); err != nil {
return err
}
loaded, err := config.Load()
if err != nil {
return err
}
c := newCache(loaded)
_ = c.Load()
if err := hook.WriteAliasFiles(hook.BuildAliasEntries(c.All(), *loaded)); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
}
return nil
}