feat(ssh): jump hosts, host key checking, and configurable ssh options
Add SSH settings that apply only to netssh connections, so ~/.ssh/config does not need global changes: - proxy_jump (passed as -J; "none" disables an inherited jump host) - host_key_checking: strict | accept-new | off (off prints a notice) - target: ip (default) | name | hostname; "name" connects to the NetBox name with -o HostName=<ip> and -o HostKeyAlias=<name>, so `Host *.example.com` rules apply and known_hosts entries follow the device instead of a reused IP - address_family: any | inet | inet6 - options (-o Key=Value) and extra_args Settings layer global -> netboxes[].ssh -> ssh.rules (match by tag, instance, kind, or name glob). Options given on the command line always win: -o values are inserted after the user's own, and extra_args before them. New wrapper flags: --jump HOST, --no-jump, and --dry-run, which prints the final ssh command instead of connecting. The TUI uses the same options. The setup wizard keeps all new settings when it saves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
129a7201bc
commit
2b46c4f74d
8 files changed
+738
-19
No files matched your search
@@ -0,0 +1,200 @@
|
||||
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, " ")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestExtractWrapperFlags(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
flags WrapperFlags
|
||||
rest []string
|
||||
}{
|
||||
{[]string{"--dry-run", "web"}, WrapperFlags{DryRun: true}, []string{"web"}},
|
||||
{[]string{"-l", "--dry-run", "--jump", "jh", "web"}, WrapperFlags{Jump: "jh"}, []string{"-l", "--dry-run", "web"}},
|
||||
{[]string{"--jump=jh", "-vp", "22", "web", "--dry-run"}, WrapperFlags{Jump: "jh"}, []string{"-vp", "22", "web", "--dry-run"}},
|
||||
{[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}},
|
||||
} {
|
||||
flags, rest, err := ExtractWrapperFlags(tc.args)
|
||||
if err != nil || flags != tc.flags || !reflect.DeepEqual(rest, tc.rest) {
|
||||
t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"--jump"}, {"--jump", "-x", "web"}, {"--jump=a", "--no-jump", "web"}, {"--version-typo", "web"}} {
|
||||
if _, _, err := ExtractWrapperFlags(args); err == nil {
|
||||
t.Errorf("%v accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func apply(args []string, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||
return Apply(args, len(args)-1, Host{Name: "web.example.com", IP: "192.0.2.10"}, opts, flags)
|
||||
}
|
||||
|
||||
func TestApplyDefaultConnectsToIP(t *testing.T) {
|
||||
got := apply([]string{"-l", "root", "web"}, config.SSHOptions{}, WrapperFlags{})
|
||||
if want := []string{"-l", "root", "192.0.2.10"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTargetName(t *testing.T) {
|
||||
got := apply([]string{"admin@web"}, config.SSHOptions{Target: "name"}, WrapperFlags{})
|
||||
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web.example.com", "admin@web.example.com"}
|
||||
if !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
got = apply([]string{"web"}, config.SSHOptions{Target: "hostname"}, WrapperFlags{})
|
||||
if want := []string{"web.example.com"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
// Names that are not plain hostnames fall back to the IP address.
|
||||
got = Apply([]string{"x"}, 0, Host{Name: "rack 1 switch", IP: "192.0.2.1"}, config.SSHOptions{Target: "name"}, WrapperFlags{})
|
||||
if want := []string{"192.0.2.1"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyJumpPrecedence(t *testing.T) {
|
||||
opts := config.SSHOptions{ProxyJump: "jh.example.com"}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
flags WrapperFlags
|
||||
want string
|
||||
}{
|
||||
{[]string{"web"}, WrapperFlags{}, "-J jh.example.com 192.0.2.10"},
|
||||
{[]string{"web"}, WrapperFlags{Jump: "other"}, "-J other 192.0.2.10"},
|
||||
{[]string{"web"}, WrapperFlags{NoJump: true}, "-o ProxyJump=none 192.0.2.10"},
|
||||
{[]string{"-J", "mine", "web"}, WrapperFlags{}, "-J mine 192.0.2.10"},
|
||||
{[]string{"-oProxyCommand=nc %h %p", "web"}, WrapperFlags{}, "-oProxyCommand=nc %h %p 192.0.2.10"},
|
||||
} {
|
||||
if got := strings.Join(apply(tc.args, opts, tc.flags).Args, " "); got != tc.want {
|
||||
t.Errorf("%v %+v: got %q, want %q", tc.args, tc.flags, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := apply([]string{"web"}, config.SSHOptions{ProxyJump: "none"}, WrapperFlags{}); len(got.Args) != 1 {
|
||||
t.Fatalf("proxy_jump none still jumps: %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHostKeyChecking(t *testing.T) {
|
||||
got := apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{})
|
||||
want := "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR 192.0.2.10"
|
||||
if strings.Join(got.Args, " ") != want || got.Notice == "" {
|
||||
t.Fatalf("got %v, notice %q", got.Args, got.Notice)
|
||||
}
|
||||
// An explicit user choice disables the whole "off" bundle.
|
||||
got = apply([]string{"-o", "StrictHostKeyChecking=yes", "web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{})
|
||||
if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=yes 192.0.2.10" || got.Notice != "" {
|
||||
t.Fatalf("user choice overridden: %v", got.Args)
|
||||
}
|
||||
got = apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{})
|
||||
if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=accept-new 192.0.2.10" {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOptionsAndExtraArgs(t *testing.T) {
|
||||
opts := config.SSHOptions{
|
||||
AddressFamily: "inet",
|
||||
ExtraArgs: []string{"-A"},
|
||||
Options: map[string]string{"serveraliveinterval": "60", "user": "ignored"},
|
||||
}
|
||||
got := apply([]string{"-o", "User=me", "web"}, opts, WrapperFlags{})
|
||||
want := "-A -o User=me -o AddressFamily=inet -o serveraliveinterval=60 192.0.2.10"
|
||||
if strings.Join(got.Args, " ") != want {
|
||||
t.Fatalf("got %q", strings.Join(got.Args, " "))
|
||||
}
|
||||
got = apply([]string{"-v6", "web"}, config.SSHOptions{AddressFamily: "inet"}, WrapperFlags{})
|
||||
if strings.Join(got.Args, " ") != "-v6 192.0.2.10" {
|
||||
t.Fatalf("address family overrides -6: %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyKeepsRemoteCommand(t *testing.T) {
|
||||
got := Apply([]string{"web", "uptime", "-o", "x"}, 0, Host{Name: "web", IP: "192.0.2.1"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{})
|
||||
if want := []string{"-J", "jh", "192.0.2.1", "uptime", "-o", "x"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuote(t *testing.T) {
|
||||
got := Quote([]string{"ssh", "-o", "ProxyCommand=nc %h %p", "it's", "", "a@b:22"})
|
||||
if want := `ssh -o 'ProxyCommand=nc %h %p' 'it'\''s' '' a@b:22`; got != want {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user