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:
1 parent
5821dfbebd
commit
a950a82552
22 files changed
+1024
-49
No files matched your search
@@ -19,6 +19,7 @@ type Config struct {
|
||||
SSH SSHConfig `mapstructure:"ssh"`
|
||||
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||
Completion CompletionConfig `mapstructure:"completion"`
|
||||
Hosts []StaticHost `mapstructure:"hosts"`
|
||||
}
|
||||
|
||||
type ShellConfig struct {
|
||||
@@ -37,6 +38,8 @@ type ResolverConfig struct {
|
||||
Strategies []string `mapstructure:"strategies"`
|
||||
ManagementSubnets []string `mapstructure:"management_subnets"`
|
||||
InterfaceName string `mapstructure:"interface_name"`
|
||||
// HostnameDomain is appended by the hostname strategy to names without a dot.
|
||||
HostnameDomain string `mapstructure:"hostname_domain"`
|
||||
}
|
||||
|
||||
type CacheConfig struct {
|
||||
@@ -114,7 +117,7 @@ func Load() (*Config, error) {
|
||||
v.SetEnvPrefix("NETSSH")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} {
|
||||
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} {
|
||||
if err := v.BindEnv(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -157,6 +160,9 @@ func Load() (*Config, error) {
|
||||
if err := cfg.Shortcuts.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.ValidateHosts(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range cfg.NetBoxes {
|
||||
source := &cfg.NetBoxes[i]
|
||||
source.Name = strings.ToLower(source.Name)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StaticKind is the kind of hosts defined under hosts: instead of NetBox.
|
||||
const StaticKind = "static"
|
||||
|
||||
// StaticHost is a host that is not in NetBox, defined in the config.
|
||||
type StaticHost struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Address string `mapstructure:"address"` // IP address or DNS name
|
||||
User string `mapstructure:"user"`
|
||||
Port int `mapstructure:"port"`
|
||||
Tags []string `mapstructure:"tags"`
|
||||
SSH SSHOptions `mapstructure:"ssh"`
|
||||
}
|
||||
|
||||
var staticHostName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
|
||||
// ValidateHosts keeps static host names usable as selectors and addresses
|
||||
// safe to pass to ssh.
|
||||
func (c *Config) ValidateHosts() error {
|
||||
seen := make(map[string]bool)
|
||||
for i, host := range c.Hosts {
|
||||
scope := fmt.Sprintf("hosts[%d]", i)
|
||||
if !staticHostName.MatchString(host.Name) {
|
||||
return fmt.Errorf("%s: name %q must start with a letter or digit and contain only letters, digits, dots, underscores, or hyphens", scope, host.Name)
|
||||
}
|
||||
name := strings.ToLower(host.Name)
|
||||
if seen[name] {
|
||||
return fmt.Errorf("%s: duplicate host name %q", scope, host.Name)
|
||||
}
|
||||
seen[name] = true
|
||||
if host.Address == "" || strings.HasPrefix(host.Address, "-") || strings.ContainsAny(host.Address, " \t\n\r/@") {
|
||||
return fmt.Errorf("%s: address %q must be an IP address or hostname", scope, host.Address)
|
||||
}
|
||||
if host.Port < 0 || host.Port > 65535 {
|
||||
return fmt.Errorf("%s: port must be between 1 and 65535", scope)
|
||||
}
|
||||
if strings.HasPrefix(host.User, "-") || strings.ContainsAny(host.User, " \t\n\r@") {
|
||||
return fmt.Errorf("%s: invalid user %q", scope, host.User)
|
||||
}
|
||||
if err := host.SSH.Validate(scope + ".ssh"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StaticHost returns the static host with the given name, or nil.
|
||||
func (c *Config) StaticHost(name string) *StaticHost {
|
||||
for i := range c.Hosts {
|
||||
if strings.EqualFold(c.Hosts[i].Name, name) {
|
||||
return &c.Hosts[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -40,6 +40,9 @@ func (c *Config) ValidateNetBoxes() error {
|
||||
|
||||
func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 0 }
|
||||
|
||||
// HasHosts reports whether netssh has anything to connect to: NetBox or static hosts.
|
||||
func (c *Config) HasHosts() bool { return c.HasNetBox() || len(c.Hosts) > 0 }
|
||||
|
||||
// CachePath isolates named sources from legacy caches and from changed endpoints.
|
||||
// Rotating a token or reordering the sources does not discard usage history.
|
||||
func (c *Config) CachePath() string {
|
||||
|
||||
@@ -169,7 +169,11 @@ func (o SSHOptions) Validate(scope string) error {
|
||||
// SSHOptionsFor merges global, instance, and matching rule options for host.
|
||||
func (c *Config) SSHOptionsFor(host SSHHost) SSHOptions {
|
||||
merged := c.SSH.SSHOptions.merge(SSHOptions{})
|
||||
if host.Instance == "" {
|
||||
if host.Kind == StaticKind {
|
||||
if static := c.StaticHost(host.Name); static != nil {
|
||||
merged = merged.merge(static.SSH)
|
||||
}
|
||||
} else if host.Instance == "" {
|
||||
merged = merged.merge(c.NetBox.SSH)
|
||||
}
|
||||
for _, source := range c.NetBoxes {
|
||||
|
||||
@@ -135,3 +135,58 @@ func TestForwardsValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHosts(t *testing.T) {
|
||||
cfg, err := loadYAML(t, `
|
||||
resolver:
|
||||
hostname_domain: example.com
|
||||
ssh:
|
||||
proxy_jump: jh
|
||||
rules:
|
||||
- match: {kind: static, tag: home}
|
||||
host_key_checking: off
|
||||
hosts:
|
||||
- name: Router
|
||||
address: 192.168.1.1
|
||||
user: admin
|
||||
port: 2222
|
||||
tags: [home]
|
||||
ssh:
|
||||
proxy_jump: none
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.HasHosts() || cfg.HasNetBox() || cfg.Resolver.HostnameDomain != "example.com" {
|
||||
t.Fatalf("got %+v", cfg)
|
||||
}
|
||||
host := cfg.StaticHost("router")
|
||||
if host == nil || host.Address != "192.168.1.1" || host.User != "admin" || host.Port != 2222 {
|
||||
t.Fatalf("host: %+v", host)
|
||||
}
|
||||
opts := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: StaticKind, Tags: []string{"home"}})
|
||||
if opts.ProxyJump != "none" || opts.HostKeyChecking != "off" {
|
||||
t.Fatalf("static host options: %+v", opts)
|
||||
}
|
||||
// A NetBox host of the same name does not get the static host's options.
|
||||
if got := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: "device"}); got.ProxyJump != "jh" {
|
||||
t.Fatalf("netbox host: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHostsValidation(t *testing.T) {
|
||||
for _, hosts := range []string{
|
||||
"- {name: a, address: 10.0.0.1}\n - {name: A, address: 10.0.0.2}",
|
||||
"- {name: 'bad name', address: 10.0.0.1}",
|
||||
"- {name: prod/a, address: 10.0.0.1}",
|
||||
"- {name: a}",
|
||||
"- {name: a, address: '-oProxyCommand=x'}",
|
||||
"- {name: a, address: 10.0.0.1, port: 70000}",
|
||||
"- {name: a, address: 10.0.0.1, user: 'a b'}",
|
||||
"- {name: a, address: 10.0.0.1, ssh: {target: nope}}",
|
||||
} {
|
||||
if _, err := loadYAML(t, "hosts:\n "+hosts+"\n"); err == nil || !strings.Contains(err.Error(), "hosts[") {
|
||||
t.Errorf("%s: got %v", hosts, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user