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>
120 lines
3.8 KiB
Go
120 lines
3.8 KiB
Go
package main
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
|
)
|
|
|
|
func newNetBoxClient(cfg *config.Config) *netbox.Client {
|
|
if len(cfg.NetBoxes) > 0 {
|
|
sources := make([]netbox.Source, 0, len(cfg.NetBoxes))
|
|
for _, source := range cfg.NetBoxes {
|
|
sources = append(sources, netbox.Source{Name: source.Name, URL: source.URL, Token: source.Token, TokenVersion: source.TokenVersion})
|
|
}
|
|
return netbox.NewMultiClient(sources)
|
|
}
|
|
if cfg.NetBox.URL == "" {
|
|
return nil
|
|
}
|
|
return netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
|
}
|
|
|
|
func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) string {
|
|
return func(target string) string {
|
|
source, name := netbox.SplitTarget(target)
|
|
name = shortcuts.Normalize(name, cfg)
|
|
if qualified && source != "" {
|
|
return source + "/" + name
|
|
}
|
|
return name
|
|
}
|
|
}
|
|
|
|
// hostCompletions returns the cached hosts matching toComplete in a form that
|
|
// starts with the typed text, because bash and fish only accept such
|
|
// candidates. Without an instance prefix, a hostname that is unique across
|
|
// 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").
|
|
// 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)
|
|
for _, e := range entries {
|
|
_, name := netbox.SplitTarget(e.Name)
|
|
instances[strings.ToLower(name)]++
|
|
}
|
|
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 {
|
|
names = append(names, name)
|
|
} else {
|
|
names = append(names, e.Name)
|
|
}
|
|
case strings.HasPrefix(target, prefix):
|
|
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)
|
|
}
|
|
}
|
|
return out
|
|
}
|