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>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-13 23:46:13 +01:00
1 parent 5821dfbebd
commit a950a82552
22 files changed
+1024 -49

No files matched your search

+59 -23
View File
@@ -45,6 +45,7 @@ var managedSubcommands = map[string]bool{
"sftp": true,
"scp": true,
"tunnel": true,
"hosts": true,
}
func main() {
@@ -75,7 +76,7 @@ func loadConfigOrSetup() *config.Config {
if err != nil {
fatalf("config: %v", err)
}
if !cfg.HasNetBox() {
if !cfg.HasHosts() {
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
if err := setup.RunWizard(cfg); err != nil {
fatalf("setup: %v", err)
@@ -143,16 +144,35 @@ func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args [
return
}
// Inject the configured default user if none was given on the command line.
if cfg.SSH.DefaultUser != "" && dest.user == "" && !client.HasUserFlag(args) {
args = prependArgs(client.UserArgs(cfg.SSH.DefaultUser), args, dest.dests)
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 configured default port if none was given on the command line.
if cfg.SSH.DefaultPort > 0 && !client.HasPortFlag(args) {
args = prependArgs(client.PortArgs(strconv.Itoa(cfg.SSH.DefaultPort)), 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, resolveTarget(cfg, dest.host), dest.dests, args)
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
}
// prependArgs puts extra before args and shifts the destination indices.
@@ -174,7 +194,7 @@ type resolvedHost struct {
// 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 := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c := newCache(cfg)
_ = c.Load()
maybeBackgroundRefresh(cfg, c)
@@ -186,6 +206,21 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost {
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)
@@ -211,7 +246,7 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost {
}
if !cfg.HasNetBox() {
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
fatalf("host %q is not in hosts: and no NetBox is configured (netssh configure)", host)
}
nbClient := newNetBoxClient(cfg)
@@ -316,7 +351,7 @@ func execClient(client internalssh.Client, result internalssh.Result, flags inte
func runTUI() {
cfg := loadConfigOrSetup()
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c := newCache(cfg)
_ = c.Load()
maybeBackgroundRefresh(cfg, c)
@@ -392,17 +427,18 @@ func tuiCommand(cfg *config.Config, host *tui.SelectedHost) (internalssh.Client,
}
var args []string
// User override from TUI edit mode takes priority, then config default.
// 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 = cfg.SSH.DefaultUser
user = defaultUser
}
if user != "" {
args = append(args, client.UserArgs(user)...)
}
port := host.Port
if port == "" && cfg.SSH.DefaultPort > 0 {
port = strconv.Itoa(cfg.SSH.DefaultPort)
if port == "" && defaultPort > 0 {
port = strconv.Itoa(defaultPort)
}
if port != "" {
args = append(args, client.PortArgs(port)...)
@@ -453,7 +489,7 @@ func rootCmd() *cobra.Command {
},
}
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd())
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd())
// cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd()
@@ -575,7 +611,7 @@ func searchCmd() *cobra.Command {
if err != nil {
return err
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c := newCache(cfg)
if err := c.Load(); err != nil {
return err
}
@@ -609,7 +645,7 @@ func cacheListCmd() *cobra.Command {
if err != nil {
return err
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c := newCache(cfg)
if err := c.Load(); err != nil {
return err
}
@@ -635,7 +671,7 @@ func cacheClearCmd() *cobra.Command {
if err != nil {
return err
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c := newCache(cfg)
c.Clear()
if err := c.Save(); err != nil {
return err
@@ -677,7 +713,7 @@ func cacheRefreshCmd() *cobra.Command {
return fmt.Errorf("NetBox: %w", err)
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c := newCache(cfg)
_ = c.Load()
chain, err := resolver.New(cfg.Resolver)
@@ -762,7 +798,7 @@ Or use in a script:
if err != nil {
return err
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c := newCache(cfg)
if err := c.Load(); err != nil {
return err
}
@@ -838,7 +874,7 @@ func shellInitCmd() *cobra.Command {
if err != nil {
return nil
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c := newCache(cfg)
_ = c.Load()
// Regenerate alias files from the current cache every shell start
@@ -897,7 +933,7 @@ After installation, reload your profile or open a new shell.`,
// Generate the aliases file immediately so it is ready on the next shell start.
cfg, cfgErr := config.Load()
if cfgErr == nil && cfg.HasNetBox() {
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c := newCache(cfg)
if _ = c.Load(); true {
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
if err := hook.WriteAliasFiles(entries); err != nil {