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>
157 lines
6.9 KiB
Go
157 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|