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