Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a80049a7c3 |
No files matched your search
@@ -1,13 +1,5 @@
|
||||
# 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.
|
||||
- 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
|
||||
|
||||
- Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1<Tab>`) 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.
|
||||
|
||||
@@ -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, hyphen folding, and regex rewrite rules (`fsn1-bx1.hoster.de` → `fsnbx1`)
|
||||
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding
|
||||
- **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`
|
||||
@@ -106,19 +106,8 @@ Re-run it at any time to change settings without editing the file manually:
|
||||
netssh configure
|
||||
```
|
||||
|
||||
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, 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.
|
||||
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`.
|
||||
|
||||
### Manual config
|
||||
|
||||
@@ -366,26 +355,13 @@ 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. **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<Tab>` → `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
|
||||
```
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -393,7 +369,6 @@ 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
|
||||
|
||||
|
||||
+6
-56
@@ -41,9 +41,7 @@ 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").
|
||||
// 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 {
|
||||
func hostCompletions(entries []cache.Entry, toComplete string) []string {
|
||||
prefix := strings.ToLower(toComplete)
|
||||
qualifiedInput := strings.Contains(prefix, "/")
|
||||
instances := make(map[string]int)
|
||||
@@ -51,69 +49,21 @@ func hostCompletions(entries []cache.Entry, toComplete string, sc config.Shortcu
|
||||
_, name := netbox.SplitTarget(e.Name)
|
||||
instances[strings.ToLower(name)]++
|
||||
}
|
||||
var unmatched []cache.Entry
|
||||
var names []string
|
||||
var out []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)
|
||||
out = append(out, name)
|
||||
} else {
|
||||
names = append(names, e.Name)
|
||||
out = append(out, 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)
|
||||
out = append(out, e.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -228,92 +228,12 @@ 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, config.ShortcutsConfig{}); !reflect.DeepEqual(got, tc.want) {
|
||||
if got := hostCompletions(entries, tc.input); !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", config.ShortcutsConfig{}); !reflect.DeepEqual(got, []string{"web01"}) {
|
||||
if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w"); !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)
|
||||
}
|
||||
}
|
||||
+4
-37
@@ -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 := cfg.Shortcuts.Enabled()
|
||||
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
|
||||
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, cfg.Shortcuts)
|
||||
hosts := hostCompletions(c.All(), toComplete)
|
||||
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
||||
hosts = hosts[:limit]
|
||||
}
|
||||
@@ -645,15 +645,13 @@ func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
|
||||
}
|
||||
|
||||
func aliasCmd() *cobra.Command {
|
||||
var shell, test string
|
||||
var shell 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, rewrite rules applied, hyphens optionally stripped).
|
||||
|
||||
Check a rule with: netssh alias --test fsn1-bx1.example.com
|
||||
(domain suffixes stripped, hyphens optionally stripped).
|
||||
|
||||
Source the output in your shell profile:
|
||||
bash/zsh: eval "$(netssh alias)"
|
||||
@@ -670,10 +668,6 @@ 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"))
|
||||
}
|
||||
@@ -700,36 +694,9 @@ 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 {
|
||||
|
||||
@@ -9,7 +9,7 @@ require (
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/sys v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -102,8 +102,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
|
||||
golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -57,34 +57,8 @@ type CompletionConfig struct {
|
||||
}
|
||||
|
||||
type ShortcutsConfig struct {
|
||||
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
|
||||
Domains []string `mapstructure:"domains"`
|
||||
StripHyphens bool `mapstructure:"strip_hyphens"`
|
||||
}
|
||||
|
||||
// Path returns the canonical config file path.
|
||||
@@ -154,9 +128,6 @@ 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)
|
||||
|
||||
@@ -3,7 +3,6 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -178,21 +177,3 @@ 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
-25
@@ -50,46 +50,32 @@ 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.Validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil {
|
||||
if err := rule.SSHOptions.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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (o SSHOptions) validate(scope string) error {
|
||||
switch o.HostKeyChecking {
|
||||
case "", "strict", "accept-new", "off":
|
||||
default:
|
||||
|
||||
@@ -242,14 +242,3 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ 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"))
|
||||
@@ -54,20 +53,13 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
|
||||
if source.Name != name {
|
||||
continue
|
||||
}
|
||||
switch verb {
|
||||
case "edit":
|
||||
if verb == "edit" {
|
||||
edited, err := editInstance(source, sources, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources[i] = edited
|
||||
case "ssh":
|
||||
options, err := editSSHOptions("SSH options for "+name, source.SSH)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources[i].SSH = options
|
||||
default:
|
||||
} else {
|
||||
remove := false
|
||||
if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
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, " ") + "]"
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+160
-279
@@ -17,19 +17,163 @@ import (
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
// RunWizard runs the interactive configurator, pre-filled with any existing cfg values.
|
||||
// RunWizard runs the interactive setup form, 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 {
|
||||
return cancelled(err)
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Fprintln(os.Stderr, "Setup cancelled.")
|
||||
os.Exit(0)
|
||||
}
|
||||
return err
|
||||
}
|
||||
work := prepare(*cfg, sources)
|
||||
if err := runMenu(&work); err != nil {
|
||||
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"
|
||||
}
|
||||
|
||||
if err := save(work); err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
@@ -58,265 +202,6 @@ 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
|
||||
rewrites := formatRewrites(cfg.Shortcuts.Rewrites)
|
||||
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.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.").
|
||||
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.Shortcuts.Rewrites, _ = parseRewrites(rewrites)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
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,
|
||||
@@ -335,7 +220,15 @@ func validateStrategies(s string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseStrategies(s string) []string { return splitList(s) }
|
||||
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 save(cfg config.Config) error {
|
||||
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||
@@ -344,12 +237,6 @@ func save(cfg config.Config) error {
|
||||
if err := cfg.ValidateNetBoxes(); err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
@@ -420,7 +307,7 @@ func save(cfg config.Config) error {
|
||||
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
|
||||
}
|
||||
|
||||
if cfg.Shortcuts.Enabled() {
|
||||
if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
|
||||
b.WriteString("\nshortcuts:\n")
|
||||
if len(cfg.Shortcuts.Domains) > 0 {
|
||||
b.WriteString(" domains:\n")
|
||||
@@ -431,12 +318,6 @@ 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 != "" {
|
||||
|
||||
@@ -455,90 +455,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,13 @@
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// Normalize strips configured domain suffixes and optionally hyphens from a hostname.
|
||||
// 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 {
|
||||
@@ -37,11 +20,6 @@ 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, "-", "")
|
||||
}
|
||||
@@ -56,7 +34,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, applies rewrites, optionally strips hyphens) then
|
||||
// It normalizes the name (strips configured domains and optionally 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)
|
||||
|
||||
@@ -78,40 +78,3 @@ 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user