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 1/2] 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) + } +} -- 2.54.0 From 878701aba70decf24ae22cae0dbd589a9616d91a 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 2/2] feat(shortcuts): regex rewrite rules for host shortcuts shortcuts.rewrites applies regex replacements after the domain suffix is stripped, e.g. ^([a-z]+)[0-9]+- -> $1 turns fsn1-bx1.hoster.de into fsnbx1. The shortcut works for connecting (cached hosts), shell aliases, and Tab completion, where it is offered when the full name does not match the typed text. Rules are editable in netssh configure, and netssh alias --test HOSTNAME shows a name's shortcut and the cached hosts it matches. The shortcut lookup only ran when domains or strip_hyphens were set; it now also runs for rewrite-only configurations. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + README.md | 20 ++++++- cmd/netssh/instances.go | 62 ++++++++++++++++++-- cmd/netssh/instances_test.go | 84 +++++++++++++++++++++++++++- cmd/netssh/main.go | 41 ++++++++++++-- internal/config/config.go | 33 ++++++++++- internal/config/config_test.go | 19 +++++++ internal/hook/aliases_test.go | 11 ++++ internal/setup/wizard.go | 48 +++++++++++++++- internal/setup/wizard_test.go | 44 +++++++++++++++ internal/shortcuts/shortcuts.go | 28 +++++++++- internal/shortcuts/shortcuts_test.go | 37 ++++++++++++ 12 files changed, 408 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab37c59..49a37cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - `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. +- Add `shortcuts.rewrites`: regex rules that shorten hostnames, e.g. `^([a-z]+)[0-9]+-` → `$1` turns `fsn1-bx1.hoster.de` into `fsnbx1`. Shortcuts work for connecting, shell aliases, and Tab completion (offered when the full name does not match), and can be edited in `netssh configure`. +- Add `netssh alias --test HOSTNAME` to show the shortcut of a name and the cached hosts it matches. ## v0.0.22 diff --git a/README.md b/README.md index 024e352..6bf2069 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ netssh -p 2222 admin@app-server-03 uptime - **Shell completion** — install without sudo via `netssh completion install` - **Default SSH user** — set a fallback username once in config instead of typing it every time - **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line -- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding +- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip, hyphen folding, and regex rewrite rules (`fsn1-bx1.hoster.de` → `fsnbx1`) - **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts - **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours - **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config` @@ -115,7 +115,7 @@ A menu then gives access to every other setting; open only the sections you want | 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` | +| Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` | | Completion | `completion.max_results` | The result is validated and saved to `~/.config/netssh.yaml`; Ctrl+C cancels without writing. @@ -366,13 +366,26 @@ shortcuts: - .example.com - .example.de strip_hyphens: false # optional + rewrites: # optional, applied in order + - pattern: '^([a-z]+)[0-9]+-' # fsn1-bx1 → fsnbx1 + replace: '$1' ``` Resolution order: 1. **Exact match** — the input is looked up as-is in the cache. 2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name. -3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first. +3. **Shortcut match** — the input and every cached hostname are shortened the same way and compared: lowercase, strip the domain suffix, apply each rewrite rule in order, then remove hyphens if `strip_hyphens` is set (`fsn1web01` → `fsn1-web01.example.com`, `fsnbx1` → `fsn1-bx1.hoster.de`). This only works when the host is already cached; run `netssh cache refresh` first. + +**Rewrite rules** use Go regular expressions ([syntax](https://pkg.go.dev/regexp/syntax)); `replace` may refer to groups as `$1` or `${1}`. Every match in the name is replaced. Because the typed shortcut is rewritten too, write rules that leave an already shortened name unchanged: `^([a-z]+)[0-9]+-` matches `fsn1-bx1` but not `fsnbx1`. Shortcuts also become shell aliases and are offered by Tab completion when the full name does not match what you typed (`fsnb` → `fsnbx1`). + +Check what a hostname is shortened to, and whether the shortcut is unique in the cache: + +```sh +$ netssh alias --test fsn1-bx1.hoster.de +fsn1-bx1.hoster.de → fsnbx1 +matches fsn1-bx1.hoster.de +``` Domains may be configured with or without a leading dot. If several cached hosts normalize to the same shortcut, the connection is rejected; use the full hostname. Live host lookups check every result page and reject duplicate exact names. @@ -380,6 +393,7 @@ Domains may be configured with or without a leading dot. If several cached hosts |-------|--------|-------------| | `web01` | `domains: [.example.com]` | `web01.example.com` | | `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) | +| `fsnbx1` | `domains: [.hoster.de]`, rewrite `^([a-z]+)[0-9]+-` → `$1` | `fsn1-bx1.hoster.de` (cache only) | ### Shell aliases diff --git a/cmd/netssh/instances.go b/cmd/netssh/instances.go index 2d23f70..206de3a 100644 --- a/cmd/netssh/instances.go +++ b/cmd/netssh/instances.go @@ -41,7 +41,9 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st // all instances is offered bare ("web01"), so it completes like with a single // NetBox; connecting to it still checks every instance for ambiguity. // Names present in several instances stay qualified ("prod/web01"). -func hostCompletions(entries []cache.Entry, toComplete string) []string { +// Hosts whose full name does not match are offered by their configured +// shortcut instead ("fsnb" → "fsnbx1" for fsn1-bx1.example.com). +func hostCompletions(entries []cache.Entry, toComplete string, sc config.ShortcutsConfig) []string { prefix := strings.ToLower(toComplete) qualifiedInput := strings.Contains(prefix, "/") instances := make(map[string]int) @@ -49,21 +51,69 @@ func hostCompletions(entries []cache.Entry, toComplete string) []string { _, name := netbox.SplitTarget(e.Name) instances[strings.ToLower(name)]++ } - var out []string + var unmatched []cache.Entry + var names []string for _, e := range entries { source, name := netbox.SplitTarget(e.Name) target := strings.ToLower(e.Name) switch { case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix): if instances[strings.ToLower(name)] == 1 { - out = append(out, name) + names = append(names, name) } else { - out = append(out, e.Name) + names = append(names, e.Name) } case strings.HasPrefix(target, prefix): - out = append(out, e.Name) + names = append(names, e.Name) + default: + unmatched = append(unmatched, e) + } + } + if sc.Enabled() { + names = append(names, shortcutCompletions(entries, unmatched, prefix, sc)...) + } + sort.Strings(names) + return names +} + +// shortcutCompletions offers the shortcut of each unmatched host when it starts +// with prefix and selects exactly one host, qualified by instance if needed. +func shortcutCompletions(all, unmatched []cache.Entry, prefix string, sc config.ShortcutsConfig) []string { + bare := make(map[string]int) + qualified := make(map[string]int) + short := func(e cache.Entry) (string, string) { + source, name := netbox.SplitTarget(e.Name) + s := shortcuts.Normalize(name, sc) + if source == "" { + return s, s + } + return s, strings.ToLower(source) + "/" + s + } + for _, e := range all { + b, q := short(e) + bare[b]++ + qualified[q]++ + } + seen := make(map[string]bool) + var out []string + for _, e := range unmatched { + b, q := short(e) + candidate := "" + switch { + case strings.Contains(prefix, "/"): + if qualified[q] == 1 && strings.HasPrefix(q, prefix) { + candidate = q + } + case !strings.HasPrefix(b, prefix): + case bare[b] == 1: + candidate = b + case q != b && qualified[q] == 1: + candidate = q // like full names: zsh accepts it, bash/fish ignore it + } + if candidate != "" && !seen[candidate] { + seen[candidate] = true + out = append(out, candidate) } } - sort.Strings(out) return out } diff --git a/cmd/netssh/instances_test.go b/cmd/netssh/instances_test.go index 02955c6..69412b7 100644 --- a/cmd/netssh/instances_test.go +++ b/cmd/netssh/instances_test.go @@ -228,12 +228,92 @@ func TestHostCompletionsMatchTypedText(t *testing.T) { // An instance name typed without the slash completes qualified targets. {"st", []string{"stg/fsn1-bx2.example.com", "stg/hel1-tb1.example.com"}}, } { - if got := hostCompletions(entries, tc.input); !reflect.DeepEqual(got, tc.want) { + if got := hostCompletions(entries, tc.input, config.ShortcutsConfig{}); !reflect.DeepEqual(got, tc.want) { t.Errorf("%q: got %v, want %v", tc.input, got, tc.want) } } // Single-instance caches are unchanged. - if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w"); !reflect.DeepEqual(got, []string{"web01"}) { + if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w", config.ShortcutsConfig{}); !reflect.DeepEqual(got, []string{"web01"}) { t.Errorf("single instance: %v", got) } } + +var fsnRewrite = config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}, +} + +func TestHostCompletionsOfferShortcuts(t *testing.T) { + entries := []cache.Entry{ + {Name: "prod/fsn1-bx1.hoster.de"}, + {Name: "prod/fsn1-db1.hoster.de"}, + {Name: "stg/fsn1-db1.hoster.de"}, + {Name: "prod/web01.hoster.de"}, + } + for _, tc := range []struct { + input string + want []string + }{ + {"fsnb", []string{"fsnbx1"}}, + // Shortcut shared by two instances stays qualified. + {"fsnd", []string{"prod/fsndb1", "stg/fsndb1"}}, + {"prod/fsn", []string{"prod/fsn1-bx1.hoster.de", "prod/fsn1-db1.hoster.de"}}, + {"stg/fsnd", []string{"stg/fsndb1"}}, + // A matching full name is not repeated as shortcut. + {"web", []string{"web01.hoster.de"}}, + } { + if got := hostCompletions(entries, tc.input, fsnRewrite); !reflect.DeepEqual(got, tc.want) { + t.Errorf("%q: got %v, want %v", tc.input, got, tc.want) + } + } + // Two hosts of the same instance with one shortcut cannot be selected by it. + same := []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}} + if got := hostCompletions(same, "fsnb", fsnRewrite); len(got) != 0 { + t.Errorf("ambiguous shortcut offered: %v", got) + } +} + +func TestShortcutTestOutput(t *testing.T) { + var out bytes.Buffer + printShortcutTest(&out, "fsn1-bx1.hoster.de", []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}, {Name: "web01.hoster.de"}}, fsnRewrite) + want := "fsn1-bx1.hoster.de → fsnbx1\nambiguous, matches 2 hosts:\n fsn1-bx1.hoster.de\n fsn2-bx1.hoster.de\n" + if out.String() != want { + t.Fatalf("got:\n%s", out.String()) + } +} + +// Rewrite rules alone must enable the shortcut lookup; NetBox is unreachable, +// so only the cached host can answer. +func TestWrapperConnectsByRewriteShortcut(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("NETSSH_NETBOX_URL", "") + t.Setenv("NETSSH_NETBOX_TOKEN", "") + t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json")) + t.Setenv("NETSSH_SHELL_ALIAS", "") + data := "netbox:\n url: http://127.0.0.1:1\n token: t\nshortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n" + if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + c := cache.New(cfg.CachePath(), 3600) + c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device"}) + if err := c.Save(); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$") + cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET=fsnbx1.hoster.de", "PATH="+bin+":"+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(out), "10.0.0.9\n") { + t.Fatalf("shortcut not resolved from cache: %s, %v", out, err) + } +} diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index b8af0aa..1ccf046 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -132,7 +132,7 @@ func runSSHWrapper(args []string) { // Shortcut cache lookup: scan entries whose normalized name matches the input. // If a stale match is found, use the canonical name for the NetBox re-fetch below. lookupHost := parsed.Host - shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens + shortcutsEnabled := cfg.Shortcuts.Enabled() if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache { normalize := normalizeTarget(cfg.Shortcuts, source != "") entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize) @@ -339,7 +339,7 @@ func rootCmd() *cobra.Command { c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() - hosts := hostCompletions(c.All(), toComplete) + hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts) if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit { hosts = hosts[:limit] } @@ -645,13 +645,15 @@ func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry { } func aliasCmd() *cobra.Command { - var shell string + var shell, test string cmd := &cobra.Command{ Use: "alias", Short: "Print shell aliases for all cached hosts", Long: `Print shell alias definitions for all cached hosts. The alias name is the shortened form derived from the configured shortcuts -(domain suffixes stripped, hyphens optionally stripped). +(domain suffixes stripped, rewrite rules applied, hyphens optionally stripped). + +Check a rule with: netssh alias --test fsn1-bx1.example.com Source the output in your shell profile: bash/zsh: eval "$(netssh alias)" @@ -668,6 +670,10 @@ Or use in a script: if err := c.Load(); err != nil { return err } + if test != "" { + printShortcutTest(os.Stdout, test, c.All(), cfg.Shortcuts) + return nil + } if shell == "" { shell = filepath.Base(os.Getenv("SHELL")) } @@ -694,9 +700,36 @@ Or use in a script: }, } cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)") + cmd.Flags().StringVar(&test, "test", "", "Show the shortcut for a hostname and the cached hosts it matches") return cmd } +// printShortcutTest helps writing shortcut rules: it shows what a name is +// shortened to and which cached hosts share that shortcut. +func printShortcutTest(w io.Writer, name string, entries []cache.Entry, sc config.ShortcutsConfig) { + normalize := normalizeTarget(sc, strings.Contains(name, "/")) + short := normalize(name) + fmt.Fprintf(w, "%s → %s\n", name, short) + var matches []string + for _, e := range entries { + if normalize(e.Name) == short { + matches = append(matches, e.Name) + } + } + sort.Strings(matches) + switch len(matches) { + case 0: + fmt.Fprintln(w, "no cached host has this shortcut") + case 1: + fmt.Fprintf(w, "matches %s\n", matches[0]) + default: + fmt.Fprintf(w, "ambiguous, matches %d hosts:\n", len(matches)) + for _, m := range matches { + fmt.Fprintf(w, " %s\n", m) + } + } +} + // shellInitCmd is called at shell startup to update alias files and trigger a // background cache refresh when stale. Intentionally silent — never disrupts the prompt. func shellInitCmd() *cobra.Command { diff --git a/internal/config/config.go b/internal/config/config.go index c498b9c..04d7ea5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,8 +57,34 @@ type CompletionConfig struct { } type ShortcutsConfig struct { - Domains []string `mapstructure:"domains"` - StripHyphens bool `mapstructure:"strip_hyphens"` + Domains []string `mapstructure:"domains"` + StripHyphens bool `mapstructure:"strip_hyphens"` + Rewrites []ShortcutRewrite `mapstructure:"rewrites"` +} + +// ShortcutRewrite replaces every match of Pattern (Go regexp syntax) with +// Replace, which may reference groups as $1. Applied after domain stripping. +type ShortcutRewrite struct { + Pattern string `mapstructure:"pattern"` + Replace string `mapstructure:"replace"` +} + +// Enabled reports whether any shortcut normalization is configured. +func (s ShortcutsConfig) Enabled() bool { + return len(s.Domains) > 0 || s.StripHyphens || len(s.Rewrites) > 0 +} + +// Validate rejects rewrite rules that cannot be compiled. +func (s ShortcutsConfig) Validate() error { + for i, rewrite := range s.Rewrites { + if rewrite.Pattern == "" { + return fmt.Errorf("shortcuts.rewrites[%d]: pattern is required", i) + } + if _, err := regexp.Compile(rewrite.Pattern); err != nil { + return fmt.Errorf("shortcuts.rewrites[%d]: invalid pattern %q: %v", i, rewrite.Pattern, err) + } + } + return nil } // Path returns the canonical config file path. @@ -128,6 +154,9 @@ func Load() (*Config, error) { if err := cfg.ValidateSSH(); err != nil { return nil, err } + if err := cfg.Shortcuts.Validate(); err != nil { + return nil, err + } for i := range cfg.NetBoxes { source := &cfg.NetBoxes[i] source.Name = strings.ToLower(source.Name) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a5594a4..f9e6997 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -177,3 +178,21 @@ func TestCommandAliasEnvironmentAndValidation(t *testing.T) { } } } + +func TestLoad_ShortcutRewrites(t *testing.T) { + dir := writeConfig(t, "shortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n") + cfg := loadFromDir(t, dir) + want := []ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}} + if len(cfg.Shortcuts.Rewrites) != 1 || cfg.Shortcuts.Rewrites[0] != want[0] || !cfg.Shortcuts.Enabled() { + t.Fatalf("rewrites: %+v", cfg.Shortcuts) + } +} + +func TestLoad_InvalidShortcutRewrite(t *testing.T) { + for _, rule := range []string{"pattern: '[a-'", "replace: x"} { + t.Setenv("XDG_CONFIG_HOME", writeConfig(t, "shortcuts:\n rewrites:\n - "+rule+"\n")) + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "shortcuts.rewrites[0]") { + t.Errorf("%s: got %v", rule, err) + } + } +} diff --git a/internal/hook/aliases_test.go b/internal/hook/aliases_test.go index 2facfa2..e7f4b3c 100644 --- a/internal/hook/aliases_test.go +++ b/internal/hook/aliases_test.go @@ -242,3 +242,14 @@ func TestAliasesIncludeInstanceName(t *testing.T) { t.Fatalf("alias lost source: %+v", entries) } } + +func TestAliasesUseRewriteRules(t *testing.T) { + cfg := config.Config{Shortcuts: config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}, + }} + entries := hook.BuildAliasEntries([]cache.Entry{{Name: "fsn1-bx1.hoster.de"}}, cfg) + if len(entries) != 1 || entries[0].Definition("bash") != "alias fsnbx1='netssh fsn1-bx1.hoster.de'" { + t.Fatalf("entries: %+v", entries) + } +} diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 9c5f25e..7cb5a5e 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -209,6 +209,7 @@ func editResolverCache(cfg *config.Config) error { func editShortcuts(cfg *config.Config) error { shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ") stripHyphens := cfg.Shortcuts.StripHyphens + rewrites := formatRewrites(cfg.Shortcuts.Rewrites) commandAlias := cfg.Shell.Alias err := huh.NewForm(huh.NewGroup( huh.NewInput(). @@ -220,6 +221,17 @@ func editShortcuts(cfg *config.Config) error { 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.NewText(). + Title("Rewrite rules"). + Description("One rule per line: REGEX => REPLACEMENT, applied after the domain suffix is removed.\n"+ + "Example: ^([a-z]+)[0-9]+- => $1 turns fsn1-bx1.example.com into fsnbx1.\n"+ + "Check rules with: netssh alias --test HOSTNAME"). + Lines(3). + Value(&rewrites). + Validate(func(s string) error { + _, err := parseRewrites(s) + return err + }), huh.NewInput(). Title("Command alias"). Description("Optional alias for netssh itself, e.g. j. Leave empty to disable."). @@ -238,6 +250,7 @@ func editShortcuts(cfg *config.Config) error { cfg.Shortcuts.Domains = append(cfg.Shortcuts.Domains, s) } cfg.Shortcuts.StripHyphens = stripHyphens + cfg.Shortcuts.Rewrites, _ = parseRewrites(rewrites) cfg.Shell.Alias = strings.TrimSpace(commandAlias) return nil } @@ -269,6 +282,30 @@ func editCompletion(cfg *config.Config) error { return nil } +// parseRewrites reads "REGEX => REPLACEMENT" lines; the replacement may be empty. +func parseRewrites(s string) ([]config.ShortcutRewrite, error) { + var out []config.ShortcutRewrite + for _, line := range parseLines(s) { + pattern, replace, ok := strings.Cut(line, "=>") + if !ok { + return nil, fmt.Errorf("%q: expected REGEX => REPLACEMENT", line) + } + out = append(out, config.ShortcutRewrite{Pattern: strings.TrimSpace(pattern), Replace: strings.TrimSpace(replace)}) + } + if err := (config.ShortcutsConfig{Rewrites: out}).Validate(); err != nil { + return nil, err + } + return out, nil +} + +func formatRewrites(rewrites []config.ShortcutRewrite) string { + lines := make([]string, 0, len(rewrites)) + for _, r := range rewrites { + lines = append(lines, r.Pattern+" => "+r.Replace) + } + return strings.Join(lines, "\n") +} + // splitList returns the non-empty, trimmed entries of a comma-separated list. func splitList(s string) []string { var out []string @@ -310,6 +347,9 @@ func save(cfg config.Config) error { if err := cfg.ValidateSSH(); err != nil { return err } + if err := cfg.Shortcuts.Validate(); 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) @@ -380,7 +420,7 @@ func save(cfg config.Config) error { fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults) } - if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens { + if cfg.Shortcuts.Enabled() { b.WriteString("\nshortcuts:\n") if len(cfg.Shortcuts.Domains) > 0 { b.WriteString(" domains:\n") @@ -391,6 +431,12 @@ func save(cfg config.Config) error { if cfg.Shortcuts.StripHyphens { b.WriteString(" strip_hyphens: true\n") } + if len(cfg.Shortcuts.Rewrites) > 0 { + b.WriteString(" rewrites:\n") + for _, r := range cfg.Shortcuts.Rewrites { + fmt.Fprintf(&b, " - pattern: %q\n replace: %q\n", r.Pattern, r.Replace) + } + } } if cfg.Shell.Alias != "" { diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index d2a3ead..c02778d 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -498,3 +498,47 @@ func TestSaveRejectsInvalidSSHOptions(t *testing.T) { t.Fatalf("config file written despite error: %v", err) } } + +func TestParseRewrites(t *testing.T) { + text := `^([a-z]+)[0-9]+- => $1 + +\.lab$ => +` + got, err := parseRewrites(text) + if err != nil { + t.Fatal(err) + } + want := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab$`, Replace: ""}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v", got) + } + if again, _ := parseRewrites(formatRewrites(got)); !reflect.DeepEqual(again, want) { + t.Fatalf("roundtrip: %+v", again) + } + for _, bad := range []string{"no arrow", "[a- => x", " => x"} { + if _, err := parseRewrites(bad); err == nil { + t.Errorf("%q accepted", bad) + } + } +} + +func TestSaveShortcutRewritesRoundtrip(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + rewrites := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab\.example\.com$`, Replace: `"q"`}} + cfg := config.Config{ + NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}}, + Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}}, + Shortcuts: config.ShortcutsConfig{Rewrites: rewrites}, + } + if err := save(cfg); err != nil { + t.Fatal(err) + } + loaded, err := config.Load() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(loaded.Shortcuts.Rewrites, rewrites) { + t.Fatalf("got %+v", loaded.Shortcuts.Rewrites) + } +} diff --git a/internal/shortcuts/shortcuts.go b/internal/shortcuts/shortcuts.go index bfae38c..f1d5ffb 100644 --- a/internal/shortcuts/shortcuts.go +++ b/internal/shortcuts/shortcuts.go @@ -1,13 +1,30 @@ package shortcuts import ( + "regexp" "strings" + "sync" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" ) -// Normalize strips configured domain suffixes and optionally hyphens from a hostname. -// The result is lowercased for case-insensitive comparison. +// compiled caches rewrite patterns: Normalize runs for every cache entry on each lookup. +var compiled sync.Map // pattern → *regexp.Regexp + +func rewritePattern(pattern string) *regexp.Regexp { + if re, ok := compiled.Load(pattern); ok { + return re.(*regexp.Regexp) + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil // rejected by config.Load already + } + compiled.Store(pattern, re) + return re +} + +// Normalize strips configured domain suffixes, applies rewrite rules in order, and +// optionally strips hyphens. The result is lowercased for case-insensitive comparison. func Normalize(name string, cfg config.ShortcutsConfig) string { s := strings.ToLower(name) for _, domain := range cfg.Domains { @@ -20,6 +37,11 @@ func Normalize(name string, cfg config.ShortcutsConfig) string { break } } + for _, rewrite := range cfg.Rewrites { + if re := rewritePattern(rewrite.Pattern); re != nil { + s = re.ReplaceAllString(s, rewrite.Replace) + } + } if cfg.StripHyphens { s = strings.ReplaceAll(s, "-", "") } @@ -34,7 +56,7 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string { } // AliasName returns a shell-safe alias name for the given host. -// It normalizes the name (strips configured domains and optionally hyphens) then +// It normalizes the name (strips configured domains, applies rewrites, optionally strips hyphens) then // replaces any remaining dots with underscores so the result is a valid identifier. func AliasName(name string, cfg config.ShortcutsConfig) string { s := Normalize(name, cfg) diff --git a/internal/shortcuts/shortcuts_test.go b/internal/shortcuts/shortcuts_test.go index 2ae5aa4..51e96c0 100644 --- a/internal/shortcuts/shortcuts_test.go +++ b/internal/shortcuts/shortcuts_test.go @@ -78,3 +78,40 @@ func TestAliasName_NoConfig(t *testing.T) { t.Errorf("AliasName = %q, want %q", got, want) } } + +func TestNormalizeRewrites(t *testing.T) { + cfg := config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}, + } + for input, want := range map[string]string{ + "fsn1-bx1.hoster.de": "fsnbx1", + "FSN1-BX1.hoster.de": "fsnbx1", + "fsn1-bx1": "fsnbx1", + "fsnbx1": "fsnbx1", // the shortcut itself is left alone + "web01.hoster.de": "web01", + } { + if got := shortcuts.Normalize(input, cfg); got != want { + t.Errorf("Normalize(%q) = %q, want %q", input, got, want) + } + } + if got := shortcuts.AliasName("fsn1-bx1.hoster.de", cfg); got != "fsnbx1" { + t.Errorf("AliasName = %q", got) + } +} + +func TestNormalizeRewritesOrder(t *testing.T) { + // Rules run in order after domain stripping, before hyphen stripping. + cfg := config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + StripHyphens: true, + Rewrites: []config.ShortcutRewrite{ + {Pattern: `\.hoster\.de$`, Replace: "-never"}, + {Pattern: `^fsn`, Replace: "f"}, + {Pattern: `^f1`, Replace: "falkenstein"}, + }, + } + if got := shortcuts.Normalize("fsn1-bx-1.hoster.de", cfg); got != "falkensteinbx1" { + t.Fatalf("got %q", got) + } +} -- 2.54.0