Improve installer updates and dependency checks and add command aliases
Release / release (push) Successful in 7m57s

This commit is contained in:
Sebastian Unterschütz committed 2026-10-27 18:10:44 +01:00
1 parent 12cc64fdd9
commit 4b28d441b0
13 files changed
+973 -148

No files matched your search

+26 -1
View File
@@ -4,12 +4,14 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/spf13/viper"
)
type Config struct {
Shell ShellConfig `mapstructure:"shell"`
NetBox NetBoxConfig `mapstructure:"netbox"`
Resolver ResolverConfig `mapstructure:"resolver"`
Cache CacheConfig `mapstructure:"cache"`
@@ -17,6 +19,10 @@ type Config struct {
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
}
type ShellConfig struct {
Alias string `mapstructure:"alias"`
}
type NetBoxConfig struct {
URL string `mapstructure:"url"`
Token string `mapstructure:"token"`
@@ -71,7 +77,7 @@ func Load() (*Config, error) {
v.SetEnvPrefix("NETSSH")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens"} {
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias"} {
if err := v.BindEnv(key); err != nil {
return nil, err
}
@@ -88,6 +94,10 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("parsing config: %w", err)
}
if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil {
return nil, err
}
if cfg.NetBox.TokenVersion == 0 && cfg.NetBox.Token != "" {
if strings.HasPrefix(cfg.NetBox.Token, "nbt_") {
cfg.NetBox.TokenVersion = 2
@@ -106,3 +116,18 @@ func Load() (*Config, error) {
return &cfg, nil
}
// ValidateCommandAlias restricts aliases to portable shell identifiers.
func ValidateCommandAlias(name string) error {
if name == "" {
return nil
}
if !regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`).MatchString(name) {
return fmt.Errorf("shell.alias must be a shell identifier (e.g. j)")
}
switch name {
case "netssh", "alias", "unalias", "source", "eval", "exec", "command", "builtin", "if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case", "esac", "in", "function", "select", "time", "coproc", "end", "begin", "switch", "set", "export", "return", "break", "continue", "not", "and", "or":
return fmt.Errorf("shell.alias %q is reserved; choose another name", name)
}
return nil
}
+20
View File
@@ -157,3 +157,23 @@ func TestEnvironmentOverridesWithoutConfigKeys(t *testing.T) {
t.Fatalf("env ignored: %+v", cfg)
}
}
func TestCommandAliasEnvironmentAndValidation(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("NETSSH_SHELL_ALIAS", "j")
cfg, err := Load()
if err != nil || cfg.Shell.Alias != "j" {
t.Fatalf("alias env: %+v, %v", cfg, err)
}
for _, name := range []string{"netssh", "if", "alias", "j;touch /tmp/bad", "a b", "1bad"} {
t.Setenv("NETSSH_SHELL_ALIAS", name)
if _, err := Load(); err == nil {
t.Errorf("unsafe alias accepted: %q", name)
}
}
for _, name := range []string{"", "j", "jump_server", "goSSH"} {
if err := ValidateCommandAlias(name); err != nil {
t.Errorf("valid alias rejected: %s: %v", name, err)
}
}
}
+73 -17
View File
@@ -4,7 +4,14 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
)
// AliasEntry holds a single shell alias mapping.
@@ -15,9 +22,13 @@ type AliasEntry struct {
// AliasesPath returns the path for the bash/zsh aliases file.
func AliasesPath() string {
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = filepath.Join(os.Getenv("HOME"), ".cache")
cacheDir := os.Getenv("XDG_CACHE_HOME")
if cacheDir == "" {
var err error
cacheDir, err = os.UserCacheDir()
if err != nil {
cacheDir = filepath.Join(os.Getenv("HOME"), ".cache")
}
}
return filepath.Join(cacheDir, "netssh", "aliases.sh")
}
@@ -25,11 +36,7 @@ func AliasesPath() string {
// FishAliasesPath returns the path for the fish conf.d aliases file.
// Fish auto-sources every file in conf.d, so no explicit source line is needed.
func FishAliasesPath() string {
configDir, err := os.UserConfigDir()
if err != nil {
configDir = filepath.Join(os.Getenv("HOME"), ".config")
}
return filepath.Join(configDir, "fish", "conf.d", "netssh.fish")
return filepath.Join(FishConfigDir(), "conf.d", "netssh.fish")
}
// WriteAliasFiles writes alias definitions to disk for all supported shells.
@@ -54,18 +61,14 @@ func writeShAliasFile(entries []AliasEntry) error {
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
for _, e := range entries {
fmt.Fprintf(&b, "alias %s='netssh %s'\n", e.Name, e.Host)
b.WriteString(e.Definition("bash") + "\n")
}
return os.WriteFile(p, []byte(b.String()), 0o644)
return storage.WriteFile(p, []byte(b.String()), 0o600)
}
func writeFishAliasFile(entries []AliasEntry) {
configDir, err := os.UserConfigDir()
if err != nil {
return
}
// Only write if fish is configured on this system.
if _, err := os.Stat(filepath.Join(configDir, "fish")); err != nil {
if _, err := os.Stat(FishConfigDir()); err != nil {
return
}
p := FishAliasesPath()
@@ -76,7 +79,60 @@ func writeFishAliasFile(entries []AliasEntry) {
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
for _, e := range entries {
fmt.Fprintf(&b, "alias %s 'netssh %s'\n", e.Name, e.Host)
b.WriteString(e.Definition("fish") + "\n")
}
os.WriteFile(p, []byte(b.String()), 0o644) //nolint:errcheck
storage.WriteFile(p, []byte(b.String()), 0o600) //nolint:errcheck
}
// Empty Host represents an alias for the netssh command itself.
func (e AliasEntry) Definition(shell string) string {
command := "netssh"
if e.Host != "" {
host := e.Host
if !regexp.MustCompile(`^[a-zA-Z0-9_.:-]+$`).MatchString(host) {
host = shellQuote(host, shell)
}
command += " " + host
}
if shell == "fish" {
return fmt.Sprintf("alias %s %s", e.Name, shellQuote(command, shell))
}
return fmt.Sprintf("alias %s=%s", e.Name, shellQuote(command, shell))
}
func shellQuote(value, shell string) string {
if shell == "fish" {
value = strings.ReplaceAll(value, `\`, `\\`)
return "'" + strings.ReplaceAll(value, "'", `\'`) + "'"
}
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}
// BuildAliasEntries gives the configured command alias priority over host aliases.
func BuildAliasEntries(entries []cache.Entry, cfg config.Config) []AliasEntry {
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
seen := map[string]bool{"netssh": true}
var out []AliasEntry
if cfg.Shell.Alias != "" && config.ValidateCommandAlias(cfg.Shell.Alias) == nil {
seen[cfg.Shell.Alias] = true
out = append(out, AliasEntry{Name: cfg.Shell.Alias})
}
for _, e := range entries {
name := shortcuts.AliasName(e.Name, cfg.Shortcuts)
if seen[name] || name == "" || !regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]*$`).MatchString(name) {
continue
}
seen[name] = true
out = append(out, AliasEntry{Name: name, Host: e.Name})
}
return out
}
// FishConfigDir follows fish's XDG layout on both Linux and macOS.
func FishConfigDir() string {
dir := os.Getenv("XDG_CONFIG_HOME")
if dir == "" {
dir = filepath.Join(os.Getenv("HOME"), ".config")
}
return filepath.Join(dir, "fish")
}
+19
View File
@@ -6,6 +6,8 @@ import (
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
)
@@ -208,3 +210,20 @@ func TestUninstall_AlsoRemovesAliasesSourceLine(t *testing.T) {
t.Error("other content should be preserved")
}
}
func TestCommandAliasGenerationAndCollision(t *testing.T) {
cfg := config.Config{Shell: config.ShellConfig{Alias: "j"}}
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "j"}, {Name: "netssh"}, {Name: "web-01"}}, cfg)
if len(entries) != 2 {
t.Fatalf("entries: %+v", entries)
}
if got := entries[0].Definition("bash"); got != "alias j='netssh'" {
t.Fatal(got)
}
if got := entries[0].Definition("fish"); got != "alias j 'netssh'" {
t.Fatal(got)
}
if got := entries[1].Definition("bash"); got != "alias web-01='netssh web-01'" {
t.Fatal(got)
}
}
+66 -24
View File
@@ -4,12 +4,17 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
)
const (
// Marker is the unique string used to detect an existing hook line.
Marker = "netssh shell-init"
Marker = "netssh shell-init"
BlockStart = "# >>> netssh >>>"
BlockEnd = "# <<< netssh <<<"
// Line is the full line written into the shell profile.
Line = "netssh shell-init # netssh cache auto-refresh"
@@ -25,13 +30,13 @@ func ProfilePath(shell string) (string, error) {
case "bash":
return filepath.Join(os.Getenv("HOME"), ".bashrc"), nil
case "zsh":
return filepath.Join(os.Getenv("HOME"), ".zshrc"), nil
case "fish":
configDir, err := os.UserConfigDir()
if err != nil {
configDir = filepath.Join(os.Getenv("HOME"), ".config")
dir := os.Getenv("ZDOTDIR")
if dir == "" {
dir = os.Getenv("HOME")
}
return filepath.Join(configDir, "fish", "config.fish"), nil
return filepath.Join(dir, ".zshrc"), nil
case "fish":
return filepath.Join(FishConfigDir(), "config.fish"), nil
default:
return "", fmt.Errorf("unsupported shell %q — supported: bash, zsh, fish", shell)
}
@@ -49,7 +54,12 @@ func IsInstalled(profilePath string) bool {
if err != nil {
return false
}
return strings.Contains(string(data), Marker)
for _, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) == BlockStart || hookLinePattern.MatchString(strings.TrimSpace(line)) {
return true
}
}
return false
}
// Install appends the hook line to the profile if it is not already present.
@@ -81,23 +91,27 @@ func InstallAliasesSource(profilePath string) (installed bool, err error) {
if err != nil && !os.IsNotExist(err) {
return false, fmt.Errorf("reading profile: %w", err)
}
if strings.Contains(string(data), AliasesMarker) {
return false, nil
for _, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) == BlockStart || aliasesLinePattern.MatchString(strings.TrimSpace(line)) {
return false, nil
}
}
f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return false, fmt.Errorf("opening profile: %w", err)
}
defer f.Close()
if _, err := fmt.Fprintf(f, "%s\n", AliasesSourceLine); err != nil {
if _, err := fmt.Fprintf(f, "%s\n", "source "+shellQuote(AliasesPath(), "bash")+" 2>/dev/null # netssh aliases"); err != nil {
return false, err
}
return true, nil
}
// Uninstall removes the hook line and the aliases source line from the profile.
// Returns (true, nil) when something was removed, (false, nil) when nothing was found.
// Returns (false, nil) — not an error — when the file does not exist.
var hookLinePattern = regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+shell-init\s*(?:#.*)?$`)
var aliasesLinePattern = regexp.MustCompile(`^(?:source|\.)\s+["']?[^;|&]*netssh/aliases\.sh["']?(?:\s+2>/dev/null)?\s*(?:#.*)?$`)
// Uninstall removes legacy hook lines and complete installer-managed blocks.
// Missing profiles are a no-op; malformed blocks leave the file unchanged.
func Uninstall(profilePath string) (removed bool, err error) {
data, err := os.ReadFile(profilePath)
if os.IsNotExist(err) {
@@ -106,22 +120,50 @@ func Uninstall(profilePath string) (removed bool, err error) {
if err != nil {
return false, fmt.Errorf("reading profile: %w", err)
}
content := string(data)
if !strings.Contains(content, Marker) && !strings.Contains(content, AliasesMarker) {
return false, nil
}
var kept []string
for _, line := range strings.Split(content, "\n") {
if strings.Contains(line, Marker) || strings.Contains(line, AliasesMarker) {
inside := false
for _, line := range strings.Split(string(data), "\n") {
value := strings.TrimSpace(line)
switch value {
case BlockStart:
if inside {
return false, fmt.Errorf("nested netssh block in %s", profilePath)
}
inside = true
removed = true
continue
case BlockEnd:
if !inside {
return false, fmt.Errorf("unmatched netssh block in %s", profilePath)
}
inside = false
continue
}
if inside {
continue
}
if hookLinePattern.MatchString(value) || aliasesLinePattern.MatchString(value) {
removed = true
continue
}
kept = append(kept, line)
}
// Collapse triple blank lines that the removal may leave.
if inside {
return false, fmt.Errorf("unclosed netssh block in %s", profilePath)
}
if !removed {
return false, nil
}
cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
if err := os.WriteFile(profilePath, []byte(cleaned), 0o644); err != nil {
target, err := filepath.EvalSymlinks(profilePath)
if err != nil {
return false, err
}
info, err := os.Stat(target)
if err != nil {
return false, err
}
if err := storage.WriteFile(target, []byte(cleaned), info.Mode().Perm()); err != nil {
return false, fmt.Errorf("writing profile: %w", err)
}
return true, nil
+27
View File
@@ -261,3 +261,30 @@ func writeProfile(t *testing.T, content string) string {
}
return f
}
func TestManagedBlockInstallAndUninstall(t *testing.T) {
original := "export KEEP=yes\n# netssh shell-init documentation\necho 'netssh shell-init'\n"
profile := writeProfile(t, original+hook.BlockStart+"\nexport PATH='/custom/bin':\"$PATH\"\n'/custom/bin/netssh' shell-init\n[ ! -f '/custom/cache/netssh/aliases.sh' ] || . '/custom/cache/netssh/aliases.sh'\n"+hook.BlockEnd+"\n")
if installed, err := hook.Install(profile); err != nil || installed {
t.Fatalf("duplicate install: %v, %v", installed, err)
}
if installed, err := hook.InstallAliasesSource(profile); err != nil || installed {
t.Fatalf("duplicate source: %v, %v", installed, err)
}
if removed, err := hook.Uninstall(profile); err != nil || !removed {
t.Fatalf("uninstall: %v, %v", removed, err)
}
data, _ := os.ReadFile(profile)
if string(data) != original {
t.Fatalf("unrelated content changed: %s", data)
}
}
func TestProfilePathRespectsZdotdir(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZDOTDIR", dir)
path, err := hook.ProfilePath("zsh")
if err != nil || path != filepath.Join(dir, ".zshrc") {
t.Fatalf("zsh path: %s, %v", path, err)
}
}
+56 -3
View File
@@ -10,8 +10,11 @@ import (
"github.com/charmbracelet/huh"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
)
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
@@ -30,6 +33,7 @@ func RunWizard(cfg *config.Config) error {
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"
@@ -122,6 +126,12 @@ func RunWizard(cfg *config.Config) error {
Title("Strip hyphens").
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
Value(&stripHyphens),
huh.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"),
)
@@ -165,6 +175,7 @@ func RunWizard(cfg *config.Config) error {
}
out := config.Config{
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
NetBox: config.NetBoxConfig{
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
Token: strings.TrimSpace(token),
@@ -180,7 +191,8 @@ func RunWizard(cfg *config.Config) error {
InterfaceName: strings.TrimSpace(interfaceName),
},
Cache: config.CacheConfig{
TTL: ttl,
TTL: ttl,
Path: cfg.Cache.Path,
},
Shortcuts: config.ShortcutsConfig{
Domains: domainList,
@@ -188,7 +200,38 @@ func RunWizard(cfg *config.Config) error {
},
}
return save(out)
if err := save(out); err != nil {
return err
}
loaded, err := config.Load()
if err != nil {
return err
}
c := cache.New(loaded.Cache.Path, loaded.Cache.TTL)
if err := c.Load(); err != nil {
return fmt.Errorf("updating aliases: %w", err)
}
if err := hook.WriteAliasFiles(hook.BuildAliasEntries(c.All(), *loaded)); err != nil {
return err
}
if loaded.Shell.Alias != "" {
shell := filepath.Base(os.Getenv("SHELL"))
profile, err := hook.ProfilePath(shell)
if err != nil {
fmt.Fprintf(os.Stderr, "Run 'netssh hook install --shell bash|zsh|fish' to activate your alias.\n")
return nil
}
if _, err := hook.Install(profile); err != nil {
return err
}
if shell != "fish" {
if _, err := hook.InstallAliasesSource(profile); err != nil {
return err
}
}
fmt.Fprintln(os.Stderr, hook.ReloadNote(profile))
}
return nil
}
var knownStrategies = map[string]bool{
@@ -220,6 +263,9 @@ func parseStrategies(s string) []string {
}
func save(cfg config.Config) error {
if err := config.ValidateCommandAlias(cfg.Shell.Alias); 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)
@@ -248,6 +294,9 @@ func save(cfg config.Config) error {
b.WriteString("\ncache:\n")
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL)
if cfg.Cache.Path != "" {
fmt.Fprintf(&b, " path: %q\n", cfg.Cache.Path)
}
b.WriteString("\nssh:\n")
if cfg.SSH.DefaultUser != "" {
@@ -270,7 +319,11 @@ func save(cfg config.Config) error {
}
}
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
if cfg.Shell.Alias != "" {
fmt.Fprintf(&b, "\nshell:\n alias: %q\n", cfg.Shell.Alias)
}
if err := storage.WriteFile(path, []byte(b.String()), 0o600); err != nil {
return fmt.Errorf("writing config: %w", err)
}
+20
View File
@@ -366,3 +366,23 @@ func TestSave_RoundtripViaLoad(t *testing.T) {
t.Errorf("TTL: got %d, want %d", loaded.Cache.TTL, original.Cache.TTL)
}
}
func TestSaveCommandAliasRoundtripAndDisable(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := config.Config{Shell: config.ShellConfig{Alias: "j"}, Cache: config.CacheConfig{Path: filepath.Join(t.TempDir(), "hosts.json")}}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil || loaded.Shell.Alias != "j" || loaded.Cache.Path != cfg.Cache.Path {
t.Fatalf("roundtrip: %+v, %v", loaded, err)
}
cfg.Shell.Alias = ""
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err = config.Load()
if err != nil || loaded.Shell.Alias != "" {
t.Fatalf("disable: %+v, %v", loaded, err)
}
}