From 4b28d441b05ac23becf934c6e5f3da51337143c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Tue, 27 Oct 2026 18:10:44 +0100 Subject: [PATCH] Improve installer updates and dependency checks and add command aliases --- README.md | 56 +++++- cmd/netssh/main.go | 31 +-- cmd/netssh/notes_test.go | 35 ++++ install.sh | 296 ++++++++++++++++++++------- installer_test.go | 351 +++++++++++++++++++++++++++++++++ internal/config/config.go | 27 ++- internal/config/config_test.go | 20 ++ internal/hook/aliases.go | 90 +++++++-- internal/hook/aliases_test.go | 19 ++ internal/hook/hook.go | 90 ++++++--- internal/hook/hook_test.go | 27 +++ internal/setup/wizard.go | 59 +++++- internal/setup/wizard_test.go | 20 ++ 13 files changed, 973 insertions(+), 148 deletions(-) create mode 100644 installer_test.go diff --git a/README.md b/README.md index 4bbe74d..cb55b39 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,34 @@ The script detects your OS and architecture, downloads the matching binary from To install to a custom directory: ```sh -INSTALL_DIR=~/.local/bin curl -fsSL https://git.zb-server.de/Sebi/ssh-netbox-wrapper/raw/branch/main/install.sh | bash +curl -fsSL https://git.zb-server.de/Sebi/ssh-netbox-wrapper/raw/branch/main/install.sh | INSTALL_DIR="$HOME/.local/bin" bash ``` +### Updating an existing installation + +Run the same one-liner again to update to the latest release. The installer uses +an existing `netssh` found in `PATH`, or `/usr/local/bin/netssh` for a new installation. +`INSTALL_DIR` explicitly overrides the destination. The replacement is atomic, +so an installed binary can be updated while it is running. + +The installer checks all required programs before downloading or modifying files: +`curl`, `uname`, `sed`, `awk`, `mktemp`, `rm`, `dirname`, `readlink`, `mkdir`, `cat`, +`install`, `mv`, `cp`, `chmod`, `cmp`, and either `sha256sum` (Linux) or `shasum` +(macOS). Bash runs the installer; `sudo` is checked beforehand only if the target +directory requires elevated permissions. Missing programs are listed together; +install them with your package manager and retry. **Python is not required. SHA-256 verification is mandatory:** missing, +duplicate or mismatched checksums abort the update before replacing anything. + +It also consolidates netssh integration in existing Bash, Zsh and Fish profiles +and creates the current shell's rc file when needed. Old standalone netssh hooks, +inline host aliases and `eval "$(netssh alias)"` entries are replaced by a single +managed block per rc file. Legacy entries in login profiles are removed as well. +Other shell settings, NetBox configuration, cache and local notes are preserved. +Every changed existing profile gets a `.netssh-backup.XXXXXX` backup; symlinks +and permissions are preserved. `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and `ZDOTDIR` +are respected. Alias files and installed shell completions are regenerated. +Open a **new terminal** after updating to load the new aliases and hooks. + ### Build from source ```sh @@ -68,7 +93,7 @@ Re-run it at any time to change settings without editing the file manually: netssh configure ``` -The wizard walks through NetBox connection, SSH defaults, resolver strategies, and cache TTL, +The wizard walks through NetBox connection, SSH defaults, resolver strategies, cache TTL, and an optional command alias, then saves to `~/.config/netssh.yaml`. ### Manual config @@ -109,6 +134,31 @@ shortcuts: Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.). +### Short command alias + +In `netssh configure`, set **Command alias** to `j` to use `j` anywhere you would +use `netssh`: + +```sh +j # interactive server search +j server01 # connect to a server +j notes show server01 +``` + +The setting is saved as: + +```yaml +shell: + alias: j +``` + +The wizard regenerates shell aliases and installs the current shell's hook when +an alias is set. Open a new terminal (or follow the displayed reload command). +Changing the setting replaces the old generated alias; leaving it empty disables +it for new shells. The command alias takes priority over a host alias with the +same name and works even when no servers are cached. Environment override: +`NETSSH_SHELL_ALIAS=j`. + ### API tokens NetBox supports two token formats: @@ -457,7 +507,7 @@ Both are non-blocking — the refresh runs as a background process and never del ## Development ```sh -go test ./... # run all tests +go test ./... # includes offline installer/update tests go build ./... # build all packages ``` diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index 9ba813c..1b3ada5 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -366,8 +366,7 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command { } note = "Make sure ~/.zfunc is in your fpath:\n fpath=(~/.zfunc $fpath)\n autoload -Uz compinit && compinit" case "fish": - configDir, _ := os.UserConfigDir() - dir = filepath.Join(configDir, "fish", "completions") + dir = filepath.Join(hook.FishConfigDir(), "completions") file = filepath.Join(dir, "netssh.fish") gen = func() ([]byte, error) { var buf strings.Builder @@ -570,23 +569,7 @@ func cacheRefreshCmd() *cobra.Command { // buildAliasEntries computes the sorted, deduplicated alias list from the cache. func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry { - all := c.All() - sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name }) - - seen := make(map[string]string) - var out []hook.AliasEntry - for _, e := range all { - name := shortcuts.AliasName(e.Name, cfg.Shortcuts) - if name == "" { - continue - } - if _, dup := seen[name]; dup { - continue - } - seen[name] = e.Name - out = append(out, hook.AliasEntry{Name: name, Host: e.Name}) - } - return out + return hook.BuildAliasEntries(c.All(), cfg) } func aliasCmd() *cobra.Command { @@ -627,12 +610,12 @@ Or use in a script: case "fish": fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(entries)) for _, e := range entries { - fmt.Printf("alias %s 'netssh %s'\n", e.Name, e.Host) + fmt.Println(e.Definition("fish")) } default: fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries)) for _, e := range entries { - fmt.Printf("alias %s='netssh %s'\n", e.Name, e.Host) + fmt.Println(e.Definition("bash")) } } return nil @@ -651,7 +634,7 @@ func shellInitCmd() *cobra.Command { Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() - if err != nil || cfg.NetBox.URL == "" { + if err != nil { return nil } c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) @@ -659,9 +642,7 @@ func shellInitCmd() *cobra.Command { // Regenerate alias files from the current cache every shell start // so aliases are always up-to-date even before a refresh completes. - if entries := buildAliasEntries(c, *cfg); len(entries) > 0 { - _ = hook.WriteAliasFiles(entries) - } + _ = hook.WriteAliasFiles(buildAliasEntries(c, *cfg)) maybeBackgroundRefresh(cfg, c) return nil diff --git a/cmd/netssh/notes_test.go b/cmd/netssh/notes_test.go index 1270f9f..f6cecfb 100644 --- a/cmd/netssh/notes_test.go +++ b/cmd/netssh/notes_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" + "os" "path/filepath" "strings" "testing" @@ -38,3 +40,36 @@ func TestLocalNotesCLI(t *testing.T) { t.Fatalf("clear: %q, %v", out, err) } } + +func TestShellInitUpdatesAndRemovesCommandAliasWithoutHosts(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json")) + t.Setenv("NETSSH_NETBOX_URL", "") + t.Setenv("NETSSH_SHELL_ALIAS", "j") + run := func() { + t.Helper() + root := rootCmd() + root.SetArgs([]string{"shell-init"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + } + run() + data, err := os.ReadFile(hook.AliasesPath()) + if err != nil || !strings.Contains(string(data), "alias j='netssh'") { + t.Fatalf("alias missing: %s, %v", data, err) + } + t.Setenv("NETSSH_SHELL_ALIAS", "k") + run() + data, _ = os.ReadFile(hook.AliasesPath()) + if strings.Contains(string(data), "alias j=") || !strings.Contains(string(data), "alias k='netssh'") { + t.Fatalf("alias change: %s", data) + } + t.Setenv("NETSSH_SHELL_ALIAS", "") + run() + data, _ = os.ReadFile(hook.AliasesPath()) + if strings.Contains(string(data), "alias k=") { + t.Fatalf("stale alias: %s", data) + } +} diff --git a/install.sh b/install.sh index 9528aa5..1380a9e 100755 --- a/install.sh +++ b/install.sh @@ -1,94 +1,240 @@ #!/usr/bin/env bash -set -euo pipefail +set -eo pipefail +# Bash 3 (macOS) treats empty arrays as unset under nounset. +: "${HOME:?HOME must be set}" REPO="Sebi/ssh-netbox-wrapper" BASE_URL="https://git.zb-server.de" BINARY="netssh" -INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" +info() { printf ' %s\n' "$*"; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not installed"; } +# Check the complete toolchain before any network request or filesystem change. +MISSING=() +for program in curl uname sed awk mktemp rm dirname readlink mkdir cat install mv cp chmod cmp; do + if ! command -v "$program" >/dev/null 2>&1; then MISSING+=("$program"); fi +done +if command -v sha256sum >/dev/null 2>&1; then + SHA256=(sha256sum) +elif command -v shasum >/dev/null 2>&1; then + SHA256=(shasum -a 256) +else + MISSING+=("sha256sum or shasum (SHA-256 verification)") +fi +if [[ ${#MISSING[@]} -gt 0 ]]; then + printf 'error: required programs are missing:\n' >&2 + printf ' - %s\n' "${MISSING[@]}" >&2 + die "install these dependencies using your package manager, then run the installer again" +fi -# --- helpers ----------------------------------------------------------------- - -red() { printf '\033[31m%s\033[0m\n' "$*"; } -green() { printf '\033[32m%s\033[0m\n' "$*"; } -bold() { printf '\033[1m%s\033[0m\n' "$*"; } -info() { printf ' %s\n' "$*"; } - -die() { red "error: $*" >&2; exit 1; } - -need() { - command -v "$1" &>/dev/null || die "'$1' is required but not installed" -} - -# --- detect OS / arch -------------------------------------------------------- - -detect_os() { - case "$(uname -s)" in - Linux) echo linux ;; - Darwin) echo darwin ;; - *) die "unsupported OS: $(uname -s)" ;; - esac -} - -detect_arch() { - case "$(uname -m)" in - x86_64|amd64) echo amd64 ;; - aarch64|arm64) echo arm64 ;; - *) die "unsupported architecture: $(uname -m)" ;; - esac -} - -# --- main -------------------------------------------------------------------- - -need curl - -OS=$(detect_os) -ARCH=$(detect_arch) - -bold "netssh installer" -info "platform : $OS/$ARCH" +# Re-running the one-liner updates the existing installation in its current path. +if [[ -z "${INSTALL_DIR:-}" ]]; then + EXISTING=$(command -v "$BINARY" || true) + if [[ "$EXISTING" == /* && -f "$EXISTING" ]]; then + INSTALL_DIR=$(dirname "$EXISTING") + else + INSTALL_DIR=/usr/local/bin + fi +fi +[[ "$INSTALL_DIR" == /* ]] || INSTALL_DIR="$PWD/$INSTALL_DIR" +# Check sudo only when the destination needs elevated permissions, also before +# downloading. For a new directory, inspect its nearest existing parent. +PRIVILEGE=() +INSTALL_PARENT=$INSTALL_DIR +while [[ ! -e "$INSTALL_PARENT" ]]; do INSTALL_PARENT=$(dirname "$INSTALL_PARENT"); done +[[ -d "$INSTALL_PARENT" ]] || die "installation path is not a directory: $INSTALL_PARENT" +if [[ ! -w "$INSTALL_PARENT" || ! -x "$INSTALL_PARENT" ]]; then + need sudo + PRIVILEGE=(sudo) +fi +case "$(uname -s)" in + Linux) PLATFORM_OS=linux ;; + Darwin) PLATFORM_OS=darwin ;; + *) die "unsupported OS: $(uname -s)" ;; +esac +case "$(uname -m)" in + x86_64|amd64) PLATFORM_ARCH=amd64 ;; + aarch64|arm64) PLATFORM_ARCH=arm64 ;; + *) die "unsupported architecture: $(uname -m)" ;; +esac +printf 'netssh install / update\n' +info "platform : $PLATFORM_OS/$PLATFORM_ARCH" info "target : $INSTALL_DIR/$BINARY" -# Fetch the latest release tag from the Gitea API. API_URL="$BASE_URL/api/v1/repos/$REPO/releases/latest" -TAG=$(curl -sf "$API_URL" | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])") \ - || die "could not fetch latest release from $API_URL" - -ASSET="${BINARY}_${OS}_${ARCH}" -DOWNLOAD_URL="$BASE_URL/$REPO/releases/download/$TAG/$ASSET" -CHECKSUM_URL="$BASE_URL/$REPO/releases/download/$TAG/checksums.txt" - +RELEASE=$(curl -fsSL "$API_URL") || die "could not fetch latest release from $API_URL" +TAG=$(printf '%s\n' "$RELEASE" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.+-][a-zA-Z0-9.+-]+)?$ ]] || die "invalid or missing release tag" +ASSET="${BINARY}_${PLATFORM_OS}_${PLATFORM_ARCH}" +DOWNLOAD_URL="$BASE_URL/$REPO/releases/download/$TAG" info "version : $TAG" -echo -# Download binary and checksums into a temp directory. TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT - +STAGED="" +cleanup() { + rm -rf "$TMP" + if [[ -n "$STAGED" ]]; then "${PRIVILEGE[@]}" rm -f "$STAGED"; fi +} +trap cleanup EXIT info "downloading $ASSET..." -curl -fL --progress-bar -o "$TMP/$ASSET" "$DOWNLOAD_URL" +curl -fL --progress-bar -o "$TMP/$ASSET" "$DOWNLOAD_URL/$ASSET" +curl -fsSL -o "$TMP/checksums.txt" "$DOWNLOAD_URL/checksums.txt" \ + || die "could not fetch checksums; installation aborted" +EXPECTED=$(awk -v asset="$ASSET" '$2 == asset || $2 == "*" asset {print tolower($1)}' "$TMP/checksums.txt") +[[ "$EXPECTED" =~ ^[a-f0-9]{64}$ ]] || die "missing, duplicate, or invalid checksum; installation aborted" +ACTUAL=$("${SHA256[@]}" "$TMP/$ASSET") +ACTUAL=${ACTUAL%% *} +[[ "$ACTUAL" == "$EXPECTED" ]] || die "checksum mismatch; installation aborted" +info "checksum OK" -info "verifying checksum..." -curl -sf -o "$TMP/checksums.txt" "$CHECKSUM_URL" \ - || { info "warning: could not fetch checksums, skipping verification"; } +# Only standalone commands and our managed blocks are removed. Unrelated content +# and commands which merely mention netssh are retained. +cat > "$TMP/clean-profile.awk" <<'AWK' +function legacy(s) { + if (s ~ /^([^ \t]*\/)?netssh[ \t]+shell-init[ \t]*(#.*)?$/) return 1 + if (s ~ /^(source|\.)[ \t]+["']?[^;|&]*netssh\/aliases\.sh["']?([ \t]+2>\/dev\/null)?[ \t]*(#.*)?$/) return 1 + if (s ~ /^eval[ \t]+["']?\$\(([^ \t]*\/)?netssh[ \t]+alias([ \t]+--shell[ \t]+(bash|zsh))?\)["']?[ \t]*(#.*)?$/) return 1 + if (s ~ /^([^ \t]*\/)?netssh[ \t]+alias([ \t]+--shell[ \t]+fish)?[ \t]*\|[ \t]*source[ \t]*(#.*)?$/) return 1 + if (s ~ /^source[ \t]+<\(([^ \t]*\/)?netssh[ \t]+alias\)[ \t]*(#.*)?$/) return 1 + if (s ~ /^alias[ \t]+[A-Za-z0-9_.-]+(=|[ \t]+)["']netssh([ \t]+[^"']+)?["'][ \t]*(#.*)?$/) return 1 + return 0 +} +{ + s = $0 + sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s) + if (s == "# >>> netssh >>>") { if (inside) bad = 1; inside = 1; next } + if (s == "# <<< netssh <<<") { if (!inside) bad = 1; inside = 0; next } + if (!inside && !legacy(s)) print $0 +} +END { if (inside || bad) exit 1 } +AWK -if [[ -f "$TMP/checksums.txt" ]]; then - # checksums.txt was built with `sha256sum` in the dist/ dir, so entries look - # like "abc123 netssh_linux_amd64". We need to cd into the temp dir first. - (cd "$TMP" && grep "$ASSET" checksums.txt | sha256sum --check --status) \ - || die "checksum mismatch — aborting installation" - info "checksum OK" -fi +# POSIX-style single quoting also works for these paths in fish, except embedded +# quotes, which fish escapes with a backslash instead of concatenated quotes. +quote_sh() { printf "'%s'" "${1//\'/\'\\\'\'}"; } +quote_fish() { + local value=${1//\\/\\\\} + value=${value//\'/\\\'} + printf "'%s'" "$value" +} +resolve_profile() { + local path=$1 target steps=0 + while [[ -L "$path" ]]; do + steps=$((steps + 1)) + [[ "$steps" -le 40 ]] || die "symlink loop in $1" + target=$(readlink "$path") + if [[ "$target" == /* ]]; then path=$target; else path="$(dirname "$path")/$target"; fi + done + printf '%s\n' "$path" +} -chmod +x "$TMP/$ASSET" - -# Install — try without sudo first, fall back if needed. -if [[ -w "$INSTALL_DIR" ]]; then - mv "$TMP/$ASSET" "$INSTALL_DIR/$BINARY" +PROFILE_PATHS=() +PROFILE_TARGETS=() +PROFILE_DATA=() +SHELLS=() +CURRENT_SHELL=${SHELL:-} +CURRENT_SHELL=${CURRENT_SHELL##*/} +CONFIG_DIR=${XDG_CONFIG_HOME:-$HOME/.config} +if [[ "$PLATFORM_OS" == darwin ]]; then + CACHE_DIR=${XDG_CACHE_HOME:-$HOME/Library/Caches} else - info "sudo required to write to $INSTALL_DIR" - sudo mv "$TMP/$ASSET" "$INSTALL_DIR/$BINARY" + CACHE_DIR=${XDG_CACHE_HOME:-$HOME/.cache} fi +ZSH_DIR=${ZDOTDIR:-$HOME} +plan_profile() { + local path=$1 kind=$2 target data previous count + if [[ ! -e "$path" && ! -L "$path" ]] && { [[ -z "$kind" ]] || [[ "$kind" != "$CURRENT_SHELL" ]]; }; then return; fi + target=$(resolve_profile "$path") + # A dotfile manager may point two profile names at the same file. + for previous in "${PROFILE_TARGETS[@]}"; do [[ "$previous" != "$target" ]] || return 0; done + data="$TMP/profile-${#PROFILE_PATHS[@]}" + if [[ -e "$target" ]]; then + awk -f "$TMP/clean-profile.awk" "$target" > "$data" \ + || die "unclosed or nested netssh block in $path; profiles were not changed" + else + : > "$data" + fi + if [[ -n "$kind" ]]; then + count=0 + for previous in "${SHELLS[@]}"; do [[ "$previous" != "$kind" ]] || count=1; done + if [[ "$count" == 0 ]]; then SHELLS+=("$kind"); fi + { + printf '# >>> netssh >>>\n' + if [[ "$kind" == fish ]]; then + printf 'set -gx PATH %s $PATH\n' "$(quote_fish "$INSTALL_DIR")" + printf '%s shell-init\n' "$(quote_fish "$INSTALL_DIR/$BINARY")" + else + printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$INSTALL_DIR")" + printf '%s shell-init\n' "$(quote_sh "$INSTALL_DIR/$BINARY")" + printf '[ ! -f %s ] || . %s\n' "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")" "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")" + fi + printf '# <<< netssh <<<\n' + } >> "$data" + fi + PROFILE_PATHS+=("$path"); PROFILE_TARGETS+=("$target"); PROFILE_DATA+=("$data") +} +# Preflight every profile before installing the binary or modifying any profile. +plan_profile "$HOME/.bashrc" bash +plan_profile "$HOME/.bash_profile" "" +plan_profile "$HOME/.bash_login" "" +plan_profile "$HOME/.profile" "" +plan_profile "$ZSH_DIR/.zshrc" zsh +plan_profile "$ZSH_DIR/.zprofile" "" +plan_profile "$ZSH_DIR/.zshenv" "" +if [[ "$ZSH_DIR" != "$HOME" ]]; then + plan_profile "$HOME/.zshrc" "" + plan_profile "$HOME/.zprofile" "" + plan_profile "$HOME/.zshenv" "" +fi +plan_profile "$CONFIG_DIR/fish/config.fish" fish -echo -green "✓ netssh $TAG installed to $INSTALL_DIR/$BINARY" -info "run 'netssh --help' to get started" +if [[ ${#PRIVILEGE[@]} -gt 0 ]]; then + info "sudo required to write to $INSTALL_DIR" +fi +"${PRIVILEGE[@]}" mkdir -p "$INSTALL_DIR" +# Stage on the destination filesystem to replace a running binary atomically. +STAGED=$("${PRIVILEGE[@]}" mktemp "$INSTALL_DIR/.netssh.XXXXXX") +"${PRIVILEGE[@]}" install -m 755 "$TMP/$ASSET" "$STAGED" +"${PRIVILEGE[@]}" mv -f "$STAGED" "$INSTALL_DIR/$BINARY" +STAGED="" + +# Regenerate aliases, including the configured command alias, as the calling user. +# An empty cache clears obsolete generated aliases. Never run the wizard here. +write_aliases() { + local kind=$1 path=$2 staged + "$INSTALL_DIR/$BINARY" alias --shell "$kind" > "$TMP/aliases-$kind" \ + || die "could not regenerate $kind aliases; profiles were not changed" + mkdir -p "$(dirname "$path")" + staged=$(mktemp "$(dirname "$path")/.netssh.XXXXXX") + cat "$TMP/aliases-$kind" > "$staged" + chmod 600 "$staged" + mv -f "$staged" "$path" +} +write_aliases bash "$CACHE_DIR/netssh/aliases.sh" +for kind in "${SHELLS[@]}"; do + if [[ "$kind" == fish ]]; then write_aliases fish "$CONFIG_DIR/fish/conf.d/netssh.fish"; fi +done + +for ((index=0; index<${#PROFILE_PATHS[@]}; index++)); do + target=${PROFILE_TARGETS[$index]} + data=${PROFILE_DATA[$index]} + if [[ -e "$target" ]] && cmp -s "$target" "$data"; then continue; fi + mkdir -p "$(dirname "$target")" + if [[ -e "$target" ]]; then + backup=$(mktemp "$target.netssh-backup.XXXXXX") + cp -p "$target" "$backup" + info "backup : $backup" + fi + staged=$(mktemp "$(dirname "$target")/.netssh.XXXXXX") + if [[ -e "$target" ]]; then cp -p "$target" "$staged"; else chmod 644 "$staged"; fi + cat "$data" > "$staged" + mv -f "$staged" "$target" + info "updated : ${PROFILE_PATHS[$index]}" +done +for kind in "${SHELLS[@]}"; do + if ! "$INSTALL_DIR/$BINARY" completion install --shell "$kind"; then + info "warning: could not update $kind completion" + fi +done +printf '\nnetssh %s installed / updated at %s/%s\n' "$TAG" "$INSTALL_DIR" "$BINARY" +info "Open a new terminal to load the current hooks and aliases." diff --git a/installer_test.go b/installer_test.go new file mode 100644 index 0000000..639e615 --- /dev/null +++ b/installer_test.go @@ -0,0 +1,351 @@ +package netssh_test + +import ( + "crypto/sha256" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +type installerFixture struct { + home, bin, assets string + env []string +} + +func newInstallerFixture(t *testing.T) installerFixture { + t.Helper() + root := t.TempDir() + f := installerFixture{home: filepath.Join(root, "home"), bin: filepath.Join(root, "tools"), assets: filepath.Join(root, "assets")} + for _, dir := range []string{f.home, f.bin, f.assets} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + writeFixture(t, filepath.Join(f.bin, "curl"), `#!/bin/bash +set -eu +out="" +while [[ $# -gt 0 ]]; do + case "$1" in -o) out=$2; shift 2 ;; *) url=$1; shift ;; esac +done +case "$url" in + */releases/latest) printf '{"tag_name": "v0.0.99"}\n'; exit ;; + */checksums.txt) [[ "${CHECKSUM_MODE:-}" != missing ]] || exit 22; file="$FIXTURE_ASSETS/checksums.txt" ;; + *) file="$FIXTURE_ASSETS/netssh_linux_amd64" ;; +esac +if [[ -n "$out" ]]; then cp "$file" "$out"; else cat "$file"; fi +`, 0o755) + writeFixture(t, filepath.Join(f.bin, "uname"), "#!/bin/sh\ncase \"$1\" in -s) echo Linux;; -m) echo x86_64;; esac\n", 0o755) + // Any accidental runtime dependency on Python must fail the installation. + writeFixture(t, filepath.Join(f.bin, "python3"), "#!/bin/sh\nexit 99\n", 0o755) + binary := `#!/bin/bash +set -eu +case "$1" in + alias) + if [[ "$3" == fish ]]; then printf "alias j 'netssh'\n"; else printf "alias j='netssh'\n"; fi ;; + completion) exit 0 ;; + shell-init) exit 0 ;; + *) exit 1 ;; +esac +` + writeFixture(t, filepath.Join(f.assets, "netssh_linux_amd64"), binary, 0o755) + checksum := fmt.Sprintf("%x netssh_linux_amd64\n", sha256.Sum256([]byte(binary))) + writeFixture(t, filepath.Join(f.assets, "checksums.txt"), checksum, 0o644) + f.env = append(os.Environ(), "HOME="+f.home, "XDG_CONFIG_HOME="+filepath.Join(f.home, "config"), "XDG_CACHE_HOME="+filepath.Join(f.home, "cache"), "ZDOTDIR="+f.home, "SHELL=/bin/bash", "PATH="+f.bin+":"+os.Getenv("PATH"), "FIXTURE_ASSETS="+f.assets, "INSTALL_DIR="+filepath.Join(f.home, "local bin's"), "BASH_ENV=", "CHECKSUM_MODE=") + return f +} + +func writeFixture(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } +} + +func (f installerFixture) run(t *testing.T) (string, error) { + t.Helper() + // Piping stdin mirrors curl | bash, including EOF for any child commands. + script, err := os.ReadFile("install.sh") + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("bash") + cmd.Env = f.env + cmd.Stdin = strings.NewReader(string(script)) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func TestInstallerUpdatesAndConsolidatesProfilesWithoutPython(t *testing.T) { + f := newInstallerFixture(t) + original := "export KEEP=yes\n# netssh shell-init is documented here\necho 'netssh shell-init'\nnetssh shell-init\nnetssh shell-init # duplicate\nsource ~/.cache/netssh/aliases.sh 2>/dev/null\neval \"$(netssh alias)\"\nalias old='netssh old.example.com'\n" + target := filepath.Join(f.home, "dotfiles", "bashrc") + writeFixture(t, target, original, 0o600) + bashrc := filepath.Join(f.home, ".bashrc") + if err := os.Symlink(target, bashrc); err != nil { + t.Fatal(err) + } + writeFixture(t, filepath.Join(f.home, ".zshrc"), "export ZSH_KEEP=yes\nnetssh shell-init\n", 0o644) + writeFixture(t, filepath.Join(f.home, ".profile"), "export LOGIN_KEEP=yes\nnetssh shell-init\n", 0o644) + fish := filepath.Join(f.home, "config", "fish", "config.fish") + writeFixture(t, fish, "set KEEP yes\nnetssh alias --shell fish | source\n", 0o644) + installed := filepath.Join(f.home, "local bin's", "netssh") + writeFixture(t, installed, "old binary", 0o755) + if out, err := f.run(t); err != nil { + t.Fatalf("install: %v\n%s", err, out) + } + for _, path := range []string{bashrc, filepath.Join(f.home, ".zshrc"), fish} { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(data), "# >>> netssh >>>") != 1 { + t.Fatalf("bad managed blocks: %s", data) + } + } + data, _ := os.ReadFile(bashrc) + for _, kept := range []string{"export KEEP=yes", "# netssh shell-init is documented here", "echo 'netssh shell-init'"} { + if !strings.Contains(string(data), kept) { + t.Fatalf("unrelated line lost: %s", kept) + } + } + if strings.Contains(string(data), "alias old=") || strings.Contains(string(data), "eval ") { + t.Fatalf("legacy entries remain: %s", data) + } + if info, err := os.Lstat(bashrc); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("symlink lost: %v", err) + } + if info, _ := os.Stat(target); info.Mode().Perm() != 0o600 { + t.Fatal("permissions changed") + } + backups, _ := filepath.Glob(target + ".netssh-backup.*") + if len(backups) != 1 { + t.Fatalf("backups: %v", backups) + } + backup, _ := os.ReadFile(backups[0]) + if string(backup) != original { + t.Fatal("backup lost original content") + } + before := string(data) + if out, err := f.run(t); err != nil { + t.Fatalf("second install: %v\n%s", err, out) + } + data, _ = os.ReadFile(bashrc) + if string(data) != before { + t.Fatalf("not idempotent:\n%s", data) + } + backups, _ = filepath.Glob(target + ".netssh-backup.*") + if len(backups) != 1 { + t.Fatal("unchanged profile backed up again") + } + // Execute the generated Bash block to verify paths containing quotes and spaces. + cmd := exec.Command("bash", "-c", `. "$HOME/.bashrc"; alias j`) + cmd.Env = f.env + if out, err := cmd.CombinedOutput(); err != nil || !strings.Contains(string(out), "alias j='netssh'") { + t.Fatalf("source: %v\n%s", err, out) + } +} + +func TestInstallerRejectsUnverifiedDownloads(t *testing.T) { + for _, mode := range []string{"missing", "mismatch", "no-entry", "duplicate"} { + t.Run(mode, func(t *testing.T) { + f := newInstallerFixture(t) + f.env = append(f.env, "CHECKSUM_MODE="+mode) + sums := filepath.Join(f.assets, "checksums.txt") + switch mode { + case "mismatch": + writeFixture(t, sums, strings.Repeat("0", 64)+" netssh_linux_amd64\n", 0o644) + case "no-entry": + writeFixture(t, sums, strings.Repeat("0", 64)+" other_binary\n", 0o644) + case "duplicate": + data, _ := os.ReadFile(sums) + writeFixture(t, sums, string(data)+string(data), 0o644) + } + installed := filepath.Join(f.home, "local bin's", "netssh") + writeFixture(t, installed, "old binary", 0o755) + profile := filepath.Join(f.home, ".bashrc") + writeFixture(t, profile, "old profile\n", 0o600) + if out, err := f.run(t); err == nil { + t.Fatalf("accepted invalid checksum: %s", out) + } + data, _ := os.ReadFile(installed) + if string(data) != "old binary" { + t.Fatal("binary changed") + } + data, _ = os.ReadFile(profile) + if string(data) != "old profile\n" { + t.Fatal("profile changed") + } + }) + } +} + +func TestInstallerRefusesBrokenManagedBlock(t *testing.T) { + f := newInstallerFixture(t) + profile := filepath.Join(f.home, ".bashrc") + original := "export KEEP=yes\n# >>> netssh >>>\nunfinished\n" + writeFixture(t, profile, original, 0o644) + if out, err := f.run(t); err == nil { + t.Fatalf("accepted broken block: %s", out) + } + data, _ := os.ReadFile(profile) + if string(data) != original { + t.Fatal("profile changed") + } +} + +func TestInstallerUsesShasumAndFindsExistingBinary(t *testing.T) { + if _, err := exec.LookPath("shasum"); err != nil { + t.Skip("shasum not available") + } + f := newInstallerFixture(t) + // Hide sha256sum (and Python) as on a standard macOS installation. + for _, name := range []string{"shasum", "sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "perl"} { + tool, err := exec.LookPath(name) + if err != nil { + t.Fatal(err) + } + if err := os.Symlink(tool, filepath.Join(f.bin, name)); err != nil { + t.Fatal(err) + } + } + f.env = append(f.env, "PATH="+f.bin, "INSTALL_DIR=") + installed := filepath.Join(f.bin, "netssh") + writeFixture(t, installed, "#!/bin/sh\nexit 1\n", 0o755) + if out, err := f.run(t); err != nil { + t.Fatalf("shasum update: %v\n%s", err, out) + } + data, _ := os.ReadFile(installed) + expected, _ := os.ReadFile(filepath.Join(f.assets, "netssh_linux_amd64")) + if string(data) != string(expected) { + t.Fatal("existing installation was not replaced") + } +} + +func TestInstallerRespectsZdotdir(t *testing.T) { + f := newInstallerFixture(t) + zshDir := filepath.Join(f.home, "custom-zsh") + f.env = append(f.env, "SHELL=/bin/zsh", "ZDOTDIR="+zshDir) + old := filepath.Join(f.home, ".zshrc") + writeFixture(t, old, "export KEEP=yes\nnetssh shell-init\n", 0o644) + if out, err := f.run(t); err != nil { + t.Fatalf("zsh setup: %v\n%s", err, out) + } + data, _ := os.ReadFile(filepath.Join(zshDir, ".zshrc")) + if strings.Count(string(data), "# >>> netssh >>>") != 1 { + t.Fatalf("missing custom zsh setup: %s", data) + } + data, _ = os.ReadFile(old) + if string(data) != "export KEEP=yes\n" { + t.Fatalf("old zsh setup not cleaned: %s", data) + } +} + +func TestInstallerUsesMacOSCacheLayout(t *testing.T) { + f := newInstallerFixture(t) + writeFixture(t, filepath.Join(f.bin, "uname"), "#!/bin/sh\ncase \"$1\" in -s) echo Darwin;; -m) echo x86_64;; esac\n", 0o755) + data, _ := os.ReadFile(filepath.Join(f.assets, "netssh_linux_amd64")) + writeFixture(t, filepath.Join(f.assets, "checksums.txt"), fmt.Sprintf("%x netssh_darwin_amd64\n", sha256.Sum256(data)), 0o644) + f.env = append(f.env, "XDG_CACHE_HOME=") + if out, err := f.run(t); err != nil { + t.Fatalf("macOS setup: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(f.home, "Library", "Caches", "netssh", "aliases.sh")); err != nil { + t.Fatal(err) + } +} + +func TestInstallerChecksDependenciesBeforeDownloading(t *testing.T) { + for _, missing := range [][]string{{"awk", "cmp"}, {"curl"}, {"sha256sum", "shasum"}} { + t.Run(strings.Join(missing, "+"), func(t *testing.T) { + f := newInstallerFixture(t) + // Build an isolated PATH containing every dependency except the selected tools. + for _, name := range []string{"curl", "uname", "sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "sha256sum"} { + skip := false + for _, absent := range missing { + if name == absent { + skip = true + } + } + if skip { + if err := os.Remove(filepath.Join(f.bin, name)); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + continue + } + if _, err := os.Stat(filepath.Join(f.bin, name)); err == nil { + continue + } + path, err := exec.LookPath(name) + if err != nil { + t.Fatal(err) + } + if err := os.Symlink(path, filepath.Join(f.bin, name)); err != nil { + t.Fatal(err) + } + } + marker := filepath.Join(f.assets, "curl-was-called") + // Replacing curl with a marker makes any premature network operation observable. + if missing[0] != "curl" { + writeFixture(t, filepath.Join(f.bin, "curl"), "#!/bin/bash\nprintf called > \"$FIXTURE_ASSETS/curl-was-called\"\nexit 99\n", 0o755) + } + f.env = append(f.env, "PATH="+f.bin) + installed := filepath.Join(f.home, "local bin's", "netssh") + writeFixture(t, installed, "old binary", 0o755) + out, err := f.run(t) + if err == nil { + t.Fatalf("missing dependencies accepted: %s", out) + } + for _, name := range missing { + if !strings.Contains(out, name) { + t.Errorf("missing diagnostic for %s: %s", name, out) + } + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("curl ran before dependency checks completed") + } + data, _ := os.ReadFile(installed) + if string(data) != "old binary" { + t.Fatal("binary changed") + } + if _, err := os.Stat(filepath.Join(f.home, ".bashrc")); !os.IsNotExist(err) { + t.Fatal("profile created") + } + }) + } +} + +func TestInstallerChecksSudoBeforeDownloadingWhenNeeded(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can write directories regardless of mode bits") + } + f := newInstallerFixture(t) + for _, name := range []string{"sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "sha256sum"} { + path, err := exec.LookPath(name) + if err != nil { + t.Fatal(err) + } + if err := os.Symlink(path, filepath.Join(f.bin, name)); err != nil { + t.Fatal(err) + } + } + locked := filepath.Join(f.home, "locked") + if err := os.Mkdir(locked, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(locked, 0o755) }) + writeFixture(t, filepath.Join(f.bin, "curl"), "#!/bin/bash\nprintf called > \"$FIXTURE_ASSETS/curl-was-called\"\nexit 99\n", 0o755) + f.env = append(f.env, "PATH="+f.bin, "INSTALL_DIR="+filepath.Join(locked, "bin")) + out, err := f.run(t) + if err == nil || !strings.Contains(out, "sudo") { + t.Fatalf("missing sudo diagnostic: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(f.assets, "curl-was-called")); !os.IsNotExist(err) { + t.Fatal("download ran before sudo check") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 3d2f7f3..211084f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 99ac5d3..a5594a4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } + } +} diff --git a/internal/hook/aliases.go b/internal/hook/aliases.go index 05767e3..22c605b 100644 --- a/internal/hook/aliases.go +++ b/internal/hook/aliases.go @@ -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") } diff --git a/internal/hook/aliases_test.go b/internal/hook/aliases_test.go index c067076..d7ebd0d 100644 --- a/internal/hook/aliases_test.go +++ b/internal/hook/aliases_test.go @@ -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) + } +} diff --git a/internal/hook/hook.go b/internal/hook/hook.go index 6829af3..dc574d8 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -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 diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go index 0a6bc0c..34b84c7 100644 --- a/internal/hook/hook_test.go +++ b/internal/hook/hook_test.go @@ -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) + } +} diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index f0e586b..e886749 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -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) } diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index 1dc7602..372ff92 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -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) + } +}