Files
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

131 lines
4.5 KiB
Go

package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// TestNetsshHelper runs main() with the arguments in NETSSH_TEST_ARGS.
func TestNetsshHelper(t *testing.T) {
if args := os.Getenv("NETSSH_TEST_ARGS"); args != "" {
os.Args = append([]string{"netssh"}, strings.Split(args, "\x1f")...)
main()
os.Exit(0)
}
}
// prepareTransferTest writes a config whose only host is cached; NetBox is
// unreachable, so every result must come from cache, shortcuts, and options.
func prepareTransferTest(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("NETSSH_NETBOX_URL", "")
t.Setenv("NETSSH_NETBOX_TOKEN", "")
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
t.Setenv("NETSSH_SHELL_ALIAS", "")
data := `netbox:
url: http://127.0.0.1:1
token: t
ssh:
default_user: ops
default_port: 2222
extra_args: ["-A"]
forwards:
web: {local: "8080:localhost:80"}
rules:
- match: {tag: dmz}
proxy_jump: jh.example.com
forwards:
socks: {dynamic: "1080"}
shortcuts:
domains: [.hoster.de]
rewrites:
- pattern: '^([a-z]+)[0-9]+-'
replace: '$1'
`
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
c := cache.New(cfg.CachePath(), 3600)
c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device", Tags: []string{"dmz"}})
if err := c.Save(); err != nil {
t.Fatal(err)
}
}
func runNetssh(t *testing.T, args ...string) (string, error) {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=^TestNetsshHelper$")
cmd.Env = append(os.Environ(), "NETSSH_TEST_ARGS="+strings.Join(args, "\x1f"))
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
func TestTransferAndTunnelCommands(t *testing.T) {
prepareTransferTest(t)
for _, tc := range []struct {
args []string
want string
}{
// sftp: -P and -o User, rule jump host, path kept, no ssh-only extra_args.
{[]string{"sftp", "--dry-run", "fsnbx1:/var/log"}, "sftp -P 2222 -o User=ops -J jh.example.com 10.0.0.9:/var/log"},
{[]string{"sftp", "--dry-run", "-P", "22", "root@fsnbx1"}, "sftp -P 22 -J jh.example.com root@10.0.0.9"},
{[]string{"scp", "--dry-run", "-r", "a.txt", "fsn1-bx1.hoster.de:/tmp/"}, "scp -P 2222 -o User=ops -r -J jh.example.com a.txt 10.0.0.9:/tmp/"},
{[]string{"scp", "--dry-run", "--no-jump", "fsnbx1:/etc/hosts", "."}, "scp -P 2222 -o User=ops -o ProxyJump=none 10.0.0.9:/etc/hosts ."},
// Forwards come from the global options and the matching rule.
{[]string{"tunnel", "--dry-run", "fsnbx1", "web", "socks", "5432:db:5432"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 -D 1080 -L 5432:db:5432 -o ExitOnForwardFailure=yes -N 10.0.0.9"},
{[]string{"--dry-run", "--forward", "web", "fsnbx1"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 10.0.0.9"},
} {
out, err := runNetssh(t, tc.args...)
if err != nil || out != tc.want {
t.Errorf("%v:\n got %s (%v)\n want %s", tc.args, out, err, tc.want)
}
}
for _, tc := range []struct {
args []string
want string
}{
{[]string{"tunnel", "--dry-run", "fsnbx1"}, "forwards for fsn1-bx1.hoster.de: socks, web"},
{[]string{"tunnel", "--dry-run", "fsnbx1", "db"}, `unknown forward "db"; available: socks, web`},
{[]string{"sftp", "--forward", "web", "fsnbx1"}, "only available for ssh"},
{[]string{"scp", "fsnbx1:a", "other:b"}, "not supported"},
} {
out, err := runNetssh(t, tc.args...)
if err == nil || !strings.Contains(out, tc.want) {
t.Errorf("%v: got %s (%v), want error containing %q", tc.args, out, err, tc.want)
}
}
}
func TestTransferCompletion(t *testing.T) {
prepareTransferTest(t)
for _, tc := range []struct {
args []string
want string
}{
{[]string{"__complete", "sftp", "fsnb"}, "fsnbx1\n"},
{[]string{"__complete", "scp", "a.txt", "fsnb"}, "fsnbx1:\n"},
{[]string{"__complete", "tunnel", "fsnbx1", ""}, "socks\nweb\n"},
{[]string{"__complete", "tunnel", "fsnbx1", "web", ""}, "socks\n"},
{[]string{"__complete", "--forward", ""}, "socks\nweb\n"},
} {
out, err := runNetssh(t, tc.args...)
if err != nil || !strings.HasPrefix(out+"\n", tc.want) {
t.Errorf("%v: got %q (%v), want prefix %q", tc.args, out, err, tc.want)
}
}
}