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>
284 lines
8.8 KiB
Go
284 lines
8.8 KiB
Go
package ssh
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
|
)
|
|
|
|
// 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
|
|
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++ {
|
|
arg := args[i]
|
|
switch {
|
|
case arg == "--dry-run":
|
|
flags.DryRun = true
|
|
case arg == "--no-jump":
|
|
flags.NoJump = true
|
|
case arg == "--jump":
|
|
if i+1 >= len(args) {
|
|
return flags, nil, fmt.Errorf("--jump requires a host")
|
|
}
|
|
i++
|
|
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)
|
|
case strings.HasPrefix(arg, "--"):
|
|
return flags, nil, fmt.Errorf("unknown option %s", arg)
|
|
default:
|
|
out = append(out, arg)
|
|
// Keep the value of an ssh option such as -l USER together with it.
|
|
if c.takesSeparateValue(arg) && i+1 < len(args) {
|
|
i++
|
|
out = append(out, args[i])
|
|
}
|
|
}
|
|
}
|
|
return flags, out, validateJump(flags)
|
|
}
|
|
|
|
func validateJump(flags WrapperFlags) error {
|
|
if flags.Jump == "" && flags.NoJump {
|
|
return nil
|
|
}
|
|
if flags.Jump != "" && flags.NoJump {
|
|
return fmt.Errorf("--jump and --no-jump are mutually exclusive")
|
|
}
|
|
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 (c Client) takesSeparateValue(arg string) bool {
|
|
for j := 1; j < len(arg); j++ {
|
|
if c.withArg[arg[j]] {
|
|
return j == len(arg)-1
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Host is a resolved NetBox host.
|
|
type Host struct {
|
|
Name string // NetBox name without the instance prefix
|
|
IP string
|
|
}
|
|
|
|
// Result is the final ssh argument list and an optional notice for stderr.
|
|
type Result struct {
|
|
Args []string
|
|
Notice string
|
|
}
|
|
|
|
var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`)
|
|
|
|
// Apply replaces the destination at destIdx and inserts the configured -o
|
|
// 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 !c.hasOption(args[:destIdx], 0, keyword) {
|
|
inserted = append(inserted, "-o", keyword+"="+value)
|
|
}
|
|
}
|
|
|
|
dest := host.IP
|
|
if opts.Target == "name" || opts.Target == "hostname" {
|
|
if plainHostname.MatchString(host.Name) {
|
|
dest = host.Name
|
|
if opts.Target == "name" {
|
|
add("HostName", host.IP)
|
|
add("HostKeyAlias", host.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
userJump := c.hasOption(args[:destIdx], 'J', "ProxyJump") || c.hasOption(args[:destIdx], 0, "ProxyCommand")
|
|
switch {
|
|
case userJump:
|
|
case flags.NoJump:
|
|
inserted = append(inserted, "-o", "ProxyJump=none")
|
|
case flags.Jump != "":
|
|
inserted = append(inserted, "-J", flags.Jump)
|
|
case opts.ProxyJump != "" && opts.ProxyJump != "none":
|
|
inserted = append(inserted, "-J", opts.ProxyJump)
|
|
}
|
|
|
|
var notice string
|
|
switch opts.HostKeyChecking {
|
|
case "strict":
|
|
add("StrictHostKeyChecking", "yes")
|
|
case "accept-new":
|
|
add("StrictHostKeyChecking", "accept-new")
|
|
case "off":
|
|
if !hasOption(args[:destIdx], 0, "StrictHostKeyChecking") {
|
|
add("StrictHostKeyChecking", "no")
|
|
add("UserKnownHostsFile", "/dev/null")
|
|
add("LogLevel", "ERROR")
|
|
notice = "host key checking is disabled for this connection (ssh.host_key_checking: off)"
|
|
}
|
|
}
|
|
|
|
if opts.AddressFamily != "" && !c.hasBoolFlag(args[:destIdx], '4') && !c.hasBoolFlag(args[:destIdx], '6') {
|
|
add("AddressFamily", opts.AddressFamily)
|
|
}
|
|
|
|
keys := make([]string, 0, len(opts.Options))
|
|
for key := range opts.Options {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
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.
|
|
// 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[at:]...)
|
|
return Result{Args: out, Notice: notice}
|
|
}
|
|
|
|
// hasBoolFlag reports whether a flag without a value, such as -4, was given.
|
|
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] != '-' {
|
|
break
|
|
}
|
|
for j := 1; j < len(arg); j++ {
|
|
if arg[j] == wanted {
|
|
return true
|
|
}
|
|
if c.withArg[arg[j]] {
|
|
if j == len(arg)-1 {
|
|
i++
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Quote renders an argument list as a copy-pasteable POSIX shell command.
|
|
func Quote(argv []string) string {
|
|
quoted := make([]string, len(argv))
|
|
for i, arg := range argv {
|
|
if arg != "" && strings.Trim(arg, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") == "" {
|
|
quoted[i] = arg
|
|
continue
|
|
}
|
|
quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
|
|
}
|
|
return strings.Join(quoted, " ")
|
|
}
|