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>
156 lines
4.2 KiB
Go
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, ""
|
|
}
|