Skip to content

Instantly share code, notes, and snippets.

@chattr
Last active April 15, 2025 11:22
Show Gist options
  • Save chattr/84ab255a1601b2e88c0f5dc07772c1b6 to your computer and use it in GitHub Desktop.
Save chattr/84ab255a1601b2e88c0f5dc07772c1b6 to your computer and use it in GitHub Desktop.
ssh_port_forward_via_ssm.sh
#!/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