Add netssh sftp and netssh scp. Both resolve the host like a connection does (cache, shortcuts and rewrites, instance/host, default user and port, SSH options and rules, --jump/--no-jump, --dry-run) and get shell completion. The per-client flag tables handle the differences from ssh: the port is -P, -l is a bandwidth limit (the default user goes in as -o User=), and IPv6 addresses are bracketed in host:path. All remote scp operands must name the same host. Add named port forwards under ssh.forwards (global, per instance, per rule; a more specific layer replaces a forward of the same name), netssh tunnel HOST NAME|SPEC... (ssh -N with ExitOnForwardFailure) and netssh --forward NAME. Specs such as 8080:localhost:80 work without configuration. Forwards are editable in netssh configure and completed in the shell; the TUI opens SFTP with ctrl+o and a tunnel with ctrl+t. Host resolution moves out of runSSHWrapper into resolveTarget, shared by ssh, sftp, scp, tunnel, and the TUI. Options added by netssh are now inserted before the first operand or "--": after "--", ssh read them as the destination, and scp stops reading options at the first operand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
254 lines
7.7 KiB
Go
254 lines
7.7 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.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)), "-")
|
|
}
|