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>
205 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|