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] 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) + } +}