Add tag support and improve list display formatting

- Implement SSH host tagging with filtering capability
- Add --tag option to filter hosts by tag in list command
- Enhanced table formatting with dynamic column sizing
- Tags are sorted alphabetically for consistent display
This commit is contained in:
Guillaume Archambault 2025-08-28 17:02:34 +02:00
parent 37818f77a9
commit dc3fbc00d1
2 changed files with 304 additions and 18 deletions

View File

@ -5,6 +5,7 @@ SSH Manager (sshm) is a bash script that simplifies and automates the management
## Features ## Features
- List all SSH hosts in the configuration file (with optional ping check). - List all SSH hosts in the configuration file (with optional ping check).
- Filter SSH hosts by tags for better organization.
- Connect to an SSH host by name or number from the list. - Connect to an SSH host by name or number from the list.
- View the configuration details of a specific SSH host. - View the configuration details of a specific SSH host.
- Add a new SSH host configuration. - Add a new SSH host configuration.
@ -57,6 +58,18 @@ To check host availability with ping (may be slower if hosts are unreachable):
sshm list --ping sshm list --ping
``` ```
To filter hosts by a specific tag:
```bash
sshm list --tag production
```
You can combine options:
```bash
sshm list --ping --tag staging
```
### Connect to an SSH Host ### Connect to an SSH Host
```bash ```bash
@ -164,6 +177,12 @@ sshm list
# List with availability check (slower if hosts are down) # List with availability check (slower if hosts are down)
sshm list --ping sshm list --ping
# Filter by specific tag
sshm list --tag production
# Combine filtering and ping check
sshm list --ping --tag staging
``` ```
### Adding a New SSH Host ### Adding a New SSH Host
@ -178,6 +197,8 @@ You will be prompted to enter the following details:
- User (default: current user) - User (default: current user)
- Port (default: 22) - Port (default: 22)
- IdentityFile (default: `~/.ssh/id_rsa`) - IdentityFile (default: `~/.ssh/id_rsa`)
- ProxyJump (optional)
- Tags (optional, comma-separated)
### Editing an Existing SSH Host ### Editing an Existing SSH Host
@ -215,6 +236,36 @@ sshm upgrade
Checks for and installs the latest version of sshm. The command will show you the current version, the available version, and ask for confirmation before upgrading. Checks for and installs the latest version of sshm. The command will show you the current version, the available version, and ask for confirmation before upgrading.
## Tags
SSH Manager supports tagging hosts for better organization and filtering. Tags are comma-separated labels that help you categorize your SSH hosts.
### Using Tags
When adding or editing a host, you can specify tags:
```bash
sshm add
# When prompted, enter tags like: production, webserver, ubuntu
```
### Filtering by Tags
Use the `--tag` option to filter hosts:
```bash
# Show only production hosts
sshm list --tag production
# Show only development hosts with ping check
sshm list --ping --tag development
```
### Tag Display
Tags are displayed in the list view with a `#` prefix and are automatically sorted alphabetically:
- Tags: `#database #production #ubuntu`
## License ## License
This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.

271
sshm.bash
View File

