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>
91 lines
2.5 KiB
Go
91 lines
2.5 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func loadYAML(t *testing.T, data string) (*Config, error) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
|
t.Setenv("HOME", t.TempDir())
|
|
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return Load()
|
|
}
|
|
|
|
func TestSSHOptionsLayering(t *testing.T) {
|
|
cfg, err := loadYAML(t, `
|
|
netboxes:
|
|
- name: prod
|
|
url: https://prod.example.com
|
|
ssh:
|
|
proxy_jump: jh-prod.example.com
|
|
host_key_checking: strict
|
|
- name: staging
|
|
url: https://staging.example.com
|
|
ssh:
|
|
host_key_checking: off
|
|
ssh:
|
|
proxy_jump: jh.example.com
|
|
extra_args: ["-4"]
|
|
options:
|
|
ServerAliveInterval: 60
|
|
ServerAliveCountMax: 5
|
|
rules:
|
|
- match: {tag: dmz}
|
|
proxy_jump: jh-dmz.example.com
|
|
options: {ServerAliveInterval: 10}
|
|
- match: {instance: prod, name: "db*"}
|
|
proxy_jump: none
|
|
completion:
|
|
max_results: 200
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Completion.MaxResults != 200 {
|
|
t.Fatalf("max_results: %d", cfg.Completion.MaxResults)
|
|
}
|
|
for _, tc := range []struct {
|
|
host SSHHost
|
|
jump, keys, alive string
|
|
}{
|
|
{SSHHost{Name: "web"}, "jh.example.com", "", "60"},
|
|
{SSHHost{Instance: "prod", Name: "web"}, "jh-prod.example.com", "strict", "60"},
|
|
{SSHHost{Instance: "staging", Name: "web", Tags: []string{"DMZ"}}, "jh-dmz.example.com", "off", "10"},
|
|
{SSHHost{Instance: "prod", Name: "db01", Tags: []string{"dmz"}}, "none", "strict", "10"},
|
|
} {
|
|
got := cfg.SSHOptionsFor(tc.host)
|
|
if got.ProxyJump != tc.jump || got.HostKeyChecking != tc.keys || got.Options["serveraliveinterval"] != tc.alive || got.Options["serveralivecountmax"] != "5" {
|
|
t.Errorf("%+v: got %+v", tc.host, got)
|
|
}
|
|
if !reflect.DeepEqual(got.ExtraArgs, []string{"-4"}) {
|
|
t.Errorf("extra args: %v", got.ExtraArgs)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSSHOptionsValidation(t *testing.T) {
|
|
for _, data := range []string{
|
|
"ssh:\n host_key_checking: maybe\n",
|
|
"ssh:\n target: dns\n",
|
|
"ssh:\n address_family: ipx\n",
|
|
"ssh:\n proxy_jump: \"-oProxyCommand=x\"\n",
|
|
"ssh:\n options:\n \"bad key\": 1\n",
|
|
"ssh:\n rules:\n - proxy_jump: jh\n",
|
|
"netboxes:\n - name: a\n url: https://a.example\n ssh:\n target: nope\n",
|
|
} {
|
|
if _, err := loadYAML(t, data); err == nil {
|
|
t.Errorf("accepted invalid config:\n%s", data)
|
|
} else if !strings.Contains(err.Error(), "ssh") {
|
|
t.Errorf("unclear error %v", err)
|
|
}
|
|
}
|
|
}
|