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 } // 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) { 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 == "--" || !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 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) } return 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 { for j := 1; j < len(arg); j++ { if flagsWithArg[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 { var inserted []string add := func(keyword, value string) { if !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 := hasOption(args[:destIdx], 'J', "ProxyJump") || 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 != "" && !hasBoolFlag(args[:destIdx], '4') && !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]) } // 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]...) out = append(out, inserted...) out = append(out, replaced[destIdx:]...) 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 { 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 flagsWithArg[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, " ") }