Files
ssh-netbox-wrapper/internal/config/ssh_test.go
T
Sebastian UnterschützandClaude Opus 5.5 a31aab4110 feat: netssh multi — several hosts side by side in tmux or zellij
netssh multi opens one pane per host in a tmux window or zellij tab.
Hosts come from names, tag:TAG (NetBox and static hosts), cluster:NAME
(NetBox virtualization clusters; devices by default, --kind vm|all),
and groups from the config (@name, nested, with cycle detection).
Every pane runs netssh HOST, so lookup, rules, and jump hosts apply.

- tmux: a new window inside tmux, else a new session that is attached;
  tiled layout, pane borders with the host and a [SYNC] marker.
- zellij: a generated grid layout; a new tab inside zellij, else a new
  session.
- --sync broadcasts typing to all panes; netssh sync toggles it in the
  current tmux window or zellij tab.
- TUI: Space marks hosts, Ctrl+A marks all, Enter opens them together.
- Configurator: multiplexer, panes per window, groups.

NetBox SearchAll gains a cluster filter (name to cluster IDs per
instance), and cache entries keep the host's cluster for completion
and an offline fallback. Panes skip the 24-hour background refresh
(checked once by multi) and get HOME/XDG paths but no NETSSH_* values,
which would expose tokens in the process list.

testenv/tests/15-multi.sh checks real tmux and zellij sessions,
including a broadcast command arriving on all three cluster nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 12:45:05 +01:00

205 lines
6.1 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)
}
}
}
func TestMultiConfig(t *testing.T) {
cfg, err := loadYAML(t, "groups:\n K8s: [master1, \"tag:worker\"]\nmulti:\n multiplexer: zellij\n")
if err != nil || len(cfg.Groups["k8s"]) != 2 || cfg.Multi.Multiplexer != "zellij" || cfg.Multi.PanesPerWindowOrDefault() != 9 {
t.Fatalf("%+v %v", cfg, err)
}
for _, bad := range []string{"multi: {multiplexer: screen}", "multi: {panes_per_window: 100}", "groups: {web: []}", "groups: {web: [\"a b\"]}"} {
if _, err := loadYAML(t, bad+"\n"); err == nil {
t.Errorf("%s accepted", bad)
}
}
}