Files
ssh-netbox-wrapper/internal/ssh/client.go
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

138 lines
4.0 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]
}
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 := 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 mirrors scp's colon(): an argument is remote when a ':' comes
// before any '/'. A leading "[" starts a bracketed IPv6 address.
func splitRemote(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")
}