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 <noreply@anthropic.com>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-13 22:42:13 +01:00
1 parent d51c040d9f
commit 878701aba7
12 files changed
+408 -21

No files matched your search

+31 -2
View File
@@ -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)
+19
View File
@@ -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)
}
}
}
+11
View File
@@ -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)
}
}
+47 -1
View File
@@ -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 != "" {
+44
View File
@@ -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)
}
}
+25 -3
View File
@@ -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)
+37
View File
@@ -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)
}
}