Add the hostname resolver strategy. It connects by the NetBox name instead of an IP, with resolver.hostname_domain appended to names without a dot. Its position in the chain decides whether it is always used or only as a fallback (e.g. [primary_ip, hostname]); names that are not valid DNS hostnames fall through to the next strategy. Add static hosts under hosts: for machines that are not in NetBox, with address, user, port, tags, and their own SSH options. They are checked before NetBox (by name or shortcut), never expire, survive cache refresh and clear, and work without any NetBox configured. The cache overlays them after every load and stores only their last use, so they appear in completion, aliases, the TUI and its recent list. SSH options merge global, static host, then rules; rules can match kind: static. Manage them with netssh hosts list|add|remove and in netssh configure, which can now also continue without NetBox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
5.5 KiB
Go
193 lines
5.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestForwardsLayering(t *testing.T) {
|
|
cfg, err := loadYAML(t, `
|
|
netboxes:
|
|
- name: prod
|
|
url: https://prod.example
|
|
token: t
|
|
ssh:
|
|
forwards:
|
|
db: {local: "5432:localhost:5432"}
|
|
ssh:
|
|
forwards:
|
|
web: {local: "8080:localhost:80"}
|
|
socks: {dynamic: "1080"}
|
|
rules:
|
|
- match: {tag: dmz}
|
|
forwards:
|
|
web: {local: "8443:localhost:443"}
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := cfg.SSHOptionsFor(SSHHost{Instance: "prod", Name: "web01", Tags: []string{"dmz"}}).Forwards
|
|
want := map[string]Forward{"web": {Local: "8443:localhost:443"}, "socks": {Dynamic: "1080"}, "db": {Local: "5432:localhost:5432"}}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %+v", got)
|
|
}
|
|
if got := cfg.SSHOptionsFor(SSHHost{Instance: "other", Name: "web01"}).Forwards; len(got) != 2 || got["web"].Local != "8080:localhost:80" {
|
|
t.Fatalf("other instance: %+v", got)
|
|
}
|
|
if f := (Forward{Remote: "9000:localhost:9000"}); f.String() != "-R 9000:localhost:9000" {
|
|
t.Fatal(f.String())
|
|
}
|
|
}
|
|
|
|
func TestForwardsValidation(t *testing.T) {
|
|
for _, forwards := range []string{
|
|
`web: {local: "8080:a:80", dynamic: "1080"}`,
|
|
`web: {local: "-oProxyCommand=x"}`,
|
|
`web: {local: "8080 a:80"}`,
|
|
`"bad name": {local: "8080:a:80"}`,
|
|
} {
|
|
if _, err := loadYAML(t, "ssh:\n forwards:\n "+forwards+"\n"); err == nil || !strings.Contains(err.Error(), "forwards") {
|
|
t.Errorf("%s: got %v", forwards, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStaticHosts(t *testing.T) {
|
|
cfg, err := loadYAML(t, `
|
|
resolver:
|
|
hostname_domain: example.com
|
|
ssh:
|
|
proxy_jump: jh
|
|
rules:
|
|
- match: {kind: static, tag: home}
|
|
host_key_checking: off
|
|
hosts:
|
|
- name: Router
|
|
address: 192.168.1.1
|
|
user: admin
|
|
port: 2222
|
|
tags: [home]
|
|
ssh:
|
|
proxy_jump: none
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !cfg.HasHosts() || cfg.HasNetBox() || cfg.Resolver.HostnameDomain != "example.com" {
|
|
t.Fatalf("got %+v", cfg)
|
|
}
|
|
host := cfg.StaticHost("router")
|
|
if host == nil || host.Address != "192.168.1.1" || host.User != "admin" || host.Port != 2222 {
|
|
t.Fatalf("host: %+v", host)
|
|
}
|
|
opts := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: StaticKind, Tags: []string{"home"}})
|
|
if opts.ProxyJump != "none" || opts.HostKeyChecking != "off" {
|
|
t.Fatalf("static host options: %+v", opts)
|
|
}
|
|
// A NetBox host of the same name does not get the static host's options.
|
|
if got := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: "device"}); got.ProxyJump != "jh" {
|
|
t.Fatalf("netbox host: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestStaticHostsValidation(t *testing.T) {
|
|
for _, hosts := range []string{
|
|
"- {name: a, address: 10.0.0.1}\n - {name: A, address: 10.0.0.2}",
|
|
"- {name: 'bad name', address: 10.0.0.1}",
|
|
"- {name: prod/a, address: 10.0.0.1}",
|
|
"- {name: a}",
|
|
"- {name: a, address: '-oProxyCommand=x'}",
|
|
"- {name: a, address: 10.0.0.1, port: 70000}",
|
|
"- {name: a, address: 10.0.0.1, user: 'a b'}",
|
|
"- {name: a, address: 10.0.0.1, ssh: {target: nope}}",
|
|
} {
|
|
if _, err := loadYAML(t, "hosts:\n "+hosts+"\n"); err == nil || !strings.Contains(err.Error(), "hosts[") {
|
|
t.Errorf("%s: got %v", hosts, err)
|
|
}
|
|
}
|
|
}
|