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>
138 lines
4.0 KiB
Go
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")
|
|
}
|