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>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-13 23:05:07 +01:00
1 parent 878701aba7
commit b7fa71fe45
19 files changed
+1389 -141

No files matched your search

+56 -33
View File
@@ -12,15 +12,19 @@ var flagsWithArg = map[byte]bool{
// ParsedArgs holds the result of parsing SSH arguments.
type ParsedArgs struct {
Host string // hostname without the user@ prefix
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 {
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]
@@ -33,64 +37,83 @@ func Parse(args []string) *ParsedArgs {
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
// OpenSSH accepts clusters such as -vp 2222 and -vladmin.
for j := 1; j < len(arg); j++ {
if flagsWithArg[arg[j]] {
if j == len(arg)-1 {
i++
}
break
}
if c.takesSeparateValue(arg) {
i++
}
i++
continue
}
// first non-flag argument is the destination
host, user := splitUserHost(arg)
return &ParsedArgs{
Host: host,
User: user,
DestIdx: i,
Args: args,
}
return c.parsed(args, i)
}
// handle arguments after "--"
if i < len(args) {
host, user := splitUserHost(args[i])
return &ParsedArgs{
Host: host,
User: user,
DestIdx: i,
Args: 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)
original := args[destIdx]
if at := strings.Index(original, "@"); at != -1 {
result[destIdx] = original[:at+1] + newHost
} else {
result[destIdx] = newHost
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 hasOption(args, 'p', "Port") }
func HasPortFlag(args []string) bool { return SSH.HasPortFlag(args) }
// HasUserFlag reports whether a login user was explicitly supplied.
func HasUserFlag(args []string) bool { return hasOption(args, 'l', "User") }
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] != '-' {
@@ -98,7 +121,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
}
for j := 1; j < len(arg); j++ {
flag := arg[j]
if !flagsWithArg[flag] {
if !c.withArg[flag] {
continue
}
value := arg[j+1:]
@@ -109,7 +132,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
}
value = args[i]
}
if flag == wanted {
if wanted != 0 && flag == wanted {
return true
}
if flag == 'o' {
+137
View File
@@ -0,0 +1,137 @@
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")
}
+156
View File
@@ -0,0 +1,156 @@
package ssh
import (
"reflect"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestSFTPParse(t *testing.T) {
// -l is a bandwidth limit and -s takes a value for sftp, unlike ssh.
parsed := SFTP.Parse([]string{"-l", "100", "-s", "sub", "-P", "2222", "ops@web01:/var/log"})
if parsed == nil || parsed.Host != "web01" || parsed.User != "ops" || parsed.Path != "/var/log" || parsed.DestIdx != 6 {
t.Fatalf("got %+v", parsed)
}
if p := SFTP.Parse([]string{"prod/web01"}); p == nil || p.Host != "prod/web01" || p.Path != "" {
t.Fatalf("instance-qualified host: %+v", p)
}
if !SFTP.HasPortFlag([]string{"-P", "2222", "web"}) || SFTP.HasPortFlag([]string{"-p", "web"}) {
t.Fatal("sftp port flag is -P")
}
if SFTP.HasUserFlag([]string{"-l", "100", "web"}) || !SFTP.HasUserFlag([]string{"-o", "User=ops", "web"}) {
t.Fatal("sftp has no -l user flag")
}
}
func TestSCPOperands(t *testing.T) {
ops, err := SCP.Operands([]string{"-X", "nrequests=8", "-r", "./local:dir", "/abs/file", "ops@web01:/tmp/", "web01:b"})
if err != nil {
t.Fatal(err)
}
want := []Operand{{Idx: 5, User: "ops", Host: "web01", Path: "/tmp/"}, {Idx: 6, Host: "web01", Path: "b"}}
if !reflect.DeepEqual(ops, want) {
t.Fatalf("got %+v", ops)
}
if ops, _ := SCP.Operands([]string{"a.txt", "b.txt"}); len(ops) != 0 {
t.Fatalf("local copy has remote operands: %+v", ops)
}
if _, err := SCP.Operands([]string{"web01:a", "db01:b"}); err == nil {
t.Fatal("two remote hosts accepted")
}
if _, err := SCP.Operands([]string{"scp://web01/a", "."}); err == nil {
t.Fatal("URI accepted")
}
}
func TestSFTPApply(t *testing.T) {
args := []string{"-P", "2222", "ops@web01:/var/log"}
opts := config.SSHOptions{ProxyJump: "jh", ExtraArgs: []string{"-A"}, Forwards: map[string]config.Forward{"web": {Local: "1:a:2"}}}
got := SFTP.Apply(args, []int{2}, Host{Name: "web01", IP: "192.0.2.10"}, opts, WrapperFlags{ForwardArgs: []string{"-L", "1:a:2"}}).Args
want := []string{"-P", "2222", "-J", "jh", "ops@192.0.2.10:/var/log"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v (extra_args and forwards are ssh only)", got, want)
}
}
func TestSCPApplyReplacesEveryOperandAndBracketsIPv6(t *testing.T) {
args := []string{"a.txt", "web01:/tmp/", "ops@web01:b"}
got := SCP.Apply(args, []int{1, 2}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{}).Args
want := []string{"-o", "StrictHostKeyChecking=accept-new", "a.txt", "[2001:db8::1]:/tmp/", "ops@[2001:db8::1]:b"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v", got)
}
// sftp needs brackets even without a path; ssh never does.
if got := SFTP.Apply([]string{"web01"}, []int{0}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{}, WrapperFlags{}).Args; got[0] != "[2001:db8::1]" {
t.Fatalf("sftp: %v", got)
}
if got := apply([]string{"web01"}, config.SSHOptions{}, WrapperFlags{}); got.Args[0] != "192.0.2.10" {
t.Fatalf("ssh: %v", got.Args)
}
}
func TestSFTPTargetName(t *testing.T) {
got := SFTP.Apply([]string{"web01:/srv"}, []int{0}, Host{Name: "web01.example.com", IP: "192.0.2.10"}, config.SSHOptions{Target: "name"}, WrapperFlags{}).Args
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web01.example.com", "web01.example.com:/srv"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v", got)
}
}
func TestClientUserAndPortArgs(t *testing.T) {
if got := SSH.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-l", "ops"}) {
t.Fatal(got)
}
if got := SCP.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-o", "User=ops"}) {
t.Fatal(got)
}
if got := SFTP.PortArgs("2222"); !reflect.DeepEqual(got, []string{"-P", "2222"}) {
t.Fatal(got)
}
}
func TestClientExtractWrapperFlags(t *testing.T) {
// For scp, -X takes a value; for ssh it does not.
flags, rest, err := SCP.ExtractWrapperFlags([]string{"-X", "--dry-run", "--dry-run", "a", "web:b"})
if err != nil || !flags.DryRun || !reflect.DeepEqual(rest, []string{"-X", "--dry-run", "a", "web:b"}) {
t.Fatalf("got %+v %v %v", flags, rest, err)
}
flags, rest, err = ExtractWrapperFlags([]string{"--forward", "web", "--forward=8080:localhost:80", "-v", "web01"})
if err != nil || !reflect.DeepEqual(flags.Forwards, []string{"web", "8080:localhost:80"}) || !reflect.DeepEqual(rest, []string{"-v", "web01"}) {
t.Fatalf("got %+v %v %v", flags, rest, err)
}
for _, args := range [][]string{{"--forward"}, {"--forward", "-L", "web"}} {
if _, _, err := ExtractWrapperFlags(args); err == nil {
t.Errorf("%v accepted", args)
}
}
}
func TestForwardArgs(t *testing.T) {
available := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
got, err := ForwardArgs([]string{"WEB", "socks", "back", "5432:db:5432", "127.0.0.1:3000:localhost:3000"}, available)
want := []string{"-L", "8080:localhost:80", "-D", "1080", "-R", "9000:localhost:9000", "-L", "5432:db:5432", "-L", "127.0.0.1:3000:localhost:3000"}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("got %v %v", got, err)
}
if _, err := ForwardArgs([]string{"db"}, available); err == nil || !strings.Contains(err.Error(), "back, socks, web") {
t.Fatalf("unknown name: %v", err)
}
if _, err := ForwardArgs([]string{"db"}, nil); err == nil || !strings.Contains(err.Error(), "no forwards are configured") {
t.Fatalf("no forwards: %v", err)
}
}
func TestApplyForwardsAndTunnel(t *testing.T) {
flags := WrapperFlags{ForwardArgs: []string{"-L", "8080:localhost:80"}}
if got := apply([]string{"web"}, config.SSHOptions{}, flags).Args; !reflect.DeepEqual(got, []string{"-L", "8080:localhost:80", "192.0.2.10"}) {
t.Fatalf("forward: %v", got)
}
flags.Tunnel = true
result := apply([]string{"web"}, config.SSHOptions{}, flags)
want := []string{"-L", "8080:localhost:80", "-o", "ExitOnForwardFailure=yes", "-N", "192.0.2.10"}
if !reflect.DeepEqual(result.Args, want) || !strings.Contains(result.Notice, "forwarding -L 8080:localhost:80 via web.example.com") {
t.Fatalf("tunnel: %v %q", result.Args, result.Notice)
}
// The user's own ExitOnForwardFailure wins.
if got := apply([]string{"-o", "ExitOnForwardFailure=no", "web"}, config.SSHOptions{}, flags).Args; strings.Contains(strings.Join(got, " "), "=yes") {
t.Fatalf("overrode user option: %v", got)
}
}
func TestHasForwardFlag(t *testing.T) {
for args, want := range map[string]bool{"-L 1:a:2 web": true, "-vD1080 web": true, "-o RemoteForward=1_a_2 web": true, "-l ops web": false, "web -L 1:a:2": false} {
if got := SSH.HasForwardFlag(strings.Fields(args)); got != want {
t.Errorf("%q: got %v", args, got)
}
}
}
func TestApplyInsertsOptionsBeforeDoubleDash(t *testing.T) {
got := Apply([]string{"-v", "--", "web"}, 2, Host{Name: "web", IP: "192.0.2.10"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{}).Args
if want := []string{"-v", "-J", "jh", "--", "192.0.2.10"}; !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
+8 -5
View File
@@ -9,11 +9,14 @@ import (
// Exec replaces the current process with the native ssh client via syscall.Exec.
// All existing SSH configs, keys, and agent forwarding remain intact.
func Exec(args []string) error {
sshPath, err := exec.LookPath("ssh")
func Exec(args []string) error { return SSH.Exec(args) }
// Exec replaces the current process with the client's binary.
func (c Client) Exec(args []string) error {
path, err := exec.LookPath(c.Name)
if err != nil {
return fmt.Errorf("ssh not found in PATH: %w", err)
return fmt.Errorf("%s not found in PATH: %w", c.Name, err)
}
argv := append([]string{"ssh"}, args...)
return syscall.Exec(sshPath, argv, os.Environ())
argv := append([]string{c.Name}, args...)
return syscall.Exec(path, argv, os.Environ())
}
+99 -16
View File
@@ -12,14 +12,24 @@ import (
// WrapperFlags are netssh's own long options. OpenSSH has no long options,
// so these never collide with flags meant for ssh.
type WrapperFlags struct {
DryRun bool
Jump string // --jump HOST overrides the configured jump host
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
DryRun bool
Jump string // --jump HOST overrides the configured jump host
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
Forwards []string // --forward NAME|SPEC, repeatable (ssh only)
// ForwardArgs are Forwards resolved by ForwardArgs; Apply inserts them.
ForwardArgs []string
// Tunnel runs ssh without a remote command (-N), for netssh tunnel.
Tunnel bool
}
// ExtractWrapperFlags removes netssh's long options that appear before the
// destination. Anything after the destination belongs to the remote command.
func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
return SSH.ExtractWrapperFlags(args)
}
// ExtractWrapperFlags removes netssh's long options before the destination.
func (c Client) ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
var flags WrapperFlags
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
@@ -37,6 +47,14 @@ func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
flags.Jump = args[i]
case strings.HasPrefix(arg, "--jump="):
flags.Jump = strings.TrimPrefix(arg, "--jump=")
case arg == "--forward":
if i+1 >= len(args) {
return flags, nil, fmt.Errorf("--forward requires a name or spec")
}
i++
flags.Forwards = append(flags.Forwards, args[i])
case strings.HasPrefix(arg, "--forward="):
flags.Forwards = append(flags.Forwards, strings.TrimPrefix(arg, "--forward="))
case arg == "--" || !strings.HasPrefix(arg, "-") || len(arg) < 2:
// Destination or end of options: keep the rest unchanged.
return flags, append(out, args[i:]...), validateJump(flags)
@@ -45,7 +63,7 @@ func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
default:
out = append(out, arg)
// Keep the value of an ssh option such as -l USER together with it.
if takesSeparateValue(arg) && i+1 < len(args) {
if c.takesSeparateValue(arg) && i+1 < len(args) {
i++
out = append(out, args[i])
}
@@ -64,14 +82,48 @@ func validateJump(flags WrapperFlags) error {
if strings.HasPrefix(flags.Jump, "-") {
return fmt.Errorf("--jump: invalid host %q", flags.Jump)
}
for _, forward := range flags.Forwards {
if forward == "" || strings.HasPrefix(forward, "-") || strings.ContainsAny(forward, " \t\n") {
return fmt.Errorf("--forward: invalid value %q", forward)
}
}
return nil
}
var adHocForward = regexp.MustCompile(`^([^\s:]+:)?[0-9]+(:[^\s]+:[0-9]+)?$`)
// ForwardArgs resolves forward names against the configured forwards. A value
// that is no configured name but looks like a port or [bind:]port:host:port
// is used as a local forward (-L).
func ForwardArgs(names []string, available map[string]config.Forward) ([]string, error) {
var out []string
for _, name := range names {
if forward, ok := available[strings.ToLower(name)]; ok {
out = append(out, forward.Flag()...)
continue
}
if strings.Contains(name, ":") && adHocForward.MatchString(name) {
out = append(out, "-L", name)
continue
}
known := make([]string, 0, len(available))
for key := range available {
known = append(known, key)
}
sort.Strings(known)
if len(known) == 0 {
return nil, fmt.Errorf("unknown forward %q; no forwards are configured for this host (ssh.forwards), or pass a spec such as 8080:localhost:80", name)
}
return nil, fmt.Errorf("unknown forward %q; available: %s", name, strings.Join(known, ", "))
}
return out, nil
}
// takesSeparateValue reports whether a short option cluster ends with a flag
// whose value is the next argument (e.g. "-l" or "-vp").
func takesSeparateValue(arg string) bool {
func (c Client) takesSeparateValue(arg string) bool {
for j := 1; j < len(arg); j++ {
if flagsWithArg[arg[j]] {
if c.withArg[arg[j]] {
return j == len(arg)-1
}
}
@@ -96,9 +148,17 @@ var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])
// options directly before it. Explicit options given by the user come first,
// and ssh uses the first -o value it obtains, so the user's choice wins.
func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
return SSH.Apply(args, []int{destIdx}, host, opts, flags)
}
// Apply replaces the host in every destination (several only for scp) and
// inserts the configured options before the first one. extra_args and
// forwards are ssh flags and apply to ssh only.
func (c Client) Apply(args []string, dests []int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
destIdx := dests[0]
var inserted []string
add := func(keyword, value string) {
if !hasOption(args[:destIdx], 0, keyword) {
if !c.hasOption(args[:destIdx], 0, keyword) {
inserted = append(inserted, "-o", keyword+"="+value)
}
}
@@ -114,7 +174,7 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
}
}
userJump := hasOption(args[:destIdx], 'J', "ProxyJump") || hasOption(args[:destIdx], 0, "ProxyCommand")
userJump := c.hasOption(args[:destIdx], 'J', "ProxyJump") || c.hasOption(args[:destIdx], 0, "ProxyCommand")
switch {
case userJump:
case flags.NoJump:
@@ -140,7 +200,7 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
}
}
if opts.AddressFamily != "" && !hasBoolFlag(args[:destIdx], '4') && !hasBoolFlag(args[:destIdx], '6') {
if opts.AddressFamily != "" && !c.hasBoolFlag(args[:destIdx], '4') && !c.hasBoolFlag(args[:destIdx], '6') {
add("AddressFamily", opts.AddressFamily)
}
@@ -153,19 +213,42 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
add(key, opts.Options[key])
}
var extra []string
if c.Name == SSH.Name {
extra = opts.ExtraArgs
inserted = append(inserted, flags.ForwardArgs...)
if flags.Tunnel {
add("ExitOnForwardFailure", "yes")
inserted = append(inserted, "-N")
forwards := strings.Join(flags.ForwardArgs, " ")
if forwards == "" {
forwards = "the given ports"
}
tunnel := fmt.Sprintf("forwarding %s via %s, press Ctrl+C to stop", forwards, host.Name)
if notice == "" {
notice = tunnel
} else {
notice += "\nnetssh: " + tunnel
}
}
}
// Extra arguments go first: short flags such as -4 or -A take the last
// value given, so the user's own flags later on the line still win.
replaced := ReplaceHost(args, destIdx, dest)
out := make([]string, 0, len(opts.ExtraArgs)+len(replaced)+len(inserted))
out = append(out, opts.ExtraArgs...)
out = append(out, replaced[:destIdx]...)
// Options go before the first operand: scp stops reading options there,
// and after "--" they would be taken as the destination.
at := c.insertionPoint(args)
replaced := c.replaceHost(args, dests, dest)
out := make([]string, 0, len(extra)+len(replaced)+len(inserted))
out = append(out, extra...)
out = append(out, replaced[:at]...)
out = append(out, inserted...)
out = append(out, replaced[destIdx:]...)
out = append(out, replaced[at:]...)
return Result{Args: out, Notice: notice}
}
// hasBoolFlag reports whether a flag without a value, such as -4, was given.
func hasBoolFlag(args []string, wanted byte) bool {
func (c Client) hasBoolFlag(args []string, wanted byte) bool {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
@@ -175,7 +258,7 @@ func hasBoolFlag(args []string, wanted byte) bool {
if arg[j] == wanted {
return true
}
if flagsWithArg[arg[j]] {
if c.withArg[arg[j]] {
if j == len(arg)-1 {
i++
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestExtractWrapperFlags(t *testing.T) {
{[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}},
} {
flags, rest, err := ExtractWrapperFlags(tc.args)
if err != nil || flags != tc.flags || !reflect.DeepEqual(rest, tc.rest) {
if err != nil || !reflect.DeepEqual(flags, tc.flags) || !reflect.DeepEqual(rest, tc.rest) {
t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err)
}
}