From 2b46c4f74d14ab605f2b4449aaadb67f29f5efe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Wed, 28 Oct 2026 11:23:09 +0100 Subject: [PATCH] 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= and -o HostKeyAlias=, 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 --- cmd/netssh/main.go | 47 +++++--- internal/config/config.go | 16 ++- internal/config/ssh.go | 181 ++++++++++++++++++++++++++++++ internal/config/ssh_test.go | 90 +++++++++++++++ internal/setup/wizard.go | 56 ++++++++++ internal/setup/wizard_test.go | 37 ++++++- internal/ssh/options.go | 200 ++++++++++++++++++++++++++++++++++ internal/ssh/options_test.go | 130 ++++++++++++++++++++++ 8 files changed, 738 insertions(+), 19 deletions(-) create mode 100644 internal/config/ssh.go create mode 100644 internal/config/ssh_test.go create mode 100644 internal/ssh/options.go create mode 100644 internal/ssh/options_test.go diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index 9e4dbe0..c8157ea 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -81,16 +81,21 @@ func loadConfigOrSetup() *config.Config { // runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary. func runSSHWrapper(args []string) { + flags, args, err := internalssh.ExtractWrapperFlags(args) + if err != nil { + fatalf("%v", err) + } cfg := loadConfigOrSetup() 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) - } + execSSH(internalssh.Result{Args: args}, flags) return } + connect := func(name, kind, ip string, tags []string) { + connectHost(cfg, flags, name, kind, ip, tags, parsed.DestIdx, args) + } // Inject the configured default user if none was given on the command line. if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) { @@ -114,7 +119,7 @@ func runSSHWrapper(args []string) { if entry, fresh := c.Get(parsed.Host); fresh && useCache { c.MarkUsed(parsed.Host) _ = c.Save() - connect(entry.IP, parsed, args) + connect(entry.Name, entry.Kind, entry.IP, entry.Tags) return } @@ -132,7 +137,7 @@ func runSSHWrapper(args []string) { if fresh { c.MarkUsed(entry.Name) _ = c.Save() - connect(entry.IP, parsed, args) + connect(entry.Name, entry.Kind, entry.IP, entry.Tags) return } // Stale shortcut match — use canonical name for NetBox re-fetch. @@ -165,7 +170,7 @@ func runSSHWrapper(args []string) { if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind { c.MarkUsed(target.Target()) _ = c.Save() - connect(cached.IP, parsed, args) + connect(cached.Name, cached.Kind, cached.IP, cached.Tags) return } @@ -183,7 +188,7 @@ func runSSHWrapper(args []string) { c.MarkUsed(target.Target()) _ = c.Save() - connect(ip, parsed, args) + connect(target.Target(), target.Kind, ip, target.Tags) } // findSSHHost uses the paginated, ambiguity-checking lookup for connection @@ -206,9 +211,24 @@ func findSSHHost(ctx context.Context, client *netbox.Client, name string, domain return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name) } -func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) { - newArgs := internalssh.ReplaceHost(originalArgs, parsed.DestIdx, ip) - if err := internalssh.Exec(newArgs); err != nil { +// connectHost applies the SSH options configured for the host and execs ssh. +// target is the cache/selector name, which may carry an instance prefix. +func connectHost(cfg *config.Config, flags internalssh.WrapperFlags, target, kind, ip string, tags []string, destIdx int, args []string) { + instance, name := netbox.SplitTarget(target) + opts := cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: kind, Tags: tags}) + execSSH(internalssh.Apply(args, destIdx, internalssh.Host{Name: name, IP: ip}, opts, flags), flags) +} + +// execSSH replaces the process with ssh, or prints the command for --dry-run. +func execSSH(result internalssh.Result, flags internalssh.WrapperFlags) { + if flags.DryRun { + fmt.Println(internalssh.Quote(append([]string{"ssh"}, result.Args...))) + return + } + if result.Notice != "" { + fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice) + } + if err := internalssh.Exec(result.Args); err != nil { fatalf("%v", err) } } @@ -244,8 +264,10 @@ func runTUI() { } // Revalidate stale or live TUI results through the configured resolver chain. + var tags []string if cached, fresh := c.Get(host.Name); fresh && cached.Kind == host.Kind { host.IP = cached.IP + tags = cached.Tags } else { if nbClient == nil { fatalf("cannot resolve %q: NetBox is not configured", host.Name) @@ -265,6 +287,7 @@ func runTUI() { fatalf("IP resolution: %v", err) } c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags}) + tags = target.Tags } fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP) @@ -292,9 +315,7 @@ func runTUI() { c.MarkUsed(host.Name) _ = c.Save() - if err := internalssh.Exec(sshArgs); err != nil { - fatalf("%v", err) - } + connectHost(cfg, internalssh.WrapperFlags{}, host.Name, host.Kind, host.IP, tags, len(sshArgs)-1, sshArgs) } // --- cobra root + subcommands --- diff --git a/internal/config/config.go b/internal/config/config.go index a4377ad..c498b9c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/config/ssh.go b/internal/config/ssh.go new file mode 100644 index 0000000..0e55e09 --- /dev/null +++ b/internal/config/ssh.go @@ -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)), "-") +} diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go new file mode 100644 index 0000000..a6780bc --- /dev/null +++ b/internal/config/ssh_test.go @@ -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) + } + } +} diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index f346579..9b78b26 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" @@ -246,10 +247,18 @@ func save(cfg config.Config) error { b.WriteString("netboxes:\n") for _, source := range cfg.NetBoxes { fmt.Fprintf(&b, " - name: %q\n url: %q\n token: %q\n token_version: %d\n", source.Name, source.URL, source.Token, source.TokenVersion) + if !sshOptionsEmpty(source.SSH) { + b.WriteString(" ssh:\n") + writeSSHOptions(&b, " ", source.SSH) + } } } else { b.WriteString("netbox:\n") fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) + if !sshOptionsEmpty(cfg.NetBox.SSH) { + b.WriteString(" ssh:\n") + writeSSHOptions(&b, " ", cfg.NetBox.SSH) + } } b.WriteString("\nresolver:\n") @@ -280,6 +289,23 @@ func save(cfg config.Config) error { if cfg.SSH.DefaultPort > 0 { fmt.Fprintf(&b, " default_port: %d\n", cfg.SSH.DefaultPort) } + writeSSHOptions(&b, " ", cfg.SSH.SSHOptions) + if len(cfg.SSH.Rules) > 0 { + b.WriteString(" rules:\n") + for _, rule := range cfg.SSH.Rules { + b.WriteString(" - match:\n") + for _, field := range [][2]string{{"tag", rule.Match.Tag}, {"instance", rule.Match.Instance}, {"kind", rule.Match.Kind}, {"name", rule.Match.Name}} { + if field[1] != "" { + fmt.Fprintf(&b, " %s: %q\n", field[0], field[1]) + } + } + writeSSHOptions(&b, " ", rule.SSHOptions) + } + } + + if cfg.Completion.MaxResults > 0 { + fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults) + } if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens { b.WriteString("\nshortcuts:\n") @@ -305,3 +331,33 @@ func save(cfg config.Config) error { fmt.Printf("\nConfig saved → %s\n", path) return nil } + +func sshOptionsEmpty(o config.SSHOptions) bool { + return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0 +} + +// writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config. +func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) { + for _, field := range [][2]string{{"proxy_jump", o.ProxyJump}, {"host_key_checking", o.HostKeyChecking}, {"target", o.Target}, {"address_family", o.AddressFamily}} { + if field[1] != "" { + fmt.Fprintf(b, "%s%s: %q\n", indent, field[0], field[1]) + } + } + if len(o.ExtraArgs) > 0 { + fmt.Fprintf(b, "%sextra_args:\n", indent) + for _, arg := range o.ExtraArgs { + fmt.Fprintf(b, "%s - %q\n", indent, arg) + } + } + if len(o.Options) > 0 { + keys := make([]string, 0, len(o.Options)) + for key := range o.Options { + keys = append(keys, key) + } + sort.Strings(keys) + fmt.Fprintf(b, "%soptions:\n", indent) + for _, key := range keys { + fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key]) + } + } +} diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index 077e5c2..4469300 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -3,6 +3,7 @@ package setup import ( "os" "path/filepath" + "reflect" "strings" "testing" @@ -408,7 +409,7 @@ func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) { t.Fatalf("instances lost during save: %+v", loaded) } for i, want := range cfg.NetBoxes { - if loaded.NetBoxes[i] != want { + if !reflect.DeepEqual(loaded.NetBoxes[i], want) { t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want) } } @@ -420,3 +421,37 @@ func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) { t.Fatal("credentials are not private") } } + +func TestSaveKeepsSSHOptions(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + opts := config.SSHOptions{ + ProxyJump: "jh.example.com", + HostKeyChecking: "accept-new", + Target: "name", + AddressFamily: "inet", + ExtraArgs: []string{"-A"}, + Options: map[string]string{"serveraliveinterval": "60"}, + } + cfg := config.Config{ + NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", TokenVersion: 1, SSH: config.SSHOptions{HostKeyChecking: "off"}}}, + Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}}, + Cache: config.CacheConfig{TTL: 3600}, + SSH: config.SSHConfig{ + DefaultUser: "root", + SSHOptions: opts, + Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz", Name: "*.dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}}, + }, + Completion: config.CompletionConfig{MaxResults: 100}, + } + if err := save(cfg); err != nil { + t.Fatal(err) + } + loaded, err := config.Load() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(loaded.SSH, cfg.SSH) || !reflect.DeepEqual(loaded.NetBoxes[0].SSH, cfg.NetBoxes[0].SSH) || loaded.Completion != cfg.Completion { + t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH) + } +} diff --git a/internal/ssh/options.go b/internal/ssh/options.go new file mode 100644 index 0000000..0542140 --- /dev/null +++ b/internal/ssh/options.go @@ -0,0 +1,200 @@ +package ssh + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +// WrapperFlags are netssh's own long options. OpenSSH has no long options, +// so these never collide with flags meant for ssh. +type WrapperFlags struct { + DryRun bool + Jump string // --jump HOST overrides the configured jump host + NoJump bool // --no-jump disables any jump host, including ~/.ssh/config +} + +// ExtractWrapperFlags removes netssh's long options that appear before the +// destination. Anything after the destination belongs to the remote command. +func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) { + var flags WrapperFlags + out := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--dry-run": + flags.DryRun = true + case arg == "--no-jump": + flags.NoJump = true + case arg == "--jump": + if i+1 >= len(args) { + return flags, nil, fmt.Errorf("--jump requires a host") + } + i++ + flags.Jump = args[i] + case strings.HasPrefix(arg, "--jump="): + flags.Jump = strings.TrimPrefix(arg, "--jump=") + case arg == "--" || !strings.HasPrefix(arg, "-") || len(arg) < 2: + // Destination or end of options: keep the rest unchanged. + return flags, append(out, args[i:]...), validateJump(flags) + case strings.HasPrefix(arg, "--"): + return flags, nil, fmt.Errorf("unknown option %s", arg) + default: + out = append(out, arg) + // Keep the value of an ssh option such as -l USER together with it. + if takesSeparateValue(arg) && i+1 < len(args) { + i++ + out = append(out, args[i]) + } + } + } + return flags, out, validateJump(flags) +} + +func validateJump(flags WrapperFlags) error { + if flags.Jump == "" && flags.NoJump { + return nil + } + if flags.Jump != "" && flags.NoJump { + return fmt.Errorf("--jump and --no-jump are mutually exclusive") + } + if strings.HasPrefix(flags.Jump, "-") { + return fmt.Errorf("--jump: invalid host %q", flags.Jump) + } + return nil +} + +// takesSeparateValue reports whether a short option cluster ends with a flag +// whose value is the next argument (e.g. "-l" or "-vp"). +func takesSeparateValue(arg string) bool { + for j := 1; j < len(arg); j++ { + if flagsWithArg[arg[j]] { + return j == len(arg)-1 + } + } + return false +} + +// Host is a resolved NetBox host. +type Host struct { + Name string // NetBox name without the instance prefix + IP string +} + +// Result is the final ssh argument list and an optional notice for stderr. +type Result struct { + Args []string + Notice string +} + +var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`) + +// Apply replaces the destination at destIdx and inserts the configured -o +// options directly before it. Explicit options given by the user come first, +// and ssh uses the first -o value it obtains, so the user's choice wins. +func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags WrapperFlags) Result { + var inserted []string + add := func(keyword, value string) { + if !hasOption(args[:destIdx], 0, keyword) { + inserted = append(inserted, "-o", keyword+"="+value) + } + } + + dest := host.IP + if opts.Target == "name" || opts.Target == "hostname" { + if plainHostname.MatchString(host.Name) { + dest = host.Name + if opts.Target == "name" { + add("HostName", host.IP) + add("HostKeyAlias", host.Name) + } + } + } + + userJump := hasOption(args[:destIdx], 'J', "ProxyJump") || hasOption(args[:destIdx], 0, "ProxyCommand") + switch { + case userJump: + case flags.NoJump: + inserted = append(inserted, "-o", "ProxyJump=none") + case flags.Jump != "": + inserted = append(inserted, "-J", flags.Jump) + case opts.ProxyJump != "" && opts.ProxyJump != "none": + inserted = append(inserted, "-J", opts.ProxyJump) + } + + var notice string + switch opts.HostKeyChecking { + case "strict": + add("StrictHostKeyChecking", "yes") + case "accept-new": + add("StrictHostKeyChecking", "accept-new") + case "off": + if !hasOption(args[:destIdx], 0, "StrictHostKeyChecking") { + add("StrictHostKeyChecking", "no") + add("UserKnownHostsFile", "/dev/null") + add("LogLevel", "ERROR") + notice = "host key checking is disabled for this connection (ssh.host_key_checking: off)" + } + } + + if opts.AddressFamily != "" && !hasBoolFlag(args[:destIdx], '4') && !hasBoolFlag(args[:destIdx], '6') { + add("AddressFamily", opts.AddressFamily) + } + + keys := make([]string, 0, len(opts.Options)) + for key := range opts.Options { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + add(key, opts.Options[key]) + } + + // Extra arguments go first: short flags such as -4 or -A take the last + // value given, so the user's own flags later on the line still win. + replaced := ReplaceHost(args, destIdx, dest) + out := make([]string, 0, len(opts.ExtraArgs)+len(replaced)+len(inserted)) + out = append(out, opts.ExtraArgs...) + out = append(out, replaced[:destIdx]...) + out = append(out, inserted...) + out = append(out, replaced[destIdx:]...) + return Result{Args: out, Notice: notice} +} + +// hasBoolFlag reports whether a flag without a value, such as -4, was given. +func hasBoolFlag(args []string, wanted byte) bool { + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" || len(arg) < 2 || arg[0] != '-' { + break + } + for j := 1; j < len(arg); j++ { + if arg[j] == wanted { + return true + } + if flagsWithArg[arg[j]] { + if j == len(arg)-1 { + i++ + } + break + } + } + } + return false +} + +// Quote renders an argument list as a copy-pasteable POSIX shell command. +func Quote(argv []string) string { + quoted := make([]string, len(argv)) + for i, arg := range argv { + if arg != "" && strings.Trim(arg, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") == "" { + quoted[i] = arg + continue + } + quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" + } + return strings.Join(quoted, " ") +} diff --git a/internal/ssh/options_test.go b/internal/ssh/options_test.go new file mode 100644 index 0000000..d7ed209 --- /dev/null +++ b/internal/ssh/options_test.go @@ -0,0 +1,130 @@ +package ssh + +import ( + "reflect" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +func TestExtractWrapperFlags(t *testing.T) { + for _, tc := range []struct { + args []string + flags WrapperFlags + rest []string + }{ + {[]string{"--dry-run", "web"}, WrapperFlags{DryRun: true}, []string{"web"}}, + {[]string{"-l", "--dry-run", "--jump", "jh", "web"}, WrapperFlags{Jump: "jh"}, []string{"-l", "--dry-run", "web"}}, + {[]string{"--jump=jh", "-vp", "22", "web", "--dry-run"}, WrapperFlags{Jump: "jh"}, []string{"-vp", "22", "web", "--dry-run"}}, + {[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}}, + } { + flags, rest, err := ExtractWrapperFlags(tc.args) + if err != nil || flags != tc.flags || !reflect.DeepEqual(rest, tc.rest) { + t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err) + } + } + for _, args := range [][]string{{"--jump"}, {"--jump", "-x", "web"}, {"--jump=a", "--no-jump", "web"}, {"--version-typo", "web"}} { + if _, _, err := ExtractWrapperFlags(args); err == nil { + t.Errorf("%v accepted", args) + } + } +} + +func apply(args []string, opts config.SSHOptions, flags WrapperFlags) Result { + return Apply(args, len(args)-1, Host{Name: "web.example.com", IP: "192.0.2.10"}, opts, flags) +} + +func TestApplyDefaultConnectsToIP(t *testing.T) { + got := apply([]string{"-l", "root", "web"}, config.SSHOptions{}, WrapperFlags{}) + if want := []string{"-l", "root", "192.0.2.10"}; !reflect.DeepEqual(got.Args, want) { + t.Fatalf("got %v", got.Args) + } +} + +func TestApplyTargetName(t *testing.T) { + got := apply([]string{"admin@web"}, config.SSHOptions{Target: "name"}, WrapperFlags{}) + want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web.example.com", "admin@web.example.com"} + if !reflect.DeepEqual(got.Args, want) { + t.Fatalf("got %v", got.Args) + } + got = apply([]string{"web"}, config.SSHOptions{Target: "hostname"}, WrapperFlags{}) + if want := []string{"web.example.com"}; !reflect.DeepEqual(got.Args, want) { + t.Fatalf("got %v", got.Args) + } + // Names that are not plain hostnames fall back to the IP address. + got = Apply([]string{"x"}, 0, Host{Name: "rack 1 switch", IP: "192.0.2.1"}, config.SSHOptions{Target: "name"}, WrapperFlags{}) + if want := []string{"192.0.2.1"}; !reflect.DeepEqual(got.Args, want) { + t.Fatalf("got %v", got.Args) + } +} + +func TestApplyJumpPrecedence(t *testing.T) { + opts := config.SSHOptions{ProxyJump: "jh.example.com"} + for _, tc := range []struct { + args []string + flags WrapperFlags + want string + }{ + {[]string{"web"}, WrapperFlags{}, "-J jh.example.com 192.0.2.10"}, + {[]string{"web"}, WrapperFlags{Jump: "other"}, "-J other 192.0.2.10"}, + {[]string{"web"}, WrapperFlags{NoJump: true}, "-o ProxyJump=none 192.0.2.10"}, + {[]string{"-J", "mine", "web"}, WrapperFlags{}, "-J mine 192.0.2.10"}, + {[]string{"-oProxyCommand=nc %h %p", "web"}, WrapperFlags{}, "-oProxyCommand=nc %h %p 192.0.2.10"}, + } { + if got := strings.Join(apply(tc.args, opts, tc.flags).Args, " "); got != tc.want { + t.Errorf("%v %+v: got %q, want %q", tc.args, tc.flags, got, tc.want) + } + } + if got := apply([]string{"web"}, config.SSHOptions{ProxyJump: "none"}, WrapperFlags{}); len(got.Args) != 1 { + t.Fatalf("proxy_jump none still jumps: %v", got.Args) + } +} + +func TestApplyHostKeyChecking(t *testing.T) { + got := apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{}) + want := "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR 192.0.2.10" + if strings.Join(got.Args, " ") != want || got.Notice == "" { + t.Fatalf("got %v, notice %q", got.Args, got.Notice) + } + // An explicit user choice disables the whole "off" bundle. + got = apply([]string{"-o", "StrictHostKeyChecking=yes", "web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{}) + if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=yes 192.0.2.10" || got.Notice != "" { + t.Fatalf("user choice overridden: %v", got.Args) + } + got = apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{}) + if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=accept-new 192.0.2.10" { + t.Fatalf("got %v", got.Args) + } +} + +func TestApplyOptionsAndExtraArgs(t *testing.T) { + opts := config.SSHOptions{ + AddressFamily: "inet", + ExtraArgs: []string{"-A"}, + Options: map[string]string{"serveraliveinterval": "60", "user": "ignored"}, + } + got := apply([]string{"-o", "User=me", "web"}, opts, WrapperFlags{}) + want := "-A -o User=me -o AddressFamily=inet -o serveraliveinterval=60 192.0.2.10" + if strings.Join(got.Args, " ") != want { + t.Fatalf("got %q", strings.Join(got.Args, " ")) + } + got = apply([]string{"-v6", "web"}, config.SSHOptions{AddressFamily: "inet"}, WrapperFlags{}) + if strings.Join(got.Args, " ") != "-v6 192.0.2.10" { + t.Fatalf("address family overrides -6: %v", got.Args) + } +} + +func TestApplyKeepsRemoteCommand(t *testing.T) { + got := Apply([]string{"web", "uptime", "-o", "x"}, 0, Host{Name: "web", IP: "192.0.2.1"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{}) + if want := []string{"-J", "jh", "192.0.2.1", "uptime", "-o", "x"}; !reflect.DeepEqual(got.Args, want) { + t.Fatalf("got %v", got.Args) + } +} + +func TestQuote(t *testing.T) { + got := Quote([]string{"ssh", "-o", "ProxyCommand=nc %h %p", "it's", "", "a@b:22"}) + if want := `ssh -o 'ProxyCommand=nc %h %p' 'it'\''s' '' a@b:22`; got != want { + t.Fatalf("got %s", got) + } +}