Files
ssh-netbox-wrapper/internal/ssh/client.go
T
Sebastian UnterschützandClaude Opus 5.5 55e7a5106b fix: interface_name strategy, instance hosts in sftp/scp, alias rewrites
Found by the new Docker test environment against a real NetBox 4.7:

- The interface_name strategy filtered IP addresses with interface_name
  and vminterface_name, which NetBox ignores, so it returned the first
  address of the host instead of the named interface's. Use the real
  filters interface and vminterface; a missing interface (HTTP 400)
  falls through to the next strategy.
- sftp and scp took instance-qualified hosts (prod/web01:/path) for
  local paths, because a "/" comes before the ":". Such arguments are
  remote when the prefix is a configured NetBox instance.
- Shell aliases of instance-qualified hosts ran the shortcut rules on
  "instance/host", so anchored rewrite rules never matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 12:30:24 +01:00

163 lines
4.9 KiB
Go

package ssh
import (
"fmt"
"strings"
)
// Client describes one OpenSSH program netssh can run. The programs share
// ssh's connection options but differ in flags and destination syntax.
type Client struct {
Name string // binary name: ssh, sftp, or scp
withArg map[byte]bool // short flags that consume a value
port byte // flag for the port: -p (ssh) or -P (sftp, scp)
user byte // flag for the login user; 0 when only user@ or -o User work
paths bool // destinations are host[:path]
// instances are the configured NetBox instance names; "inst/host:path"
// is remote only for these, so local paths like "dir/file:x" stay local.
instances map[string]bool
}
// WithInstances returns c aware of the NetBox instance prefixes in host:path.
func (c Client) WithInstances(names []string) Client {
c.instances = make(map[string]bool, len(names))
for _, name := range names {
c.instances[strings.ToLower(name)] = true
}
return c
}
var (
// SSH is the default client; its flag table is flagsWithArg.
SSH = Client{Name: "ssh", withArg: flagsWithArg, port: 'p', user: 'l'}
// SFTP follows sftp(1): -l is a bandwidth limit, -P the port.
SFTP = Client{Name: "sftp", withArg: flagSet("BbcDFiJloPRSsX"), port: 'P', paths: true}
// SCP follows scp(1); unlike sftp, -B and -R take no value.
SCP = Client{Name: "scp", withArg: flagSet("cDFiJloPSX"), port: 'P', paths: true}
)
func flagSet(flags string) map[byte]bool {
set := make(map[byte]bool, len(flags))
for i := 0; i < len(flags); i++ {
set[flags[i]] = true
}
return set
}
// PortArgs returns the arguments that set port for this client.
func (c Client) PortArgs(port string) []string { return []string{"-" + string(c.port), port} }
// UserArgs returns the arguments that set the login user for this client.
func (c Client) UserArgs(user string) []string {
if c.user == 0 {
return []string{"-o", "User=" + user}
}
return []string{"-" + string(c.user), user}
}
// Operand is a remote scp argument such as user@host:/path.
type Operand struct {
Idx int
User string
Host string
Path string
}
// Operands returns the remote operands of scp arguments. All remote operands
// must name the same host, because one set of options applies per call.
func (c Client) Operands(args []string) ([]Operand, error) {
start := c.optionsEnd(args)
var out []Operand
for i := start; i < len(args); i++ {
arg := args[i]
if strings.HasPrefix(arg, "scp://") || strings.HasPrefix(arg, "sftp://") {
return nil, fmt.Errorf("%q: use host:path instead of a URI", arg)
}
dest, path, remote := c.splitRemote(arg)
if !remote {
continue
}
host, user := splitUserHost(dest)
if host == "" {
continue
}
if len(out) > 0 && !strings.EqualFold(out[0].Host, host) {
return nil, fmt.Errorf("scp between two remote hosts (%s, %s) is not supported; copy in two steps", out[0].Host, host)
}
out = append(out, Operand{Idx: i, User: user, Host: host, Path: path})
}
return out, nil
}
// optionsEnd returns the index of the first operand; scp, like ssh, stops
// option parsing at the first non-option or after "--".
func (c Client) optionsEnd(args []string) int {
end := c.insertionPoint(args)
if end < len(args) && args[end] == "--" {
end++
}
return end
}
// insertionPoint is where added options still count as options: before the
// first operand or before "--".
func (c Client) insertionPoint(args []string) int {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
return i
}
if !strings.HasPrefix(arg, "-") || len(arg) < 2 {
return i
}
if c.takesSeparateValue(arg) {
i++
}
}
return len(args)
}
// splitRemote is splitHostPath with netssh's [user@]instance/host:path form.
func (c Client) splitRemote(arg string) (dest, path string, remote bool) {
if head, rest, ok := strings.Cut(arg, "/"); ok {
instance := head[strings.LastIndex(head, "@")+1:]
if c.instances[strings.ToLower(instance)] {
if d, p, r := splitHostPath(rest); r {
return head + "/" + d, p, true
}
}
}
return splitHostPath(arg)
}
// splitHostPath mirrors scp's colon(): an argument is remote when a ':' comes
// before any '/'. A leading "[" starts a bracketed IPv6 address.
func splitHostPath(arg string) (dest, path string, remote bool) {
if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, ".") {
return "", "", false
}
inBracket := false
for i := 0; i < len(arg); i++ {
switch arg[i] {
case '[':
inBracket = true
case ']':
inBracket = false
case '/':
if !inBracket {
return "", "", false
}
case ':':
if !inBracket {
return arg[:i], arg[i+1:], true
}
}
}
return "", "", false
}
// HasForwardFlag reports whether -L, -R, or -D (or the -o equivalents) was given.
func (c Client) HasForwardFlag(args []string) bool {
return c.hasOption(args, 'L', "LocalForward") || c.hasOption(args, 'R', "RemoteForward") || c.hasOption(args, 'D', "DynamicForward")
}