feat(ssh): jump hosts, host key checking, and configurable ssh options
Add SSH settings that apply only to netssh connections, so ~/.ssh/config does not need global changes: - proxy_jump (passed as -J; "none" disables an inherited jump host) - host_key_checking: strict | accept-new | off (off prints a notice) - target: ip (default) | name | hostname; "name" connects to the NetBox name with -o HostName=<ip> and -o HostKeyAlias=<name>, so `Host *.example.com` rules apply and known_hosts entries follow the device instead of a reused IP - address_family: any | inet | inet6 - options (-o Key=Value) and extra_args Settings layer global -> netboxes[].ssh -> ssh.rules (match by tag, instance, kind, or name glob). Options given on the command line always win: -o values are inserted after the user's own, and extra_args before them. New wrapper flags: --jump HOST, --no-jump, and --dry-run, which prints the final ssh command instead of connecting. The TUI uses the same options. The setup wizard keeps all new settings when it saves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
129a7201bc
commit
2b46c4f74d
8 files changed
+738
-19
No files matched your search
@@ -26,10 +26,11 @@ type ShellConfig struct {
|
||||
}
|
||||
|
||||
type NetBoxConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
URL string `mapstructure:"url"`
|
||||
Token string `mapstructure:"token"`
|
||||
TokenVersion int `mapstructure:"token_version"`
|
||||
Name string `mapstructure:"name"`
|
||||
URL string `mapstructure:"url"`
|
||||
Token string `mapstructure:"token"`
|
||||
TokenVersion int `mapstructure:"token_version"`
|
||||
SSH SSHOptions `mapstructure:"ssh"`
|
||||
}
|
||||
|
||||
type ResolverConfig struct {
|
||||
@@ -46,6 +47,8 @@ type CacheConfig struct {
|
||||
type SSHConfig struct {
|
||||
DefaultUser string `mapstructure:"default_user"`
|
||||
DefaultPort int `mapstructure:"default_port"`
|
||||
SSHOptions `mapstructure:",squash"`
|
||||
Rules []SSHRule `mapstructure:"rules"`
|
||||
}
|
||||
|
||||
type CompletionConfig struct {
|
||||
@@ -85,7 +88,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"} {
|
||||
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"} {
|
||||
if err := v.BindEnv(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -122,6 +125,9 @@ func Load() (*Config, error) {
|
||||
if err := cfg.ValidateNetBoxes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.ValidateSSH(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range cfg.NetBoxes {
|
||||
source := &cfg.NetBoxes[i]
|
||||
source.Name = strings.ToLower(source.Name)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHOptions are connection settings that can be set globally, per NetBox
|
||||
// instance, and per rule. More specific layers override less specific ones.
|
||||
type SSHOptions struct {
|
||||
// ProxyJump is passed as -J; "none" disables a jump host set by a broader layer.
|
||||
ProxyJump string `mapstructure:"proxy_jump"`
|
||||
// HostKeyChecking is strict, accept-new, or off. Empty keeps ssh's own setting.
|
||||
HostKeyChecking string `mapstructure:"host_key_checking"`
|
||||
// Target selects the ssh destination: ip (default), name, or hostname.
|
||||
Target string `mapstructure:"target"`
|
||||
// AddressFamily is any, inet, or inet6.
|
||||
AddressFamily string `mapstructure:"address_family"`
|
||||
// ExtraArgs are inserted verbatim before the destination.
|
||||
ExtraArgs []string `mapstructure:"extra_args"`
|
||||
// Options are passed as -o Key=Value.
|
||||
Options map[string]string `mapstructure:"options"`
|
||||
}
|
||||
|
||||
// SSHRule applies SSH options to hosts that match every non-empty field.
|
||||
type SSHRule struct {
|
||||
Match SSHMatch `mapstructure:"match"`
|
||||
SSHOptions `mapstructure:",squash"`
|
||||
}
|
||||
|
||||
// SSHMatch selects hosts by NetBox attributes available in the cache.
|
||||
type SSHMatch struct {
|
||||
Tag string `mapstructure:"tag"`
|
||||
Instance string `mapstructure:"instance"`
|
||||
Kind string `mapstructure:"kind"`
|
||||
Name string `mapstructure:"name"` // shell-style glob, e.g. "*.dmz.example.com"
|
||||
}
|
||||
|
||||
// SSHHost identifies the resolved host that SSH options are computed for.
|
||||
type SSHHost struct {
|
||||
Instance string
|
||||
Name string // NetBox name without the instance prefix
|
||||
Kind string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
var sshOptionKey = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`)
|
||||
|
||||
// ValidateSSH rejects values that would be passed to ssh in an unexpected form.
|
||||
func (c *Config) ValidateSSH() error {
|
||||
if err := c.SSH.SSHOptions.validate("ssh"); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, rule := range c.SSH.Rules {
|
||||
if err := rule.SSHOptions.validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil {
|
||||
return err
|
||||
}
|
||||
if rule.Match == (SSHMatch{}) {
|
||||
return fmt.Errorf("ssh.rules[%d]: match must set at least one of tag, instance, kind, name", i)
|
||||
}
|
||||
if _, err := path.Match(rule.Match.Name, ""); err != nil {
|
||||
return fmt.Errorf("ssh.rules[%d]: invalid name pattern %q", i, rule.Match.Name)
|
||||
}
|
||||
}
|
||||
if err := c.NetBox.SSH.validate("netbox.ssh"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range c.NetBoxes {
|
||||
if err := source.SSH.validate("netboxes." + source.Name + ".ssh"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o SSHOptions) validate(scope string) error {
|
||||
switch o.HostKeyChecking {
|
||||
case "", "strict", "accept-new", "off":
|
||||
default:
|
||||
return fmt.Errorf("%s.host_key_checking must be strict, accept-new, or off", scope)
|
||||
}
|
||||
switch o.Target {
|
||||
case "", "ip", "name", "hostname":
|
||||
default:
|
||||
return fmt.Errorf("%s.target must be ip, name, or hostname", scope)
|
||||
}
|
||||
switch o.AddressFamily {
|
||||
case "", "any", "inet", "inet6":
|
||||
default:
|
||||
return fmt.Errorf("%s.address_family must be any, inet, or inet6", scope)
|
||||
}
|
||||
if strings.HasPrefix(o.ProxyJump, "-") || strings.ContainsAny(o.ProxyJump, " \t\n") {
|
||||
return fmt.Errorf("%s.proxy_jump %q is not a valid jump host", scope, o.ProxyJump)
|
||||
}
|
||||
for key, value := range o.Options {
|
||||
if !sshOptionKey.MatchString(key) || strings.ContainsAny(value, "\n\r") {
|
||||
return fmt.Errorf("%s.options: invalid option %q", scope, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
merged = merged.merge(c.NetBox.SSH)
|
||||
}
|
||||
for _, source := range c.NetBoxes {
|
||||
if strings.EqualFold(source.Name, host.Instance) {
|
||||
merged = merged.merge(source.SSH)
|
||||
}
|
||||
}
|
||||
for _, rule := range c.SSH.Rules {
|
||||
if rule.Match.matches(host) {
|
||||
merged = merged.merge(rule.SSHOptions)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// merge returns o overridden by the non-empty fields of override.
|
||||
// Extra arguments accumulate; options override per key.
|
||||
func (o SSHOptions) merge(override SSHOptions) SSHOptions {
|
||||
out := o
|
||||
out.ExtraArgs = append(append([]string(nil), o.ExtraArgs...), override.ExtraArgs...)
|
||||
out.Options = make(map[string]string, len(o.Options)+len(override.Options))
|
||||
for key, value := range o.Options {
|
||||
out.Options[strings.ToLower(key)] = value
|
||||
}
|
||||
for key, value := range override.Options {
|
||||
out.Options[strings.ToLower(key)] = value
|
||||
}
|
||||
if override.ProxyJump != "" {
|
||||
out.ProxyJump = override.ProxyJump
|
||||
}
|
||||
if override.HostKeyChecking != "" {
|
||||
out.HostKeyChecking = override.HostKeyChecking
|
||||
}
|
||||
if override.Target != "" {
|
||||
out.Target = override.Target
|
||||
}
|
||||
if override.AddressFamily != "" {
|
||||
out.AddressFamily = override.AddressFamily
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m SSHMatch) matches(host SSHHost) bool {
|
||||
if m.Instance != "" && !strings.EqualFold(m.Instance, host.Instance) {
|
||||
return false
|
||||
}
|
||||
if m.Kind != "" && !strings.EqualFold(m.Kind, host.Kind) {
|
||||
return false
|
||||
}
|
||||
if m.Name != "" {
|
||||
if ok, _ := path.Match(strings.ToLower(m.Name), strings.ToLower(host.Name)); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if m.Tag != "" {
|
||||
found := false
|
||||
for _, tag := range host.Tags {
|
||||
if strings.EqualFold(tag, m.Tag) || strings.EqualFold(tagSlug(tag), m.Tag) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// tagSlug approximates NetBox's slug so rules may use either the tag name or slug.
|
||||
func tagSlug(name string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(name)), "-")
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadYAML(t *testing.T, data string) (*Config, error) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Load()
|
||||
}
|
||||
|
||||
func TestSSHOptionsLayering(t *testing.T) {
|
||||
cfg, err := loadYAML(t, `
|
||||
netboxes:
|
||||
- name: prod
|
||||
url: https://prod.example.com
|
||||
ssh:
|
||||
proxy_jump: jh-prod.example.com
|
||||
host_key_checking: strict
|
||||
- name: staging
|
||||
url: https://staging.example.com
|
||||
ssh:
|
||||
host_key_checking: off
|
||||
ssh:
|
||||
proxy_jump: jh.example.com
|
||||
extra_args: ["-4"]
|
||||
options:
|
||||
ServerAliveInterval: 60
|
||||
ServerAliveCountMax: 5
|
||||
rules:
|
||||
- match: {tag: dmz}
|
||||
proxy_jump: jh-dmz.example.com
|
||||
options: {ServerAliveInterval: 10}
|
||||
- match: {instance: prod, name: "db*"}
|
||||
proxy_jump: none
|
||||
completion:
|
||||
max_results: 200
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Completion.MaxResults != 200 {
|
||||
t.Fatalf("max_results: %d", cfg.Completion.MaxResults)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
host SSHHost
|
||||
jump, keys, alive string
|
||||
}{
|
||||
{SSHHost{Name: "web"}, "jh.example.com", "", "60"},
|
||||
{SSHHost{Instance: "prod", Name: "web"}, "jh-prod.example.com", "strict", "60"},
|
||||
{SSHHost{Instance: "staging", Name: "web", Tags: []string{"DMZ"}}, "jh-dmz.example.com", "off", "10"},
|
||||
{SSHHost{Instance: "prod", Name: "db01", Tags: []string{"dmz"}}, "none", "strict", "10"},
|
||||
} {
|
||||
got := cfg.SSHOptionsFor(tc.host)
|
||||
if got.ProxyJump != tc.jump || got.HostKeyChecking != tc.keys || got.Options["serveraliveinterval"] != tc.alive || got.Options["serveralivecountmax"] != "5" {
|
||||
t.Errorf("%+v: got %+v", tc.host, got)
|
||||
}
|
||||
if !reflect.DeepEqual(got.ExtraArgs, []string{"-4"}) {
|
||||
t.Errorf("extra args: %v", got.ExtraArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHOptionsValidation(t *testing.T) {
|
||||
for _, data := range []string{
|
||||
"ssh:\n host_key_checking: maybe\n",
|
||||
"ssh:\n target: dns\n",
|
||||
"ssh:\n address_family: ipx\n",
|
||||
"ssh:\n proxy_jump: \"-oProxyCommand=x\"\n",
|
||||
"ssh:\n options:\n \"bad key\": 1\n",
|
||||
"ssh:\n rules:\n - proxy_jump: jh\n",
|
||||
"netboxes:\n - name: a\n url: https://a.example\n ssh:\n target: nope\n",
|
||||
} {
|
||||
if _, err := loadYAML(t, data); err == nil {
|
||||
t.Errorf("accepted invalid config:\n%s", data)
|
||||
} else if !strings.Contains(err.Error(), "ssh") {
|
||||
t.Errorf("unclear error %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user