@ -26,7 +26,7 @@ readonly BLUE='\033[0;34m'
readonly BOLD='\033[1m' readonly BOLD='\033[1m'
readonly NC='\033[0m' # No Color readonly NC='\033[0m' # No Color
readonly VERSION="2.2.0" readonly VERSION="3.0.0"
readonly CONFIG_DIR="${HOME}/.config/sshm" readonly CONFIG_DIR="${HOME}/.config/sshm"
readonly DEFAULT_CONFIG="${HOME}/.ssh/config" readonly DEFAULT_CONFIG="${HOME}/.ssh/config"
readonly CURRENT_CONTEXT_FILE="${CONFIG_DIR}/.current_context" readonly CURRENT_CONTEXT_FILE="${CONFIG_DIR}/.current_context"
@ -167,7 +167,7 @@ sshm_help() {
echo -e "${BLUE}${BOLD}Commands:${NC}" echo -e "${BLUE}${BOLD}Commands:${NC}"
cat<<EOF | column -t -s $'\t' cat<<EOF | column -t -s $'\t'
<host> Connect directly to SSH host by name <host> Connect directly to SSH host by name
list [--ping] List SSH hosts and prompt for connection (--ping to check availability) list [--ping] [--tag <tag>] List SSH hosts and prompt for connection (--ping to check availability, --tag to filter by tag)
ping <name> Ping an SSH host to check availability ping <name> Ping an SSH host to check availability
view <name> Check configuration of host view <name> Check configuration of host
delete <name> Delete an SSH host from the configuration delete <name> Delete an SSH host from the configuration
@ -185,11 +185,25 @@ EOF
sshm_list() { sshm_list() {
local config_file="$CONFIG_FILE" local config_file="$CONFIG_FILE"
local do_ping=false local do_ping=false
local filter_tag=""
# Check for --ping option # Check for options
if [[ "$1" == "--ping" ]]; then while [[ $# -gt 0 ]]; do
do_ping=true case $1 in
fi --ping)
do_ping=true
shift
;;
--tag)
filter_tag="$2"
shift 2
;;
*)
echo -e "${RED}Error: Unknown option $1${NC}" 1>&2
exit 1
;;
esac
done
# Check if the file exists and is not empty # Check if the file exists and is not empty
if [[ ! -s "$config_file" ]]; then if [[ ! -s "$config_file" ]]; then
@ -211,49 +225,196 @@ sshm_list() {
echo -e "\n${BLUE}${BOLD}Context: ${NC}${context_name}" echo -e "\n${BLUE}${BOLD}Context: ${NC}${context_name}"
fi fi
if [[ "$do_ping" == true ]]; then if [[ -n "$filter_tag" ]]; then
echo -e "\n${BLUE}${BOLD}List of SSH hosts (with ping):${NC}" if [[ "$do_ping" == true ]]; then
echo -e "\n${BLUE}${BOLD}List of SSH hosts with tag '$filter_tag' (with ping):${NC}"
else
echo -e "\n${BLUE}${BOLD}List of SSH hosts with tag '$filter_tag':${NC}"
fi
else else
echo -e "\n${BLUE}${BOLD}List of SSH hosts:${NC}" if [[ "$do_ping" == true ]]; then
echo -e "\n${BLUE}${BOLD}List of SSH hosts (with ping):${NC}"
else
echo -e "\n${BLUE}${BOLD}List of SSH hosts:${NC}"
fi
fi fi
# Create a temporary file to store results # Create a temporary file to store results
local tmp_file local tmp_file
tmp_file=$(mktemp) tmp_file=$(mktemp)
# Create a file to store the filtered host names in order
local filtered_hosts_file
filtered_hosts_file=$(mktemp)
# Process each host # Process each host
while IFS= read -r line; do while IFS= read -r line; do
host=$(echo "$line" | awk '{print $2}') host=$(echo "$line" | awk '{print $2}')
# Extract hostname from the host block
hostname=$(awk '/^Host '"$host"'$/,/^$/' "$config_file" | awk '/HostName/ {print $2}') hostname=$(awk '/^Host '"$host"'$/,/^$/' "$config_file" | awk '/HostName/ {print $2}')
# Extract tags from the line immediately before the Host line
tags=$(awk '/^# Tags:.*/{tags=$0; getline; if($0 ~ /^Host '"$host"'$/) print tags}' "$config_file" | sed 's/^# Tags: //')
# Skip if no hostname found # Skip if no hostname found
if [[ -z "$hostname" ]]; then if [[ -z "$hostname" ]]; then
continue continue
fi fi
# Filter by tag if specified
if [[ -n "$filter_tag" ]]; then
if [[ ! "$tags" =~ (^|,)[[:space:]]*$filter_tag[[:space:]]*(,|$) ]]; then
continue
fi
fi
# Store the host name in the filtered list
echo "$host" >> "$filtered_hosts_file"
# Format tags for display
if [[ -n "$tags" ]]; then
tags_display="[$tags]"
else
tags_display=""
fi
if [[ "$do_ping" == true ]]; then if [[ "$do_ping" == true ]]; then
if ping -c 1 -W 1 "$hostname" &> /dev/null; then if ping -c 1 -W 1 "$hostname" &> /dev/null; then
echo -e "${GREEN}${NC} $host ($hostname)" >> "$tmp_file" echo -e " $host ($hostname) $tags_display" >> "$tmp_file"
else else
echo -e "${RED}${NC} $host ($hostname)" >> "$tmp_file" echo -e " $host ($hostname) $tags_display" >> "$tmp_file"
fi fi
else else
echo -e "$host ($hostname)" >> "$tmp_file" echo -e "$host ($hostname) $tags_display" >> "$tmp_file"
fi fi
done < <(grep -E '^Host ' "$config_file" | grep -v '^#' | sort) done < <(grep -E '^Host ' "$config_file" | grep -v '^#' | sort)
# Display numbered results # Check if we have any results
nl "$tmp_file" if [[ ! -s "$tmp_file" ]]; then
rm -f "$tmp_file" if [[ -n "$filter_tag" ]]; then
echo -e "\n${YELLOW}No hosts found with tag '$filter_tag'.${NC}"
else
echo -e "\n${YELLOW}No SSH hosts found.${NC}"
fi
rm -f "$tmp_file" "$filtered_hosts_file"
exit 0
fi
# Display results in a formatted table
echo
echo -ne "\n${BOLD}Enter the number or name of the host (or press Enter to exit):${NC} " # First pass: calculate column widths
local max_host_len=4 # "Host" header length
local max_addr_len=7 # "Address" header length
local max_tags_len=4 # "Tags" header length
# Create arrays to store parsed data
declare -a hosts
declare -a addresses
declare -a statuses
declare -a tags_list
local counter=1
while IFS= read -r line; do
local status=""
local host_name=""
local address=""
local tags_part=""
# Check if line starts with status symbol
if [[ "$line" =~ ^[[:space:]]*[✓✗][[:space:]]+ ]]; then
status=$(echo "$line" | sed -E 's/^[[:space:]]*([✓✗])[[:space:]]+.*/\1/')
line=$(echo "$line" | sed -E 's/^[[:space:]]*[✓✗][[:space:]]+//')
fi
# Extract host and hostname: "host (hostname)"
if [[ "$line" =~ ^([^(]+)[[:space:]]*\(([^)]+)\)[[:space:]]*(.*)$ ]]; then
host_name=$(echo "${BASH_REMATCH[1]}" | xargs)
address="${BASH_REMATCH[2]}"
remainder="${BASH_REMATCH[3]}"
if [[ "$remainder" =~ ^\[([^]]*)\] ]]; then
tags_part="${BASH_REMATCH[1]}"
fi
else
host_name="$line"
fi
# Format tags
local formatted_tags=""
if [[ -n "$tags_part" ]]; then
IFS=',' read -ra TAG_ARRAY <<< "$tags_part"
# Sort tags alphabetically
IFS=$'\n' sorted_tags=($(sort <<<"${TAG_ARRAY[*]}"))
for tag in "${sorted_tags[@]}"; do
tag=$(echo "$tag" | xargs) # trim whitespace
if [[ -n "$formatted_tags" ]]; then
formatted_tags="$formatted_tags #${tag}"
else
formatted_tags="#${tag}"
fi
done
fi
# Store data and update max lengths
hosts[$counter]="$host_name"
addresses[$counter]="$address"
statuses[$counter]="$status"
tags_list[$counter]="$formatted_tags"
[[ ${#host_name} -gt $max_host_len ]] && max_host_len=${#host_name}
[[ ${#address} -gt $max_addr_len ]] && max_addr_len=${#address}
[[ ${#formatted_tags} -gt $max_tags_len ]] && max_tags_len=${#formatted_tags}
((counter++))
done < "$tmp_file"
# Add some padding
((max_host_len += 2))
((max_addr_len += 2))
((max_tags_len += 2))
# Print header based on ping option
if [[ "$do_ping" == true ]]; then
printf "%-4s %-${max_host_len}s %-${max_addr_len}s %-${max_tags_len}s %s\n" "No." "Host" "Address" "Tags" "Status"
printf "%-4s %-${max_host_len}s %-${max_addr_len}s %-${max_tags_len}s %s\n" "---" "$(printf '%*s' $max_host_len | tr ' ' '-')" "$(printf '%*s' $max_addr_len | tr ' ' '-')" "$(printf '%*s' $max_tags_len | tr ' ' '-')" "------"
else
printf "%-4s %-${max_host_len}s %-${max_addr_len}s %s\n" "No." "Host" "Address" "Tags"
printf "%-4s %-${max_host_len}s %-${max_addr_len}s %s\n" "---" "$(printf '%*s' $max_host_len | tr ' ' '-')" "$(printf '%*s' $max_addr_len | tr ' ' '-')" "----"
fi
# Print data rows
for ((i=1; i<counter; i++)); do
if [[ "$do_ping" == true ]]; then
# Add colors to status symbols
local colored_status=""
if [[ "${statuses[$i]}" == "✓" ]]; then
colored_status="${GREEN}${NC}"
elif [[ "${statuses[$i]}" == "✗" ]]; then
colored_status="${RED}${NC}"
else
colored_status="${statuses[$i]}"
fi
printf "%-4s %-${max_host_len}s %-${max_addr_len}s %-${max_tags_len}s " "$i" "${hosts[$i]}" "${addresses[$i]}" "${tags_list[$i]}"
echo -e "$colored_status"
else
printf "%-4s %-${max_host_len}s %-${max_addr_len}s %s\n" "$i" "${hosts[$i]}" "${addresses[$i]}" "${tags_list[$i]}"
fi
done
rm -f "$tmp_file"
echo
echo -ne "${BOLD}Enter the number or name of the host (or press Enter to exit):${NC} "
read host read host
if [[ -z "$host" ]]; then if [[ -z "$host" ]]; then
echo "No host specified, exiting." echo "No host specified, exiting."
rm -f "$filtered_hosts_file"
exit 0 exit 0
fi fi
sshm_connect "$config_file" "$host" sshm_connect_filtered "$config_file" "$host" "$filtered_hosts_file"
rm -f "$filtered_hosts_file"
} }
sshm_connect() { sshm_connect() {
@ -287,6 +448,39 @@ sshm_connect() {
fi fi
} }
sshm_connect_filtered() {
local config_file="$1"
local host="$2"
local filtered_hosts_file="$3"
if [[ -z "$host" ]]; then
echo -e "${RED}Error: please provide a host number or name.${NC}" 1>&2
exit 1
fi
if [[ "$host" =~ ^[0-9]+$ ]]; then
local host_name
host_name=$(sed -n "${host}p" "$filtered_hosts_file")
if [[ -n "$host_name" ]]; then
echo -e "\n${GREEN}Connecting to $host_name...${NC}\n"
ssh -F "$config_file" "$host_name"
else
echo -e "${RED}Error: Invalid host number.${NC}" 1>&2
exit 2
fi
else
# Check if the host exists in the SSH configuration
if ! grep -q "^Host $host$" "$config_file"; then
echo -e "${RED}Error: Host '$host' not found in SSH configuration.${NC}" 1>&2
echo -e "Use ${BOLD}sshm list${NC} to see available hosts or ${BOLD}sshm add $host${NC} to add it." 1>&2
exit 1
fi
echo -e "\n${GREEN}Connecting to $host...${NC}\n"
ssh -F "$config_file" "$host"
fi
}
sshm_ping() { sshm_ping() {
local config_file="$1" local config_file="$1"
local host="$2" local host="$2"
@ -345,7 +539,34 @@ sshm_delete() {
# Create a temporary file for the new content # Create a temporary file for the new content
local tmp_file local tmp_file
tmp_file=$(mktemp) tmp_file=$(mktemp)
sed '/^Host '"$host"'$/,/^$/d' "$config_file" > "$tmp_file"
# Remove host block including tags (look for "# Tags:" line before "Host")
awk '
/^# Tags:.*/ {
# Check if next non-empty line is the host we want to delete
tags_line = $0
while ((getline next_line) > 0) {
if (next_line ~ /^$/) continue
if (next_line ~ /^Host '"$host"'$/) {
# Skip this host block entirely
while ((getline) > 0 && !/^$/) continue
next
} else {
# Not our host, keep the tags line and the next line
print tags_line
print next_line
break
}
}
next
}
/^Host '"$host"'$/ {
# Skip this host block
while ((getline) > 0 && !/^$/) continue
next
}
{ print }
' "$config_file" > "$tmp_file"
# Check if the temporary file is not empty before overwriting # Check if the temporary file is not empty before overwriting
if [[ -s "$tmp_file" ]]; then if [[ -s "$tmp_file" ]]; then
@ -409,12 +630,17 @@ sshm_add() {
read -p "Enter ProxyJump host (optional): " proxy_jump read -p "Enter ProxyJump host (optional): " proxy_jump
read -p "Enter tags (comma-separated, optional): " tags
# Create the file if it doesn't exist # Create the file if it doesn't exist
touch "$config_file" touch "$config_file"
# Add the new configuration # Add the new configuration
{ {
echo "" echo ""
if [[ -n "$tags" ]]; then
echo "# Tags: $tags"
fi
echo "Host $host" echo "Host $host"
echo " HostName $hostname" echo " HostName $hostname"
echo " User $user" echo " User $user"
@ -459,6 +685,9 @@ sshm_edit() {
local current_port=$(echo "$host_info" | awk '/Port/ {print $2}') local current_port=$(echo "$host_info" | awk '/Port/ {print $2}')
local current_identity_file=$(echo "$host_info" | awk '/IdentityFile/ {print $2}') local current_identity_file=$(echo "$host_info" | awk '/IdentityFile/ {print $2}')
local current_proxyjump=$(echo "$host_info" | awk '/ProxyJump/ {print $2}') local current_proxyjump=$(echo "$host_info" | awk '/ProxyJump/ {print $2}')
# Extract tags from the line immediately before the Host line
local current_tags=$(awk '/^# Tags:.*/{tags=$0; getline; if($0 ~ /^Host '"$host"'$/) print tags}' "$config_file" | sed 's/^# Tags: //')
# Create backup of the original file # Create backup of the original file
cp "$config_file" "$config_file.bak" cp "$config_file" "$config_file.bak"
@ -486,6 +715,9 @@ sshm_edit() {
else else
read -p "ProxyJump (leave empty if none): " new_proxyjump read -p "ProxyJump (leave empty if none): " new_proxyjump
fi fi
read -p "Tags [${current_tags}] (comma-separated): " new_tags
new_tags=${new_tags:-$current_tags}
# Create a temporary file for the new content # Create a temporary file for the new content
local tmp_file local tmp_file
@ -505,6 +737,9 @@ sshm_edit() {
# Add the new configuration # Add the new configuration
{ {
echo "" echo ""
if [[ -n "$new_tags" ]]; then
echo "# Tags: $new_tags"
fi
echo "Host $host" echo "Host $host"
echo " HostName $new_hostname" echo " HostName $new_hostname"
echo " User $new_user" echo " User $new_user"