Skip to content

Instantly share code, notes, and snippets.

@axot
Created January 14, 2026 04:35
Show Gist options
  • Select an option

  • Save axot/8170be5ad17a79d34bc7f4d2e7dc390e to your computer and use it in GitHub Desktop.

Select an option

Save axot/8170be5ad17a79d34bc7f4d2e7dc390e to your computer and use it in GitHub Desktop.
ec2-ssh-proxy
#!/bin/bash
#
# ec2-ssh-proxy - Auto-start/stop EC2 instances for SSH connections
#
# DESCRIPTION:
# SSH ProxyCommand wrapper that automatically starts stopped EC2 instances
# when connecting and provides manual stop functionality. Works transparently
# with standard ssh/scp commands via SSH ProxyCommand configuration.
#
# USAGE:
# As ProxyCommand (automatic):
# Add to ~/.ssh/config:
# Host myhost
# HostName xxx
# ProxyCommand ~/bin/ec2-ssh-proxy --proxy %h
#
# Then use standard commands:
# ssh myhost
# scp file.txt myhost:/tmp/
#
# Manual stop:
# ec2-ssh-proxy --stop <elastic-ip> [profile] [region]
#
# BEHAVIOR:
# - Fast path: If SSH port (22) is accessible, connects immediately via nc
# (no AWS API calls, ~0.4s connection time)
# - Slow path: If port closed, queries AWS for instance state:
# - stopped: starts instance and waits for SSH
# - stopping/shutting-down: waits for stopped, then starts
# - running: waits for SSH to be ready
# - Uses Elastic IP (EIP) as instance identifier (easier than instance IDs)
# - Respects AWS_PROFILE and AWS_REGION environment variables
# - Only sets AWS_REGION if explicitly provided (avoids empty string bug)
#
# REQUIREMENTS:
# - aws CLI configured with credentials
# - nc (netcat) for TCP port checking and bidirectional I/O
# - timeout command (GNU coreutils)
# - Instance must have Elastic IP attached
#
# EXAMPLES:
# # SSH (auto-starts if stopped)
# ssh hostname
#
# # SCP (works transparently)
# scp file.txt sandbox:/tmp/
#
# # Manual stop
# ~/bin/ec2-ssh-proxy --stop host_ip
#
# # With custom AWS profile/region
# ~/bin/ec2-ssh-proxy --stop host_ip production us-west-2
#
# DESIGN DECISIONS:
# - Single script approach (consolidated from multiple helper scripts)
# - EIP as identifier (easier reference than instance ID/name)
# - ProxyCommand integration (transparent to ssh/scp)
# - Manual stop only (ProxyCommand cannot monitor SSH session lifecycle)
# - Optimization first (check TCP port before AWS APIs)
# - Use nc for all network operations (consistent, standard tool)
#
# EXIT CODES:
# 0 - Success
# 1 - Error (missing EIP, instance not found, start failed, unexpected state)
#
# AUTHOR:
# Generated by AI pair programming session (2026-01-14)
#
set -euo pipefail
check_tcp_port() {
local host="$1"
local port="$2"
local timeout="${3:-1}"
timeout "$timeout" nc -z "$host" "$port" >/dev/null 2>&1
}
get_instance_id_by_eip() {
local eip="$1"
aws ec2 describe-instances \
--profile "$AWS_PROFILE" \
${AWS_REGION:+--region "$AWS_REGION"} \
--filters "Name=ip-address,Values=$eip" \
--query 'Reservations[0].Instances[0].InstanceId' \
--output text 2>/dev/null
}
get_instance_state() {
local id="$1"
aws ec2 describe-instances \
--profile "$AWS_PROFILE" \
${AWS_REGION:+--region "$AWS_REGION"} \
--instance-ids "$id" \
--query 'Reservations[0].Instances[0].State.Name' \
--output text 2>/dev/null
}
start_and_wait() {
local instance_id="$1"
local eip="$2"
echo "[INFO] Starting instance $instance_id..." >&2
if ! aws ec2 start-instances \
--profile "$AWS_PROFILE" \
${AWS_REGION:+--region "$AWS_REGION"} \
--instance-ids "$instance_id" \
--output text >/dev/null 2>&1; then
echo "[ERROR] Failed to start instance" >&2
return 1
fi
echo "[INFO] Waiting for instance to be running..." >&2
aws ec2 wait instance-running \
--profile "$AWS_PROFILE" \
${AWS_REGION:+--region "$AWS_REGION"} \
--instance-ids "$instance_id" 2>/dev/null || true
echo "[INFO] Waiting for SSH to be ready..." >&2
local max_attempts=60
local attempts=0
while [ $attempts -lt $max_attempts ]; do
if check_tcp_port "$eip" 22 1; then
echo "[INFO] SSH port is ready after $attempts seconds" >&2
return 0
fi
sleep 1
((attempts++))
done
echo "[WARNING] SSH port check timed out, proceeding anyway..." >&2
}
show_usage() {
echo "[ERROR] Usage:" >&2
echo " ec2-ssh-proxy --proxy <eip> [profile] [region]" >&2
echo " ec2-ssh-proxy --stop <eip> [profile] [region]" >&2
exit 1
}
if [ "${1:-}" = "--proxy" ]; then
EIP="${2:-}"
[ -z "$EIP" ] && show_usage
AWS_PROFILE="${3:-${AWS_PROFILE:-default}}"
[ -n "${4:-}" ] && AWS_REGION="$4"
if check_tcp_port "$EIP" 22 1; then
echo "[INFO] SSH port already accessible, connecting directly..." >&2
exec nc "$EIP" 22
fi
echo "[INFO] SSH port closed, checking instance state..." >&2
INSTANCE_ID=$(get_instance_id_by_eip "$EIP")
if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" = "None" ] || [ "$INSTANCE_ID" = "null" ]; then
echo "[ERROR] No instance found with EIP: $EIP" >&2
exit 1
fi
STATE=$(get_instance_state "$INSTANCE_ID")
case "$STATE" in
running)
echo "[INFO] Instance running but SSH not ready yet, waiting..." >&2
start_and_wait "$INSTANCE_ID" "$EIP"
;;
stopped)
start_and_wait "$INSTANCE_ID" "$EIP"
;;
stopping|shutting-down)
echo "[INFO] Instance is $STATE, waiting for stopped state..." >&2
aws ec2 wait instance-stopped \
--profile "$AWS_PROFILE" \
${AWS_REGION:+--region "$AWS_REGION"} \
--instance-ids "$INSTANCE_ID" 2>/dev/null || true
start_and_wait "$INSTANCE_ID" "$EIP"
;;
*)
echo "[ERROR] Instance in unexpected state: $STATE" >&2
exit 1
;;
esac
exec nc "$EIP" 22
fi
if [ "${1:-}" = "--stop" ]; then
EIP="${2:-}"
[ -z "$EIP" ] && show_usage
AWS_PROFILE="${3:-${AWS_PROFILE:-default}}"
[ -n "${4:-}" ] && AWS_REGION="$4"
INSTANCE_ID=$(get_instance_id_by_eip "$EIP")
if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" = "None" ] || [ "$INSTANCE_ID" = "null" ]; then
echo "[ERROR] No instance found with EIP: $EIP" >&2
exit 1
fi
echo "[INFO] Stopping instance $INSTANCE_ID (EIP: $EIP)..." >&2
aws ec2 stop-instances \
--profile "$AWS_PROFILE" \
${AWS_REGION:+--region "$AWS_REGION"} \
--instance-ids "$INSTANCE_ID" \
--output text >/dev/null
echo "[INFO] Instance stop initiated" >&2
exit 0
fi
show_usage
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment