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

156 lines
4.2 KiB
Go

package ssh
import "strings"
// flagsWithArg lists all SSH flags that consume the following argument.
var flagsWithArg = map[byte]bool{
'B': true, 'P': true, 'b': true, 'c': true, 'D': true, 'E': true, 'e': true,
'F': true, 'I': true, 'i': true, 'J': true, 'L': true,
'l': true, 'm': true, 'o': true, 'O': true, 'p': true,
'Q': true, 'R': true, 'S': true, 'w': true, 'W': true,
}
// ParsedArgs holds the result of parsing SSH arguments.
type ParsedArgs struct {
Host string // hostname without the user@ prefix or :path
User string // empty if not specified
Path string // sftp only: the remote path after host:
DestIdx int // index in Args where [user@]host sits
Args []string
}
// Parse scans SSH arguments and extracts the destination ([user@]host).
// Returns nil if no destination is found.
func Parse(args []string) *ParsedArgs { return SSH.Parse(args) }
// Parse extracts the destination: [user@]host, or [user@]host[:path] for sftp.
func (c Client) Parse(args []string) *ParsedArgs {
i := 0
for i < len(args) {
arg := args[i]
// "--" ends option processing
if arg == "--" {
i++
break
}
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
// OpenSSH accepts clusters such as -vp 2222 and -vladmin.
if c.takesSeparateValue(arg) {
i++
}
i++
continue
}
// first non-flag argument is the destination
return c.parsed(args, i)
}
// handle arguments after "--"
if i < len(args) {
return c.parsed(args, i)
}
return nil
}
func (c Client) parsed(args []string, i int) *ParsedArgs {
dest, path := args[i], ""
if c.paths {
if d, p, remote := splitRemote(dest); remote {
dest, path = d, p
}
}
host, user := splitUserHost(dest)
return &ParsedArgs{Host: host, User: user, Path: path, DestIdx: i, Args: args}
}
// ReplaceHost returns a copy of args with the destination replaced by newHost,
// preserving any user@ prefix.
func ReplaceHost(args []string, destIdx int, newHost string) []string {
return SSH.replaceHost(args, []int{destIdx}, newHost)
}
// replaceHost keeps user@ and, for sftp and scp, :path. Those clients read
// host:path, so an IPv6 address must be bracketed.
func (c Client) replaceHost(args []string, dests []int, newHost string) []string {
result := make([]string, len(args))
copy(result, args)
if c.paths && strings.Contains(newHost, ":") {
newHost = "[" + newHost + "]"
}
for _, destIdx := range dests {
original := args[destIdx]
prefix, suffix := "", ""
if at := strings.Index(original, "@"); at != -1 {
prefix, original = original[:at+1], original[at+1:]
}
if c.paths {
if _, path, remote := splitRemote(original); remote {
suffix = ":" + path
}
}
result[destIdx] = prefix + newHost + suffix
}
return result
}
// HasPortFlag reports whether a port was explicitly supplied.
func HasPortFlag(args []string) bool { return SSH.HasPortFlag(args) }
// HasUserFlag reports whether a login user was explicitly supplied.
func HasUserFlag(args []string) bool { return SSH.HasUserFlag(args) }
// HasPortFlag reports whether a port was explicitly supplied.
func (c Client) HasPortFlag(args []string) bool { return c.hasOption(args, c.port, "Port") }
// HasUserFlag reports whether a login user was explicitly supplied.
func (c Client) HasUserFlag(args []string) bool { return c.hasOption(args, c.user, "User") }
func hasOption(args []string, wanted byte, keyword string) bool {
return SSH.hasOption(args, wanted, keyword)
}
func (c Client) hasOption(args []string, wanted byte, keyword string) bool {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
break
}
for j := 1; j < len(arg); j++ {
flag := arg[j]
if !c.withArg[flag] {
continue
}
value := arg[j+1:]
if value == "" {
i++
if i >= len(args) {
return false
}
value = args[i]
}
if wanted != 0 && flag == wanted {
return true
}
if flag == 'o' {
fields := strings.FieldsFunc(value, func(r rune) bool { return r == '=' || r == ' ' || r == '\t' })
if len(fields) >= 2 && strings.EqualFold(fields[0], keyword) {
return true
}
}
break
}
}
return false
}
func splitUserHost(dest string) (host, user string) {
if at := strings.Index(dest, "@"); at != -1 {
return dest[at+1:], dest[:at]
}
return dest, ""
}