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>
182 lines
5.4 KiB
Go
182 lines
5.4 KiB
Go
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)), "-")
|
|
}
|