From d51c040d9f3be7c59767608cfc83b1f8d740f62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Fri, 13 Nov 2026 22:42:13 +0100 Subject: [PATCH] feat(configure): edit every config option in the configurator After the NetBox instances, netssh configure now opens a menu with all sections: SSH defaults, global SSH options (jump host, host key checking, target, address family, extra args, -o options), SSH rules, resolver & cache (including the cache file), shortcuts & command alias, and completion.max_results. SSH options can also be set per instance. The wizard rebuilt the config from its own fields, so re-running it dropped global SSH options, ssh.rules and completion.max_results from the file. It now edits a copy of the loaded config, and save validates SSH options and rules before writing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + README.md | 15 +- internal/config/ssh.go | 36 +++- internal/setup/instances.go | 12 +- internal/setup/ssh.go | 229 ++++++++++++++++++++ internal/setup/ssh_test.go | 72 +++++++ internal/setup/wizard.go | 391 ++++++++++++++++++++-------------- internal/setup/wizard_test.go | 43 ++++ 8 files changed, 630 insertions(+), 174 deletions(-) create mode 100644 internal/setup/ssh.go create mode 100644 internal/setup/ssh_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ffee5b7..ab37c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.0.23 + +- `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list. +- Fix `netssh configure` dropping global SSH options, `ssh.rules`, and `completion.max_results` from the config file when saving. +- The configurator validates SSH options and rules before writing the config. + ## v0.0.22 - Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1`) found nothing in bash and zsh, because only `prod/…` was offered. Hostnames that are unique across all instances are now offered without the prefix; connecting to them still checks every instance for ambiguous names. diff --git a/README.md b/README.md index 1b157ea..024e352 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,19 @@ Re-run it at any time to change settings without editing the file manually: netssh configure ``` -The wizard lets you add, edit, and remove named NetBox instances, then walks through SSH defaults, resolver strategies, cache TTL, and an optional command alias, -then saves to `~/.config/netssh.yaml`. +The wizard first lets you add, edit, and remove named NetBox instances and set SSH options per instance. +A menu then gives access to every other setting; open only the sections you want to change and choose **Save and exit**: + +| Section | Settings | +|---|---| +| SSH defaults | `ssh.default_user`, `ssh.default_port` | +| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line) | +| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options | +| Resolver & cache | strategies, management subnets, interface name, cache TTL, cache file | +| Shortcuts & command alias | domain suffixes, hyphen stripping, `shell.alias` | +| Completion | `completion.max_results` | + +The result is validated and saved to `~/.config/netssh.yaml`; Ctrl+C cancels without writing. ### Manual config diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 0e55e09..df8a905 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -50,32 +50,46 @@ var sshOptionKey = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`) // ValidateSSH rejects values that would be passed to ssh in an unexpected form. func (c *Config) ValidateSSH() error { - if err := c.SSH.SSHOptions.validate("ssh"); err != nil { + if err := c.SSH.SSHOptions.Validate("ssh"); err != nil { return err } for i, rule := range c.SSH.Rules { - if err := rule.SSHOptions.validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil { + if err := rule.Validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil { return err } - if rule.Match == (SSHMatch{}) { - return fmt.Errorf("ssh.rules[%d]: match must set at least one of tag, instance, kind, name", i) - } - if _, err := path.Match(rule.Match.Name, ""); err != nil { - return fmt.Errorf("ssh.rules[%d]: invalid name pattern %q", i, rule.Match.Name) - } } - if err := c.NetBox.SSH.validate("netbox.ssh"); err != nil { + if err := c.NetBox.SSH.Validate("netbox.ssh"); err != nil { return err } for _, source := range c.NetBoxes { - if err := source.SSH.validate("netboxes." + source.Name + ".ssh"); err != nil { + if err := source.SSH.Validate("netboxes." + source.Name + ".ssh"); err != nil { return err } } return nil } -func (o SSHOptions) validate(scope string) error { +// Validate checks the match and options of a single rule. +func (r SSHRule) Validate(scope string) error { + if err := r.SSHOptions.Validate(scope); err != nil { + return err + } + return r.Match.Validate(scope) +} + +// Validate requires at least one selector and a well-formed name pattern. +func (m SSHMatch) Validate(scope string) error { + if m == (SSHMatch{}) { + return fmt.Errorf("%s: match must set at least one of tag, instance, kind, name", scope) + } + if _, err := path.Match(m.Name, ""); err != nil { + return fmt.Errorf("%s: invalid name pattern %q", scope, m.Name) + } + return nil +} + +// Validate rejects values that ssh would not accept or would misinterpret. +func (o SSHOptions) Validate(scope string) error { switch o.HostKeyChecking { case "", "strict", "accept-new", "off": default: diff --git a/internal/setup/instances.go b/internal/setup/instances.go index a4a4b8e..2540506 100644 --- a/internal/setup/instances.go +++ b/internal/setup/instances.go @@ -30,6 +30,7 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) { var options []huh.Option[string] for _, source := range sources { options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name)) + options = append(options, huh.NewOption("SSH options for "+source.Name, "ssh:"+source.Name)) options = append(options, huh.NewOption("Remove "+source.Name, "remove:"+source.Name)) } options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done")) @@ -53,13 +54,20 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) { if source.Name != name { continue } - if verb == "edit" { + switch verb { + case "edit": edited, err := editInstance(source, sources, name) if err != nil { return nil, err } sources[i] = edited - } else { + case "ssh": + options, err := editSSHOptions("SSH options for "+name, source.SSH) + if err != nil { + return nil, err + } + sources[i].SSH = options + default: remove := false if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil { return nil, err diff --git a/internal/setup/ssh.go b/internal/setup/ssh.go new file mode 100644 index 0000000..1122b44 --- /dev/null +++ b/internal/setup/ssh.go @@ -0,0 +1,229 @@ +package setup + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "charm.land/huh/v2" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +// inheritOption leaves a setting empty so a broader layer (or ssh itself) decides. +func inheritOption() huh.Option[string] { return huh.NewOption("(inherit)", "") } + +// editSSHOptions edits one SSH option layer: global, per instance, or per rule. +func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error) { + proxyJump := o.ProxyJump + hostKeyChecking := o.HostKeyChecking + target := o.Target + addressFamily := o.AddressFamily + extraArgs := strings.Join(o.ExtraArgs, "\n") + options := formatOptions(o.Options) + + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Jump host"). + Description("Passed as -J, e.g. admin@bastion.example.com.\nLeave empty to inherit; 'none' disables a jump host set elsewhere."). + Value(&proxyJump). + Validate(func(s string) error { + return config.SSHOptions{ProxyJump: strings.TrimSpace(s)}.Validate("ssh") + }), + huh.NewSelect[string](). + Title("Host key checking"). + Options(inheritOption(), huh.NewOption("strict", "strict"), huh.NewOption("accept-new", "accept-new"), huh.NewOption("off", "off")). + Value(&hostKeyChecking), + huh.NewSelect[string](). + Title("Connection target"). + Description("What is passed to ssh as destination."). + Options(inheritOption(), huh.NewOption("ip — resolved address", "ip"), huh.NewOption("name — NetBox name", "name"), huh.NewOption("hostname — name as typed", "hostname")). + Value(&target), + huh.NewSelect[string](). + Title("Address family"). + Options(inheritOption(), huh.NewOption("any", "any"), huh.NewOption("inet (IPv4)", "inet"), huh.NewOption("inet6 (IPv6)", "inet6")). + Value(&addressFamily), + huh.NewText(). + Title("Extra arguments"). + Description("One argument per line, inserted before the destination, e.g. -A"). + Lines(3). + Value(&extraArgs), + huh.NewText(). + Title("SSH options"). + Description("One Key=Value per line, passed as -o, e.g. ServerAliveInterval=60"). + Lines(4). + Value(&options). + Validate(func(s string) error { + _, err := parseOptions(s) + return err + }), + ).Title(title)).Run() + if err != nil { + return o, err + } + + parsed, _ := parseOptions(options) + return config.SSHOptions{ + ProxyJump: strings.TrimSpace(proxyJump), + HostKeyChecking: hostKeyChecking, + Target: target, + AddressFamily: addressFamily, + ExtraArgs: parseLines(extraArgs), + Options: parsed, + }, nil +} + +// parseLines returns the non-empty, trimmed lines of s. +func parseLines(s string) []string { + var out []string + for _, line := range strings.Split(s, "\n") { + if line = strings.TrimSpace(line); line != "" { + out = append(out, line) + } + } + return out +} + +// parseOptions reads Key=Value lines into the map stored under ssh.options. +func parseOptions(s string) (map[string]string, error) { + var out map[string]string + for _, line := range parseLines(s) { + key, value, ok := strings.Cut(line, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return nil, fmt.Errorf("%q: expected Key=Value", line) + } + if out == nil { + out = make(map[string]string) + } + out[key] = strings.TrimSpace(value) + } + if err := (config.SSHOptions{Options: out}).Validate("ssh"); err != nil { + return nil, err + } + return out, nil +} + +func formatOptions(options map[string]string) string { + keys := make([]string, 0, len(options)) + for key := range options { + keys = append(keys, key) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, key := range keys { + lines = append(lines, key+"="+options[key]) + } + return strings.Join(lines, "\n") +} + +// configureRules edits a copy of the rules; the caller decides whether to keep it. +func configureRules(rules []config.SSHRule, sources []config.NetBoxConfig) ([]config.SSHRule, error) { + rules = append([]config.SSHRule(nil), rules...) + for { + action := "done" + var options []huh.Option[string] + for i, rule := range rules { + index := strconv.Itoa(i) + options = append(options, huh.NewOption("Edit rule "+describeMatch(rule.Match), "edit:"+index)) + options = append(options, huh.NewOption("Remove rule "+describeMatch(rule.Match), "remove:"+index)) + } + options = append(options, huh.NewOption("Add rule", "add"), huh.NewOption("Back", "done")) + err := huh.NewForm(huh.NewGroup(huh.NewSelect[string](). + Title("SSH rules"). + Description("Rules apply SSH options to hosts matching every set field. Later rules win."). + Options(options...). + Value(&action))).Run() + if err != nil { + return nil, err + } + if action == "done" { + return rules, nil + } + if action == "add" { + rule, err := editRule(config.SSHRule{}, sources) + if err != nil { + return nil, err + } + rules = append(rules, rule) + continue + } + verb, index, _ := strings.Cut(action, ":") + i, _ := strconv.Atoi(index) + if verb == "edit" { + rule, err := editRule(rules[i], sources) + if err != nil { + return nil, err + } + rules[i] = rule + continue + } + remove := false + if err := huh.NewConfirm().Title("Remove rule " + describeMatch(rules[i].Match) + "?").Value(&remove).Run(); err != nil { + return nil, err + } + if remove { + rules = append(rules[:i], rules[i+1:]...) + } + } +} + +func editRule(rule config.SSHRule, sources []config.NetBoxConfig) (config.SSHRule, error) { + match := rule.Match + instances := []huh.Option[string]{huh.NewOption("(any)", "")} + known := false + for _, source := range sources { + instances = append(instances, huh.NewOption(source.Name, source.Name)) + known = known || strings.EqualFold(source.Name, match.Instance) + } + if match.Instance != "" && !known { + instances = append(instances, huh.NewOption(match.Instance+" (not configured)", match.Instance)) + } + + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Tag"). + Description("NetBox tag name or slug, e.g. dmz"). + Value(&match.Tag), + huh.NewSelect[string](). + Title("Instance"). + Options(instances...). + Value(&match.Instance), + huh.NewSelect[string](). + Title("Kind"). + Options(huh.NewOption("(any)", ""), huh.NewOption("device", "device"), huh.NewOption("vm", "vm")). + Value(&match.Kind), + huh.NewInput(). + Title("Name pattern"). + Description("Shell-style glob without instance prefix, e.g. *.dmz.example.com"). + Value(&match.Name). + Validate(func(s string) error { + candidate := match + candidate.Tag = strings.TrimSpace(candidate.Tag) + candidate.Name = strings.TrimSpace(s) + return candidate.Validate("rule") + }), + ).Title("Rule match")).Run() + if err != nil { + return rule, err + } + match.Tag = strings.TrimSpace(match.Tag) + match.Name = strings.TrimSpace(match.Name) + + options, err := editSSHOptions("SSH options for rule "+describeMatch(match), rule.SSHOptions) + if err != nil { + return rule, err + } + return config.SSHRule{Match: match, SSHOptions: options}, nil +} + +func describeMatch(m config.SSHMatch) string { + var parts []string + for _, field := range [][2]string{{"tag", m.Tag}, {"instance", m.Instance}, {"kind", m.Kind}, {"name", m.Name}} { + if field[1] != "" { + parts = append(parts, field[0]+"="+field[1]) + } + } + return "[" + strings.Join(parts, " ") + "]" +} diff --git a/internal/setup/ssh_test.go b/internal/setup/ssh_test.go new file mode 100644 index 0000000..c8ce079 --- /dev/null +++ b/internal/setup/ssh_test.go @@ -0,0 +1,72 @@ +package setup + +import ( + "reflect" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +func TestParseLines(t *testing.T) { + got := parseLines(" -A \n\n\t-C\n \n") + if want := []string{"-A", "-C"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + if got := parseLines(" \n "); got != nil { + t.Fatalf("blank input: got %q, want nil", got) + } +} + +func TestParseOptionsRoundtrip(t *testing.T) { + text := "ServerAliveInterval = 60\n\nForwardAgent=yes\nLocalCommand=echo a=b\n" + got, err := parseOptions(text) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"ServerAliveInterval": "60", "ForwardAgent": "yes", "LocalCommand": "echo a=b"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + again, err := parseOptions(formatOptions(got)) + if err != nil || !reflect.DeepEqual(again, want) { + t.Fatalf("roundtrip: got %v (%v)", again, err) + } + if formatOptions(got) != "ForwardAgent=yes\nLocalCommand=echo a=b\nServerAliveInterval=60" { + t.Fatalf("formatOptions not sorted: %q", formatOptions(got)) + } +} + +func TestParseOptionsEmpty(t *testing.T) { + got, err := parseOptions("\n \n") + if err != nil || got != nil { + t.Fatalf("got %v, %v; want nil, nil", got, err) + } +} + +func TestParseOptionsInvalid(t *testing.T) { + for _, text := range []string{"ServerAliveInterval", "=60", "Bad-Key=1", "1Key=1"} { + if _, err := parseOptions(text); err == nil { + t.Errorf("%q: expected error", text) + } + } +} + +func TestDescribeMatch(t *testing.T) { + got := describeMatch(config.SSHMatch{Tag: "dmz", Name: "*.dmz"}) + if got != "[tag=dmz name=*.dmz]" { + t.Fatalf("got %q", got) + } +} + +func TestRuleValidation(t *testing.T) { + if err := (config.SSHMatch{}).Validate("rule"); err == nil || !strings.Contains(err.Error(), "at least one") { + t.Fatalf("empty match: got %v", err) + } + if err := (config.SSHMatch{Name: "[web"}).Validate("rule"); err == nil { + t.Fatal("invalid glob accepted") + } + if err := (config.SSHMatch{Kind: "vm"}).Validate("rule"); err != nil { + t.Fatalf("valid match rejected: %v", err) + } +} diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 9b78b26..9c5f25e 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -17,163 +17,19 @@ import ( "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" ) -// RunWizard runs the interactive setup form, pre-filled with any existing cfg values. +// RunWizard runs the interactive configurator, pre-filled with any existing cfg values. // It saves the result to the config file on success. func RunWizard(cfg *config.Config) error { sources, err := configureInstances(cfg) if err != nil { - if errors.Is(err, huh.ErrUserAborted) { - fmt.Fprintln(os.Stderr, "Setup cancelled.") - os.Exit(0) - } - return err + return cancelled(err) } - defaultUser := cfg.SSH.DefaultUser - defaultPort := "" - if cfg.SSH.DefaultPort > 0 { - defaultPort = strconv.Itoa(cfg.SSH.DefaultPort) - } - strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ") - subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ") - interfaceName := cfg.Resolver.InterfaceName - cacheTTL := strconv.Itoa(cfg.Cache.TTL) - shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ") - stripHyphens := cfg.Shortcuts.StripHyphens - commandAlias := cfg.Shell.Alias - - if strategiesRaw == "" { - strategiesRaw = "primary_ip" - } - if cacheTTL == "0" { - cacheTTL = "3600" + work := prepare(*cfg, sources) + if err := runMenu(&work); err != nil { + return cancelled(err) } - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Default SSH user"). - Description("Leave empty to use your system user ($USER)."). - Value(&defaultUser), - huh.NewInput(). - Title("Default SSH port"). - Description("Leave empty to use the standard port (22)."). - Placeholder("22"). - Value(&defaultPort). - Validate(func(s string) error { - if strings.TrimSpace(s) == "" { - return nil - } - n, err := strconv.Atoi(strings.TrimSpace(s)) - if err != nil || n < 1 || n > 65535 { - return errors.New("must be a port number between 1 and 65535") - } - return nil - }), - ).Title("SSH defaults"), - - huh.NewGroup( - huh.NewInput(). - Title("Resolver strategies"). - Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name"). - Placeholder("primary_ip, management_subnet"). - Value(&strategiesRaw). - Validate(validateStrategies), - huh.NewInput(). - Title("Management subnets"). - Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active."). - Value(&subnets), - huh.NewInput(). - Title("Interface name"). - Description("Only used when interface_name strategy is active."). - Placeholder("eth0"). - Value(&interfaceName), - huh.NewInput(). - Title("Cache TTL (seconds)"). - Value(&cacheTTL). - Validate(func(s string) error { - if _, err := strconv.Atoi(s); err != nil { - return errors.New("must be a number") - } - return nil - }), - ).Title("Resolver & cache"), - - huh.NewGroup( - huh.NewInput(). - Title("Domain suffixes"). - Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'."). - Placeholder(".example.com"). - Value(&shortcutDomains), - huh.NewConfirm(). - Title("Strip hyphens"). - Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache."). - Value(&stripHyphens), - huh.NewInput(). - Title("Command alias"). - Description("Optional alias for netssh itself, e.g. j. Leave empty to disable."). - Placeholder("j"). - Value(&commandAlias). - Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }), - ).Title("Shortcuts"), - ) - - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - fmt.Fprintln(os.Stderr, "Setup cancelled.") - os.Exit(0) - } - return err - } - - strategies := parseStrategies(strategiesRaw) - - ttl, _ := strconv.Atoi(cacheTTL) - - port := 0 - if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil { - port = p - } - - var subnetList []string - for _, s := range strings.Split(subnets, ",") { - if s = strings.TrimSpace(s); s != "" { - subnetList = append(subnetList, s) - } - } - - var domainList []string - for _, s := range strings.Split(shortcutDomains, ",") { - if s = strings.TrimSpace(s); s != "" { - if !strings.HasPrefix(s, ".") { - s = "." + s - } - domainList = append(domainList, s) - } - } - - out := config.Config{ - Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)}, - NetBoxes: sources, - SSH: config.SSHConfig{ - DefaultUser: strings.TrimSpace(defaultUser), - DefaultPort: port, - }, - Resolver: config.ResolverConfig{ - Strategies: strategies, - ManagementSubnets: subnetList, - InterfaceName: strings.TrimSpace(interfaceName), - }, - Cache: config.CacheConfig{ - TTL: ttl, - Path: cfg.Cache.Path, - }, - Shortcuts: config.ShortcutsConfig{ - Domains: domainList, - StripHyphens: stripHyphens, - }, - } - - if err := save(out); err != nil { + if err := save(work); err != nil { return err } loaded, err := config.Load() @@ -202,6 +58,228 @@ func RunWizard(cfg *config.Config) error { return nil } +func cancelled(err error) error { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Fprintln(os.Stderr, "Setup cancelled.") + os.Exit(0) + } + return err +} + +// prepare starts from the loaded config so settings without a menu entry survive +// a rewrite, migrates a legacy single instance, and fills in usable defaults. +func prepare(cfg config.Config, sources []config.NetBoxConfig) config.Config { + cfg.NetBox = config.NetBoxConfig{} + cfg.NetBoxes = sources + if len(cfg.Resolver.Strategies) == 0 { + cfg.Resolver.Strategies = []string{"primary_ip"} + } + if cfg.Cache.TTL == 0 { + cfg.Cache.TTL = 3600 + } + return cfg +} + +func runMenu(cfg *config.Config) error { + for { + action := "save" + err := huh.NewForm(huh.NewGroup(huh.NewSelect[string](). + Title("netssh configuration"). + Description("Choose a section to edit, then save."). + Options( + huh.NewOption("SSH defaults — user, port", "defaults"), + huh.NewOption("SSH options — jump host, host keys, target, -o", "ssh"), + huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"), + huh.NewOption("Resolver & cache", "resolver"), + huh.NewOption("Shortcuts & command alias", "shortcuts"), + huh.NewOption("Completion", "completion"), + huh.NewOption("Save and exit", "save"), + ). + Value(&action))).Run() + if err != nil { + return err + } + switch action { + case "defaults": + err = editSSHDefaults(cfg) + case "ssh": + cfg.SSH.SSHOptions, err = editSSHOptions("SSH options (all hosts)", cfg.SSH.SSHOptions) + case "rules": + var rules []config.SSHRule + if rules, err = configureRules(cfg.SSH.Rules, cfg.NetBoxes); err == nil { + cfg.SSH.Rules = rules + } + case "resolver": + err = editResolverCache(cfg) + case "shortcuts": + err = editShortcuts(cfg) + case "completion": + err = editCompletion(cfg) + case "save": + return nil + } + if err != nil { + return err + } + } +} + +func editSSHDefaults(cfg *config.Config) error { + defaultUser := cfg.SSH.DefaultUser + defaultPort := "" + if cfg.SSH.DefaultPort > 0 { + defaultPort = strconv.Itoa(cfg.SSH.DefaultPort) + } + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Default SSH user"). + Description("Leave empty to use your system user ($USER)."). + Value(&defaultUser), + huh.NewInput(). + Title("Default SSH port"). + Description("Leave empty to use the standard port (22)."). + Placeholder("22"). + Value(&defaultPort). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 1 || n > 65535 { + return errors.New("must be a port number between 1 and 65535") + } + return nil + }), + ).Title("SSH defaults")).Run() + if err != nil { + return err + } + cfg.SSH.DefaultUser = strings.TrimSpace(defaultUser) + cfg.SSH.DefaultPort, _ = strconv.Atoi(strings.TrimSpace(defaultPort)) + return nil +} + +func editResolverCache(cfg *config.Config) error { + strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ") + subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ") + interfaceName := cfg.Resolver.InterfaceName + cacheTTL := strconv.Itoa(cfg.Cache.TTL) + cachePath := cfg.Cache.Path + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Resolver strategies"). + Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name"). + Placeholder("primary_ip, management_subnet"). + Value(&strategiesRaw). + Validate(validateStrategies), + huh.NewInput(). + Title("Management subnets"). + Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active."). + Value(&subnets), + huh.NewInput(). + Title("Interface name"). + Description("Only used when interface_name strategy is active."). + Placeholder("eth0"). + Value(&interfaceName), + huh.NewInput(). + Title("Cache TTL (seconds)"). + Value(&cacheTTL). + Validate(func(s string) error { + if _, err := strconv.Atoi(s); err != nil { + return errors.New("must be a number") + } + return nil + }), + huh.NewInput(). + Title("Cache file"). + Description("Where resolved hosts are stored. Leave empty for the default location."). + Value(&cachePath), + ).Title("Resolver & cache")).Run() + if err != nil { + return err + } + cfg.Resolver.Strategies = parseStrategies(strategiesRaw) + cfg.Resolver.ManagementSubnets = splitList(subnets) + cfg.Resolver.InterfaceName = strings.TrimSpace(interfaceName) + cfg.Cache.TTL, _ = strconv.Atoi(cacheTTL) + cfg.Cache.Path = strings.TrimSpace(cachePath) + return nil +} + +func editShortcuts(cfg *config.Config) error { + shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ") + stripHyphens := cfg.Shortcuts.StripHyphens + commandAlias := cfg.Shell.Alias + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Domain suffixes"). + Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'."). + Placeholder(".example.com"). + Value(&shortcutDomains), + huh.NewConfirm(). + Title("Strip hyphens"). + Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache."). + Value(&stripHyphens), + huh.NewInput(). + Title("Command alias"). + Description("Optional alias for netssh itself, e.g. j. Leave empty to disable."). + Placeholder("j"). + Value(&commandAlias). + Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }), + ).Title("Shortcuts")).Run() + if err != nil { + return err + } + cfg.Shortcuts.Domains = nil + for _, s := range splitList(shortcutDomains) { + if !strings.HasPrefix(s, ".") { + s = "." + s + } + cfg.Shortcuts.Domains = append(cfg.Shortcuts.Domains, s) + } + cfg.Shortcuts.StripHyphens = stripHyphens + cfg.Shell.Alias = strings.TrimSpace(commandAlias) + return nil +} + +func editCompletion(cfg *config.Config) error { + maxResults := "" + if cfg.Completion.MaxResults > 0 { + maxResults = strconv.Itoa(cfg.Completion.MaxResults) + } + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Maximum completion results"). + Description("Limits the hostnames offered per Tab completion. Leave empty for unlimited."). + Value(&maxResults). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + if n, err := strconv.Atoi(strings.TrimSpace(s)); err != nil || n < 0 { + return errors.New("must be a non-negative number") + } + return nil + }), + ).Title("Completion")).Run() + if err != nil { + return err + } + cfg.Completion.MaxResults, _ = strconv.Atoi(strings.TrimSpace(maxResults)) + return nil +} + +// splitList returns the non-empty, trimmed entries of a comma-separated list. +func splitList(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out +} + var knownStrategies = map[string]bool{ "primary_ip": true, "management_subnet": true, @@ -220,15 +298,7 @@ func validateStrategies(s string) error { return nil } -func parseStrategies(s string) []string { - var out []string - for _, part := range strings.Split(s, ",") { - if name := strings.TrimSpace(part); name != "" { - out = append(out, name) - } - } - return out -} +func parseStrategies(s string) []string { return splitList(s) } func save(cfg config.Config) error { if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil { @@ -237,6 +307,9 @@ func save(cfg config.Config) error { if err := cfg.ValidateNetBoxes(); err != nil { return err } + if err := cfg.ValidateSSH(); err != nil { + return err + } path := config.Path() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("creating config dir: %w", err) diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index 4469300..d2a3ead 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -455,3 +455,46 @@ func TestSaveKeepsSSHOptions(t *testing.T) { t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH) } } + +// Re-running the configurator must keep settings that have no form field of +// their own or were not touched in the menu. +func TestPrepareKeepsUnmanagedSettings(t *testing.T) { + cfg := config.Config{ + NetBox: config.NetBoxConfig{URL: "https://legacy.example", Token: "t"}, + SSH: config.SSHConfig{ + DefaultUser: "root", + SSHOptions: config.SSHOptions{ProxyJump: "jh.example.com", Options: map[string]string{"forwardagent": "yes"}}, + Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}}, + }, + Completion: config.CompletionConfig{MaxResults: 50}, + Cache: config.CacheConfig{Path: "/var/cache/netssh/hosts.json"}, + } + sources := []config.NetBoxConfig{{Name: "default", URL: "https://legacy.example", Token: "t"}} + got := prepare(cfg, sources) + + if !reflect.DeepEqual(got.SSH, cfg.SSH) || got.Completion != cfg.Completion || got.Cache.Path != cfg.Cache.Path { + t.Fatalf("settings lost: %+v", got) + } + if got.NetBox.URL != "" || !reflect.DeepEqual(got.NetBoxes, sources) { + t.Fatalf("legacy instance not migrated: %+v / %+v", got.NetBox, got.NetBoxes) + } + if !reflect.DeepEqual(got.Resolver.Strategies, []string{"primary_ip"}) || got.Cache.TTL != 3600 { + t.Fatalf("defaults missing: %+v %+v", got.Resolver, got.Cache) + } +} + +func TestSaveRejectsInvalidSSHOptions(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + cfg := config.Config{ + NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}}, + Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}}, + SSH: config.SSHConfig{Rules: []config.SSHRule{{SSHOptions: config.SSHOptions{ProxyJump: "none"}}}}, + } + if err := save(cfg); err == nil { + t.Fatal("rule without match was saved") + } + if _, err := os.Stat(config.Path()); !os.IsNotExist(err) { + t.Fatalf("config file written despite error: %v", err) + } +}