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)), "-") }