#!/usr/bin/env bash ############################################################################### # Copyright 2024 Guillaume Archambault # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law ou agreed to in writing, software # distributed under the License est distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OU CONDITIONS OF ANY KIND, either express ou implied. # See the License for the specific language governing permissions and # limitations under the License. ############################################################################### set -eo pipefail; [[ $TRACE ]] && set -x # Colors and formatting readonly RED='\033[0;31m' readonly GREEN='\033[0;32m' readonly YELLOW='\033[1;33m' readonly BLUE='\033[0;34m' readonly BOLD='\033[1m' readonly NC='\033[0m' # No Color readonly VERSION="2.1.0" readonly CONFIG_DIR="${HOME}/.config/sshm" readonly DEFAULT_CONFIG="${HOME}/.ssh/config" readonly CURRENT_CONTEXT_FILE="${CONFIG_DIR}/.current_context" readonly GITHUB_REPO="Gu1llaum-3/sshm" mkdir -p "$CONFIG_DIR" # Initialize SSHM_CONTEXT from file or default if not already set if [[ -z "${SSHM_CONTEXT:-}" ]]; then if [[ -f "$CURRENT_CONTEXT_FILE" ]]; then export SSHM_CONTEXT=$(cat "$CURRENT_CONTEXT_FILE") else export SSHM_CONTEXT="$DEFAULT_CONFIG" fi fi CONFIG_FILE="$SSHM_CONTEXT" sshm_version() { echo -e "${BLUE}${BOLD}sshm $VERSION${NC}" echo # Fetch the latest release tag from GitHub local latest_version latest_version=$(curl -s "https://api.github.com/repos/$GITHUB_REPO/releases/latest" | jq -r .tag_name) if [[ "$latest_version" == "null" ]]; then echo -e "${RED}Error: Unable to fetch the latest release from GitHub.${NC}" 1>&2 exit 1 fi # Compare with the current version if [[ "$latest_version" != "$VERSION" ]]; then echo -e "${YELLOW}A new version of sshm is available: $latest_version${NC} (current: $VERSION)" echo -e "You can update by running: ${BOLD}git pull origin main${NC}" else echo -e "${GREEN}This is the latest version${NC}" fi } sshm_help() { echo -e "${BLUE}${BOLD}Usage:${NC} sshm [command] " echo echo -e "${BLUE}${BOLD}Commands:${NC}" cat< Connect directly to SSH host by name list List SSH hosts and prompt for connection ping Ping an SSH host to check availability view Check configuration of host delete Delete an SSH host from the configuration add Add an SSH host to the configuration context list List available contexts context use Use a specific context context create Create a new context context delete Delete an existing context help Displays help version Displays the current version EOF } sshm_list() { local config_file="$CONFIG_FILE" # Check if the file exists and is not empty if [[ ! -s "$config_file" ]]; then echo -e "\n${YELLOW}No SSH hosts configured in current context.${NC}" echo -e "Use ${BOLD}sshm add${NC} to add a new host configuration." exit 0 fi # Check if there are any Host entries if ! grep -q "^Host " "$config_file"; then echo -e "\n${YELLOW}No SSH hosts configured in current context.${NC}" echo -e "Use ${BOLD}sshm add${NC} to add a new host configuration." exit 0 fi # Display context name if not default if [[ "$SSHM_CONTEXT" != "$DEFAULT_CONFIG" ]]; then local context_name=$(basename "$SSHM_CONTEXT") echo -e "\n${BLUE}${BOLD}Context: ${NC}${context_name}" fi echo -e "\n${BLUE}${BOLD}List of SSH hosts:${NC}" # Create a temporary file to store results local tmp_file tmp_file=$(mktemp) # Process each host while IFS= read -r line; do host=$(echo "$line" | awk '{print $2}') hostname=$(awk '/^Host '"$host"'$/,/^$/' "$config_file" | awk '/HostName/ {print $2}') # Skip if no hostname found if [[ -z "$hostname" ]]; then continue fi if ping -c 1 -W 1 "$hostname" &> /dev/null; then echo -e "${GREEN}✓${NC} $host ($hostname)" >> "$tmp_file" else echo -e "${RED}✗${NC} $host ($hostname)" >> "$tmp_file" fi done < <(grep -E '^Host ' "$config_file" | grep -v '^#' | sort) # Display numbered results nl "$tmp_file" rm -f "$tmp_file" echo -ne "\n${BOLD}Enter the number or name of the host (or press Enter to exit):${NC} " read host if [[ -z "$host" ]]; then echo "No host specified, exiting." exit 0 fi sshm_connect "$config_file" "$host" } sshm_connect() { local config_file="$1" local host="$2" 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=$(grep -E '^Host ' "$config_file" | awk '{print $2}' | grep -v '^#' | sed -n "${host}p") 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() { local config_file="$1" local host="$2" if [[ -z "$host" ]]; then echo -e "${RED}Error: please provide a host name.${NC}" 1>&2 exit 1 fi local hostname hostname=$(awk '/^Host '"$host"'$/,/^$/' "$config_file" | awk '/HostName/ {print $2}') if [[ -z "$hostname" ]]; then echo -e "${RED}Error: HostName not found for host $host in SSH configuration.${NC}" 1>&2 exit 1 fi echo -e "\n${BLUE}Pinging $host ($hostname)...${NC}" if ping -c 1 -W 1 "$hostname" &> /dev/null; then echo -e "${GREEN}✓ $host ($hostname) is available${NC}" else echo -e "${RED}✗ $host ($hostname) is unavailable${NC}" fi } sshm_view() { local config_file="$1" local host="$2" if [[ -z "$host" ]]; then echo -e "${RED}Error: please provide a host name.${NC}" 1>&2 exit 1 fi local host_info host_info=$(awk '/^Host '"$host"'$/,/^$/' "$config_file") if [[ -z "$host_info" ]]; then echo -e "${RED}Error: host not found in SSH configuration.${NC}" 1>&2 exit 1 fi echo -e "\n${BLUE}${BOLD}Information for host $host:${NC}\n" echo "$host_info" } sshm_delete() { local config_file="$1" local host="$2" local silent="${3:-false}" if [[ -z "$host" ]]; then echo -e "${RED}Error: please provide a host name.${NC}" 1>&2 exit 1 fi # Create a backup of the original file cp "$config_file" "$config_file.bak" # Create a temporary file for the new content local tmp_file tmp_file=$(mktemp) sed '/^Host '"$host"'$/,/^$/d' "$config_file" > "$tmp_file" # Check if the temporary file is not empty before overwriting if [[ -s "$tmp_file" ]]; then mv "$tmp_file" "$config_file" rm -f "$config_file.bak" else mv "$config_file.bak" "$config_file" rm -f "$tmp_file" echo -e "${RED}Error: Operation would result in empty file. Operation cancelled.${NC}" 1>&2 exit 1 fi if [[ "$silent" != "true" ]]; then echo -e "${GREEN}Host $host removed from SSH configuration.${NC}" fi } sshm_add() { local config_file="$CONFIG_FILE" local host="$1" local hostname local user local port local identity_file local proxy_jump default_identity_file=$(find ~/.ssh -maxdepth 1 -type f \( -name "id_rsa" -o -name "id_ed25519" -o -name "id_ecdsa" -o -name "id_dsa" \) | head -n 1) default_identity_file=${default_identity_file:-~/.ssh/id_rsa} echo -e "\n${BLUE}${BOLD}Adding new SSH host configuration${NC}\n" if [[ -z "$host" ]]; then read -p "Enter host name: " host if [[ -z "$host" ]]; then echo -e "${RED}Error: host name cannot be empty.${NC}" 1>&2 exit 1 fi fi # Vérifier si le host existe déjà if grep -q "^Host $host$" "$config_file" 2>/dev/null; then echo -e "${RED}Error: Host '$host' already exists in configuration.${NC}" 1>&2 echo -e "Use ${BOLD}sshm edit $host${NC} to modify the existing configuration or choose a different name." 1>&2 exit 1 fi read -p "Enter HostName (IP address or domain): " hostname if [[ -z "$hostname" ]]; then echo -e "${RED}Error: HostName cannot be empty.${NC}" 1>&2 exit 1 fi read -p "Enter user name (default: $(whoami)): " user user=${user:-$(whoami)} read -p "Enter SSH port (default: 22): " port port=${port:-22} read -p "Enter path to SSH key (default: $default_identity_file): " identity_file identity_file=${identity_file:-$default_identity_file} read -p "Enter ProxyJump host (optional): " proxy_jump # Create the file if it doesn't exist touch "$config_file" # Add the new configuration { echo "" echo "Host $host" echo " HostName $hostname" echo " User $user" if [[ "$port" -ne 22 ]]; then echo " Port $port" fi if [[ "$identity_file" != "$default_identity_file" ]]; then echo " IdentityFile $identity_file" fi if [[ -n "$proxy_jump" ]]; then echo " ProxyJump $proxy_jump" fi } >> "$config_file" echo -e "\n${GREEN}✓ Configuration for host $host added successfully.${NC}" echo -e "You can now connect using: ${BOLD}sshm $host${NC}" } sshm_edit() { local config_file="$CONFIG_FILE" local host="$1" if [[ -z "$host" ]]; then echo -e "${RED}Error: please provide a host name.${NC}" 1>&2 exit 1 fi local host_info host_info=$(awk '/^Host '"$host"'$/,/^$/' "$config_file") if [[ -z "$host_info" ]]; then echo -e "${RED}Error: host not found in SSH configuration.${NC}" 1>&2 exit 1 fi echo -e "\n${BLUE}${BOLD}Editing configuration for host $host${NC}\n" default_identity_file=$(find ~/.ssh -maxdepth 1 -type f \( -name "id_rsa" -o -name "id_ed25519" -o -name "id_ecdsa" -o -name "id_dsa" \) | head -n 1) default_identity_file=${default_identity_file:-~/.ssh/id_rsa} # Extract current values local current_hostname=$(echo "$host_info" | awk '/HostName/ {print $2}') local current_user=$(echo "$host_info" | awk '/User/ {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_proxyjump=$(echo "$host_info" | awk '/ProxyJump/ {print $2}') # Create backup of the original file cp "$config_file" "$config_file.bak" # Prompt for new values, defaulting to current values if no input is given read -p "HostName [$current_hostname]: " new_hostname new_hostname=${new_hostname:-$current_hostname} read -p "User [$current_user]: " new_user new_user=${new_user:-$current_user} read -p "Port [${current_port:-22}]: " new_port new_port=${new_port:-${current_port:-22}} read -p "IdentityFile [${current_identity_file:-$default_identity_file}]: " new_identity_file new_identity_file=${new_identity_file:-${current_identity_file:-$default_identity_file}} if [[ -n "$current_proxyjump" ]]; then read -p "ProxyJump [$current_proxyjump] (enter 'none' to remove): " new_proxyjump if [[ "$new_proxyjump" == "None" || "$new_proxyjump" == "none" ]]; then new_proxyjump="" else new_proxyjump=${new_proxyjump:-$current_proxyjump} fi else read -p "ProxyJump (leave empty if none): " new_proxyjump fi # Create a temporary file for the new content local tmp_file tmp_file=$(mktemp) # Delete the old configuration sed '/^Host '"$host"'$/,/^$/d' "$config_file" > "$tmp_file" # Check if the temporary file is not empty if [[ ! -s "$tmp_file" ]]; then mv "$config_file.bak" "$config_file" rm -f "$tmp_file" echo -e "${RED}Error: Operation would result in empty file. Operation cancelled.${NC}" 1>&2 exit 1 fi # Add the new configuration { echo "" echo "Host $host" echo " HostName $new_hostname" echo " User $new_user" if [[ "$new_port" -ne 22 ]]; then echo " Port $new_port" fi if [[ "$new_identity_file" != "$default_identity_file" ]]; then echo " IdentityFile $new_identity_file" fi if [[ -n "$new_proxyjump" && "$new_proxyjump" != "None" && "$new_proxyjump" != "none" ]]; then echo " ProxyJump $new_proxyjump" fi } >> "$tmp_file" # Move the temporary file to the final location mv "$tmp_file" "$config_file" rm -f "$config_file.bak" echo -e "\n${GREEN}✓ Configuration for host $host updated successfully.${NC}" } context_list() { echo -e "\n${BLUE}${BOLD}Available contexts:${NC}" if [[ "$SSHM_CONTEXT" == "$DEFAULT_CONFIG" ]]; then echo -e "${GREEN}* default${NC}" else echo " default" fi for context in "$CONFIG_DIR"/*; do if [[ -f "$context" ]]; then local context_name context_name=$(basename "$context") if [[ "$CONFIG_DIR/$context_name" == "$SSHM_CONTEXT" ]]; then echo -e "${GREEN}* $context_name${NC}" else echo " $context_name" fi fi done } context_use() { local context="$1" if [[ -z "$context" ]]; then echo -e "${RED}Error: please provide a context name.${NC}" 1>&2 exit 1 fi if [[ "$context" == "default" ]]; then export SSHM_CONTEXT="$DEFAULT_CONFIG" elif [[ ! -f "$CONFIG_DIR/$context" ]]; then echo -e "${RED}Error: context '$context' does not exist.${NC}" 1>&2 exit 1 else export SSHM_CONTEXT="$CONFIG_DIR/$context" fi # Update the file for persistence between sessions echo "$SSHM_CONTEXT" > "$CURRENT_CONTEXT_FILE" echo -e "${GREEN}✓ Switched to context '$context'.${NC}" # Update CONFIG_FILE for the current session CONFIG_FILE="$SSHM_CONTEXT" } context_create() { local context="$1" if [[ -z "$context" ]]; then echo -e "${RED}Error: please provide a context name.${NC}" 1>&2 exit 1 fi if [[ -f "$CONFIG_DIR/$context" ]]; then echo -e "${RED}Error: context '$context' already exists.${NC}" 1>&2 exit 1 fi touch "$CONFIG_DIR/$context" chmod 600 "$CONFIG_DIR/$context" echo -e "${GREEN}✓ Context '$context' created.${NC}" } context_delete() { local context="$1" if [[ -z "$context" ]]; then echo -e "${RED}Error: please provide a context name.${NC}" 1>&2 exit 1 fi if [[ ! -f "$CONFIG_DIR/$context" ]]; then echo -e "${RED}Error: context '$context' does not exist.${NC}" 1>&2 exit 1 fi rm -f "$CONFIG_DIR/$context" echo -e "${GREEN}✓ Context '$context' deleted.${NC}" # If the deleted context was the current one, switch to default if [[ "$SSHM_CONTEXT" == "$CONFIG_DIR/$context" ]]; then export SSHM_CONTEXT="$DEFAULT_CONFIG" echo "$SSHM_CONTEXT" > "$CURRENT_CONTEXT_FILE" CONFIG_FILE="$SSHM_CONTEXT" echo -e "${YELLOW}Switched to default context.${NC}" fi } sshm_main() { local command="$1" shift if [[ -z $command ]]; then sshm_version echo sshm_help exit 0 fi # Check if command is a known command, otherwise treat it as a host to connect to case "$command" in "list") sshm_list ;; "ping") sshm_ping "$CONFIG_FILE" "$@" ;; "view") sshm_view "$CONFIG_FILE" "$@" ;; "delete") sshm_delete "$CONFIG_FILE" "$@" ;; "add") sshm_add "$@" ;; "edit") sshm_edit "$@" ;; "context") local subcommand="$1" shift case "$subcommand" in "list") context_list "$@" ;; "use") context_use "$@" ;; "create") context_create "$@" ;; "delete") context_delete "$@" ;; *) echo -e "${RED}Error: invalid context subcommand.${NC}" 1>&2 echo sshm_help exit 3 ;; esac ;; "version") sshm_version ;; "help") sshm_help ;; *) # If command is not recognized, treat it as a host name to connect to sshm_connect "$CONFIG_FILE" "$command" esac } if [[ "$0" == "$BASH_SOURCE" ]]; then # If no arguments are provided, display help if [[ $# -eq 0 ]]; then sshm_version echo sshm_help else sshm_main "$@" fi fi