Files
ssh-netbox-wrapper/internal/config/ssh.go
T
Sebastian UnterschützandClaude Opus 5.5 b7fa71fe45 feat: sftp, scp, and named port forwards
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>
2026-11-13 23:05:07 +01:00

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