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

+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++
}