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>
258 lines
7.8 KiB
Go
258 lines
7.8 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"`
|
|
// Forwards are named port forwards, selected with --forward or netssh tunnel.
|
|
Forwards map[string]Forward `mapstructure:"forwards"`
|
|
}
|
|
|
|
// Forward is one port forward; exactly one of the fields is set.
|
|
type Forward struct {
|
|
Local string `mapstructure:"local"` // -L [bind:]port:host:hostport
|
|
Remote string `mapstructure:"remote"` // -R [bind:]port:host:hostport
|
|
Dynamic string `mapstructure:"dynamic"` // -D [bind:]port (SOCKS proxy)
|
|
}
|
|
|
|
// Flag returns the ssh arguments for the forward, e.g. -L 8080:localhost:80.
|
|
func (f Forward) Flag() []string {
|
|
switch {
|
|
case f.Local != "":
|
|
return []string{"-L", f.Local}
|
|
case f.Remote != "":
|
|
return []string{"-R", f.Remote}
|
|
case f.Dynamic != "":
|
|
return []string{"-D", f.Dynamic}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// String renders the forward as its ssh flag, e.g. "-L 8080:localhost:80".
|
|
func (f Forward) String() string { return strings.Join(f.Flag(), " ") }
|
|
|
|
func (f Forward) validate() error {
|
|
set := 0
|
|
for _, spec := range []string{f.Local, f.Remote, f.Dynamic} {
|
|
if spec == "" {
|
|
continue
|
|
}
|
|
set++
|
|
if strings.HasPrefix(spec, "-") || strings.ContainsAny(spec, " \t\n\r") {
|
|
return fmt.Errorf("invalid forward %q", spec)
|
|
}
|
|
}
|
|
if set != 1 {
|
|
return fmt.Errorf("set exactly one of local, remote, dynamic")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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.Validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// Validate checks the match and options of a single rule.
|
|
func (r SSHRule) Validate(scope string) error {
|
|
if err := r.SSHOptions.Validate(scope); err != nil {
|
|
return err
|
|
}
|
|
return r.Match.Validate(scope)
|
|
}
|
|
|
|
// Validate requires at least one selector and a well-formed name pattern.
|
|
func (m SSHMatch) Validate(scope string) error {
|
|
if m == (SSHMatch{}) {
|
|
return fmt.Errorf("%s: match must set at least one of tag, instance, kind, name", scope)
|
|
}
|
|
if _, err := path.Match(m.Name, ""); err != nil {
|
|
return fmt.Errorf("%s: invalid name pattern %q", scope, m.Name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate rejects values that ssh would not accept or would misinterpret.
|
|
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)
|
|
}
|
|
}
|
|
for name, forward := range o.Forwards {
|
|
if !instanceName.MatchString(name) {
|
|
return fmt.Errorf("%s.forwards: name %q must contain only letters, digits, underscores, or hyphens", scope, name)
|
|
}
|
|
if err := forward.validate(); err != nil {
|
|
return fmt.Errorf("%s.forwards.%s: %v", scope, name, err)
|
|
}
|
|
}
|
|
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.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 {
|
|
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
|
|
}
|
|
// Forwards override per name, so a rule can redefine a global "web" forward.
|
|
out.Forwards = make(map[string]Forward, len(o.Forwards)+len(override.Forwards))
|
|
for name, forward := range o.Forwards {
|
|
out.Forwards[strings.ToLower(name)] = forward
|
|
}
|
|
for name, forward := range override.Forwards {
|
|
out.Forwards[strings.ToLower(name)] = forward
|
|
}
|
|
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)), "-")
|
|
}
|