Last active
April 15, 2025 11:22
-
-
Save chattr/84ab255a1601b2e88c0f5dc07772c1b6 to your computer and use it in GitHub Desktop.
ssh_port_forward_via_ssm.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
# | |
# SSH port forward to EC2 instance via SSM | |
# | |
# Dependencies | |
# | |
# - Bash >=4 | |
# - AWS Credentials | |
# - Valid SSH private key for the target EC2 instance | |
# - [aws-cli](https://aws.amazon.com/cli/) | |
# - [fzf](https://github.com/junegunn/fzf) | |
# - [jq](https://jqlang.github.io/jq/) | |
# | |
# | |
set -o nounset -o pipefail | |
# defaults if unset or null | |
: "${aws_region:=eu-west-1}" | |
: "${ec2_instance_name:=Bastion}" | |
: "${ec2_user:=ubuntu}" | |
: "${ssh_key_param_name:=id_ed25519_custom_private_key}" | |
: "${remote_port:=22}" | |
: "${local_port:=2222}" | |
_cleanup() { | |
rm -f "${tempfile-}" ~/.ssh/."${ssh_key_param_name}"* | |
} | |
# cleanup temp file on exit | |
trap _cleanup EXIT | |
_die() { | |
exit="${2:-1}" | |
[ "$exit" -ne 0 ] && msg='ERROR' || msg='INFO' | |
# print error to stderr and exit | |
printf "\n${msg}: %s\n" "$1" >&2 | |
# send SIGTERM to whole process group, killing descendants (captures background job) | |
trap 'trap - SIGTERM && kill -- -$$' SIGINT SIGTERM EXIT | |
exit "$exit" | |
} | |
# check version of bash and exit if not using at least version 4 | |
[[ ${BASH_VERSINFO[0]} -lt 4 ]] && _die 'This script requires bash 4. Aborting' | |
_check_cmd() { | |
command -v "$1" > /dev/null 2>&1 || _die "${1} not installed. Aborting." | |
} | |
# check commands and die early if missing | |
_check_cmd aws # https://aws.amazon.com/cli/ | |
_check_cmd fzf # https://github.com/junegunn/fzf | |
_check_cmd jq # https://jqlang.github.io/jq/ | |
_get_instance_id() { | |
aws ec2 describe-instances \ | |
--region="$aws_region" \ | |
--filters \ | |
"Name=tag:Name,Values=${ec2_instance_name}" \ | |
"Name=instance-state-name,Values=running" \ | |
--output text \ | |
--no-cli-pager \ | |
--query 'Reservations[*].Instances[*].InstanceId' | |
} | |
_start_port_forward() { | |
local ec2_instance_id="$1" | |
aws ssm start-session \ | |
--region="$aws_region" \ | |
--target "$ec2_instance_id" \ | |
--document-name AWS-StartPortForwardingSession \ | |
--parameters "{\"portNumber\":[\"$remote_port\"],\"localPortNumber\":[\"$local_port\"]}" & | |
} | |
_start_ssh_session() { | |
ssh \ | |
-o StrictHostKeyChecking=no \ | |
-o UserKnownHostsFile=/dev/null \ | |
-i ~/.ssh/"$ssh_private_key" \ | |
"$user"@localhost -p "$local_port" -n -T > /dev/null 2>&1 || _die 'SSH session failed to open. Aborting.' | |
} | |
_get_remote_private_key() { | |
ssh_key_tags="$(aws ssm list-tags-for-resource \ | |
--region="$aws_region" \ | |
--output json \ | |
--resource-type "Parameter" \ | |
--resource-id "$ssh_key_param_name" \ | |
--query "TagList")" | |
app_name="$(jq -r '.[] | select(.Key == "Application") | .Value' <<< "$ssh_key_tags")" | |
env_name="$(jq -r '.[] | select(.Key == "Environment") | .Value' <<< "$ssh_key_tags")" | |
file_name="${ssh_key_param_name}"-"${app_name}"-"${env_name}" | |
# stage temporary file | |
tempfile="$(mktemp ~/.ssh/."${file_name,,}")" | |
# attempt to retrieve SSH private key from SSM Parameter Store | |
aws ssm get-parameter \ | |
--region="$aws_region" \ | |
--output text \ | |
--with-decryption \ | |
--name "$ssh_key_param_name" \ | |
--query "Parameter.Value" > "$tempfile" || _die 'Unable to retrieve SSH private key from SSM Parameter Store. Aborting' | |
cat "$tempfile" > ~/.ssh/"${file_name,,}" | |
chmod 0400 ~/.ssh/"${file_name,,}" | |
} | |
# test SSH private key existence and die early if none found | |
_get_local_private_key() { | |
# get a list of all [RSA] SSH private keys in ~/.ssh directory | |
ssh_private_keys=() | |
while IFS= read -r ssh_private_key; do | |
ssh_private_keys+=("$ssh_private_key") | |
done < <(grep -rli 'private key' ~/.ssh | grep -v 'ssh/\.') | |
# present all SSH private keys to user for selection | |
ssh_private_key="$(while read -r key; do basename -- "$key"; done <<< "$(printf -- '%s\n' "${ssh_private_keys[@]}")" | sort | fzf --color=header:\#EC7211 --layout=reverse --header "Select an SSH private key or hit <CTRL>+C if none matches:")" | |
} | |
# if retrieving a local SSH private key is not successful, attempt to retrieve from SSM Parameter Store | |
if ! _get_local_private_key; then | |
_get_remote_private_key && ssh_private_key="${file_name,,}" | |
fi | |
# get instance id | |
ec2_instance="$(_get_instance_id)" | |
# attempt port-forward to instance | |
if [[ $ec2_instance ]]; then | |
if ! { exec 3<>/dev/tcp/localhost/"$local_port"; } 2>/dev/null; then | |
_start_port_forward "$ec2_instance" || _die "Unable to start port forward of remote port \"${remote_port}\" on local port \"${local_port}\". Aborting." | |
else | |
_die "Local port \"${local_port}\" is already in use by another process. Aborting." | |
fi | |
else | |
_die "No InstanceId for EC2 instance filtered by name \"${ec2_instance_name}\". Aborting." | |
fi | |
# wait for local port to become available | |
c=0 | |
while ! { exec 3<>/dev/tcp/localhost/"$local_port"; } 2>/dev/null; do | |
((c++)) && ((c==10)) && _die "Check local port \"${local_port}\" exceeded retry interval. Aborting." | |
sleep 1 | |
done | |
# main workload | |
_main() { | |
# start ssh session | |
_start_ssh_session | |
# print instance details | |
printf "\n%s (%s) is now available via ssh://localhost:%s\n" "$ec2_instance_name" "$ec2_instance" "$local_port" | |
# user prompt to quit | |
printf -- '\n%s\n' 'Hit <CTRL>+C to quit' | |
wait | |
} | |
# execute main workload | |
_main | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment