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, " ") }