Compare commits

..
Author SHA1 Message Date
Sebi a80049a7c3 fix(deps): update module golang.org/x/sys to v0.50.0 2026-11-09 12:45:57 +00:00
18 changed files with 197 additions and 1040 deletions

No files matched your search

-8
View File
@@ -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.
+4 -29
View File
@@ -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
View File
@@ -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
}
+2 -82
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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 (
+2 -2
View File
@@ -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=
+2 -31
View File
@@ -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)
-19
View File
@@ -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
View File
@@ -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:
-11
View File
@@ -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)
}
}
+2 -10
View File
@@ -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
-229
View File
@@ -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, " ") + "]"
}
-72
View File
@@ -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
View File
@@ -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 != "" {
-87
View File
@@ -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)
}
}
+3 -25
View File
@@ -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)
-37
View File
@@ -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)
}
}