From 76bc2c7adedbb0679055261bf3a8402d408a52ee Mon Sep 17 00:00:00 2001 From: fatalerrors Date: Tue, 13 Oct 2026 15:12:19 +0200 Subject: [PATCH] ssh bug fix --- README.md | 6 +- doc/CHANGELOG.md | 9 +++ profile.d/ssh.sh | 155 ++++++++++++++++++++++++++++++++--------------- 3 files changed, 118 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 60bf995..e6881d5 100644 --- a/README.md +++ b/README.md @@ -138,14 +138,14 @@ A bar-style prompt showing current time, execution time of the last command | `pwdscore` | pwd | Calculate the strength score of a given password | | `rain` | rain | Console screensaver with falling-rain effect (multiple color themes) | | `rainbow` | rain | Full-screen rainbow screensaver using only background colors, with horizontal color shifting | -| `rmhost` | ssh | Remove host(s) (name and IP) from SSH known_hosts; supports `--all-users` as root | +| `rmhost` | ssh | Remove host(s) from SSH known_hosts: a name is removed with every IP address it resolves to, `[host]:port` entries and glob patterns are supported; `--all-users` (root) handles every local user's file and keeps its owner and mode | | `rmspc` | filefct | Replace spaces in filenames with underscores (or a custom character, attached: `-c-` / `--subst-char=-`); never overwrites an existing file and does not follow symlinked directories | | `setc` | lang | Set locale to standard C (POSIX) | | `setlocale` | lang | Set console locale to any installed locale (codeset spelling tolerant, UTF-8 preferred when none is given); clears `LC_ALL` and the other per-category variables so the change always applies | | `set_theme` | prompt | Switch the prompt colour theme for the session (`--list`, `--preview`, `--save`) | | `settrace` | debug | Activate (`--on`), deactivate (`--off`) or query (`--status`) an ERR trap that prints the exit status and a call stack (on stderr) for each failing command; `--force` replaces/removes an ERR trap set by something else; active at startup when `SETTRACE_DEFAULT_ENABLED=1` | | `showinfo` | info | Display welcome banner and system information (figlet + fastfetch/neofetch) | -| `ssr` | ssh | SSH into a server as root, forwarding extra ssh options | +| `ssr` | ssh | SSH into a server as root (the server comes first, then extra ssh options); default options from `SSH_DEFAULT_OPT` | | `taz` | compress | Compress files and directories into a chosen archive format | | `term_set` | conf | Set `TERM` to the best available terminal capability | | `urlencode` | net | URL-encode a string | @@ -261,7 +261,7 @@ change the default without having to pass flags every time. | Key | Default | Description | | --- | --- | --- | -| `SSH_DEFAULT_OPT` | _(empty)_ | Extra options passed to `ssr` (e.g. `-Y` for X forwarding) | +| `SSH_DEFAULT_OPT` | `-Y` when unset | Options passed to `ssr` before the server (e.g. `-Y -o StrictHostKeyChecking=accept-new`); set it empty for none | **`[pwd]`** — password tools diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 039a78a..4a1fdc5 100755 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -152,6 +152,15 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`). silent white, a null speed no longer spins the CPU, a non-terminal stdin or stdout is refused instead of looping, and the usage mentions `q` to quit; README defaults of the `[rain]` keys corrected. +- `ssh.sh`: `rmhost` with a wildcard that matched nothing tried to remove an + empty host name; it removed only the first IP of a name (all addresses are + now handled); `[host]:port` arguments were taken for glob patterns; each + match was removed from every file with warnings for the others (now per + file); with `--all-users`, `ssh-keygen` left other users' files owned by + root (owner and mode are restored) and a shared home directory was + processed twice; `@cert-authority` lines are never touched. `ssr` ignored an + empty `SSH_DEFAULT_OPT` (documented as "no option") and now rejects an + option given in place of the server. - `compress.sh`: option flags and loop variables no longer leak into the interactive shell. diff --git a/profile.d/ssh.sh b/profile.d/ssh.sh index 20fb679..94c89d7 100644 --- a/profile.d/ssh.sh +++ b/profile.d/ssh.sh @@ -36,6 +36,35 @@ load_conf "ssh" +# ------------------------------------------------------------------------------ +# Internal helper: remove (a name or an IP, in known_hosts notation, so +# also "[host]:port") from the known_hosts file with ssh-keygen -R. +# ssh-keygen writes a new file: run by root on another user's file (--all-users) +# it would leave it owned by root with mode 0644, which keeps that user's ssh +# from adding host keys, or exposes a private file. The owner and mode of the +# file, and of its ".old" backup, are therefore put back. +# Returns ssh-keygen's status (non-zero when the key was not found). +# Usage: _rmhost_remove +_rmhost_remove() +{ + local key="$1" file="$2" owner="" mode="" rc=0 + if [[ ${EUID:-$(id -u)} -eq 0 ]]; then + owner=$(stat -c '%u:%g' -- "$file" 2>/dev/null) + mode=$(stat -c '%a' -- "$file" 2>/dev/null) + fi + + ssh-keygen -R "$key" -f "$file" /dev/null 2>&1 || rc=$? + + if [[ -n $owner && -n $mode ]]; then + chown "$owner" -- "$file" "${file}.old" 2>/dev/null + chmod "$mode" -- "$file" "${file}.old" 2>/dev/null + fi + return "$rc" +} +export -f _rmhost_remove +# ------------------------------------------------------------------------------ + + # ------------------------------------------------------------------------------ # Remove host entries (name and IP) from ~/.ssh/known_hosts for the active user # Usage: rmhost [hostname2|ip2 [...]] @@ -47,10 +76,12 @@ rmhost() PARSED=$(getopt -o ha --long help,all-users -n 'rmhost' -- "$@") # shellcheck disable=SC2181 # getopt return code is checked immediately after - if [[ $? -ne 0 ]]; then return 1; fi - local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') - set -- "${_pargs[@]}" + if [[ $? -ne 0 ]]; then + disp E "Invalid options, use \"rmhost --help\" to display usage." + return 1 + fi + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" while true; do case "$1" in @@ -60,6 +91,7 @@ rmhost() printf "Options:\n" printf " -a, --all-users Remove entries from all local users when run as root\n" printf " -h, --help Display this help screen\n\n" + printf "A host name is removed together with all the IP addresses it resolves to.\n" printf "Wildcards:\n" printf " Glob patterns (*, ?, [...]) are expanded against unhashed known_hosts entries.\n" printf " Hashed entries (prefixed with |1|) are never matched by wildcards.\n" @@ -90,14 +122,27 @@ rmhost() return 127 } + # A name starting with "-" would be read as an option by the tools below + local target + for target in "$@"; do + if [[ "$target" == -* ]]; then + disp E "Invalid host '$target'." + return 1 + fi + done + if (( all_users )); then [[ ${EUID:-$(id -u)} -eq 0 ]] || { disp E "Option --all-users is only available when run as root." return 1 } + # Users may share a home directory: each file is listed once + local -A _seen_homes=() + local home while IFS=: read -r _ _ _ _ _ home _; do - [[ -n $home && -f $home/.ssh/known_hosts ]] || continue + [[ -n $home && -f $home/.ssh/known_hosts && -z ${_seen_homes[$home]+x} ]] || continue + _seen_homes[$home]=1 known_hosts_files+=("$home/.ssh/known_hosts") done < /etc/passwd @@ -109,19 +154,27 @@ rmhost() return 0 } else + if [[ ! -f "${HOME}/.ssh/known_hosts" ]]; then + disp W "No known_hosts file found (${HOME}/.ssh/known_hosts)." + return 0 + fi known_hosts_files=("${HOME}/.ssh/known_hosts") fi for target in "$@"; do - # Wildcard: expand glob pattern against unhashed known_hosts entries - if [[ "$target" == *['*?[']* ]]; then - local -a _matched=() - local _wf _wl _wfield _whost _wmatch - local -a _wentries + # Wildcard: expand the glob pattern against the unhashed entries of + # each file, and remove the matches from that file only. A "[host]:port" + # entry is not a pattern: its brackets are known_hosts notation. + if [[ "$target" == *['*?']* ]] || \ + [[ "$target" == *'['* && ! "$target" =~ ^\[[^][]+\]:[0-9]+$ ]]; then + local -a _matched=() _wentries=() + local _wf _wl _wfield _whost _wmatch _key _found=0 for _wf in "${known_hosts_files[@]}"; do - [[ -f "$_wf" ]] || continue + _matched=() while IFS= read -r _wl; do - [[ -z "$_wl" || "$_wl" == '#'* || "$_wl" == '|'* ]] && continue + # Comments, hashed entries and @cert-authority/@revoked + # marker lines are left alone + [[ -z "$_wl" || "$_wl" == '#'* || "$_wl" == '|'* || "$_wl" == '@'* ]] && continue _wfield="${_wl%% *}" IFS=',' read -ra _wentries <<< "$_wfield" for _whost in "${_wentries[@]}"; do @@ -132,55 +185,50 @@ rmhost() else _wmatch="$_whost" fi - # shellcheck disable=SC2053 + # shellcheck disable=SC2053 # unquoted on purpose: glob match [[ "$_wmatch" == $target ]] && _matched+=("$_whost") done done < "$_wf" - done - mapfile -t _matched < <(printf '%s\n' "${_matched[@]}" | sort -u) - - if [[ ${#_matched[@]} -eq 0 ]]; then - disp W "No known_hosts entries match pattern '$target'." - continue - fi - - local _key _known_hosts_file - for _key in "${_matched[@]}"; do - for _known_hosts_file in "${known_hosts_files[@]}"; do - disp I "Removing '$_key' from $_known_hosts_file..." - if ! ssh-keygen -R "$_key" -f "$_known_hosts_file" >/dev/null 2>&1; then - disp W "No known_hosts entry found for '$_key' in '$_known_hosts_file'." + [[ ${#_matched[@]} -gt 0 ]] || continue + _found=1 + while IFS= read -r _key; do + disp I "Removing '$_key' from $_wf..." + if ! _rmhost_remove "$_key" "$_wf"; then + disp W "No known_hosts entry found for '$_key' in '$_wf'." fi - done + done < <(printf '%s\n' "${_matched[@]}" | sort -u) done + + (( _found )) || disp W "No known_hosts entries match pattern '$target'." continue fi local hst="$target" - local ip="" - local v4=1 - local v6=1 + local ip + local -a ips=() - isipv4 "$hst" >/dev/null 2>&1; v4=$? - isipv6 "$hst" >/dev/null 2>&1; v6=$? - - if [[ $v4 -eq 0 || $v6 -eq 0 ]]; then - ip="$hst" + if isipv4 "$hst" >/dev/null 2>&1 || isipv6 "$hst" >/dev/null 2>&1; then + ips=("$hst") hst="" - fi - - if [[ -z ${ip:-} && -n ${hst:-} ]]; then + elif [[ "$hst" == '['*']:'* ]]; then + # Explicit [host]:port entry: removed exactly as given + : + else + # All the addresses of the name: ssh records the one it connected + # to, which may be any of them (IPv4 and IPv6, several A records) if command -v host >/dev/null 2>&1; then - ip=$(host "$hst" 2>/dev/null | - awk '/has address|has IPv6 address/ {print $NF; exit}') + mapfile -t ips < <(host "$hst" 2>/dev/null | + awk '/has address|has IPv6 address/ {print $NF}' | sort -u) elif command -v getent >/dev/null 2>&1; then - ip=$(getent ahosts "$hst" 2>/dev/null | awk 'NR == 1 {print $1; exit}') + # ahosts lists every address once per socket type + mapfile -t ips < <(getent ahosts "$hst" 2>/dev/null | + awk '{print $1}' | sort -u) else disp W "No resolver tool found; removing hostname only for '$hst'." fi - [[ -z ${ip:-} ]] && \ + [[ ${#ips[@]} -eq 0 ]] && \ disp W "Could not resolve IP for '$hst'; removing hostname only." fi @@ -188,16 +236,16 @@ rmhost() for known_hosts_file in "${known_hosts_files[@]}"; do if [[ -n ${hst:-} ]]; then disp I "Removing host $hst from $known_hosts_file..." - if ! ssh-keygen -R "$hst" -f "$known_hosts_file" >/dev/null 2>&1; then + if ! _rmhost_remove "$hst" "$known_hosts_file"; then disp W "No known_hosts entry found for '$hst' in '$known_hosts_file'." fi fi - if [[ -n ${ip:-} ]]; then + for ip in "${ips[@]}"; do disp I "Removing IP $ip from $known_hosts_file..." - if ! ssh-keygen -R "$ip" -f "$known_hosts_file" >/dev/null 2>&1; then + if ! _rmhost_remove "$ip" "$known_hosts_file"; then disp W "No known_hosts entry found for '$ip' in '$known_hosts_file'." fi - fi + done done done } @@ -238,9 +286,18 @@ ssr() local srv=$1 shift - # Build default options array from config, falling back to -Y (X11 forwarding) + # The first argument is the server: an option here ("ssr -p 22 host") + # would end up in the root@... destination + if [[ $srv == -* ]]; then + disp E "'$srv' is not a server name: the server comes first, then the ssh options." + return 1 + fi + + # Build the default options array from the configuration. An unset + # SSH_DEFAULT_OPT means -Y (X11 forwarding); an empty one means no default + # option at all, as the example configuration documents. local -a ssh_default_opts=() - if [[ -n "${SSH_DEFAULT_OPT:-}" ]]; then + if [[ -n "${SSH_DEFAULT_OPT+x}" ]]; then read -ra ssh_default_opts <<< "${SSH_DEFAULT_OPT}" else ssh_default_opts=(-Y)