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:
1 parent
878701aba7
commit
b7fa71fe45
19 files changed
+1389
-141
No files matched your search
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user