diff --git a/.changes/next-release/bugfix-ec2-instance-connect-ssh-IPv6.json b/.changes/next-release/bugfix-ec2-instance-connect-ssh-IPv6.json new file mode 100644 index 000000000000..2398806295ee --- /dev/null +++ b/.changes/next-release/bugfix-ec2-instance-connect-ssh-IPv6.json @@ -0,0 +1,5 @@ +{ + "type": "bugfix", + "category": "``ec2-instance-connect``", + "description": "Fall back to the instance's IPv6 address in ``ec2-instance-connect ssh --connection-type eice`` and ``ec2-instance-connect open-tunnel`` when the instance has no private IPv4 address (e.g. an IPv6-only subnet)." +} diff --git a/awscli/customizations/ec2instanceconnect/opentunnel.py b/awscli/customizations/ec2instanceconnect/opentunnel.py index 2d301b77b3a6..0de7849e58b9 100644 --- a/awscli/customizations/ec2instanceconnect/opentunnel.py +++ b/awscli/customizations/ec2instanceconnect/opentunnel.py @@ -30,7 +30,7 @@ class OpenTunnelCommand(BasicCommand): NAME = "open-tunnel" DESCRIPTION = ( - "Opens a websocket tunnel to the specified EC2 Instance or private ip." + "Opens a websocket tunnel to the specified EC2 Instance or IP address." ) ARG_TABLE = [ @@ -55,7 +55,7 @@ class OpenTunnelCommand(BasicCommand): { "name": "private-ip-address", "help_text": ( - "Specify the private ip address to open a tunnel for. " + "Specify the private IPv4 (or IPv6) address to open a tunnel for. " "If this is specified, you must specify instance-connect-endpoint-id." ), "required": False, @@ -114,7 +114,16 @@ def _run_main(self, parsed_args, parsed_globals): )["Reservations"][0]["Instances"][0] instance_vpc_id = instance_metadata["VpcId"] instance_subnet_id = instance_metadata["SubnetId"] - private_ip_address = instance_metadata["PrivateIpAddress"] + # Fall back to IPv6 when the instance has no private IPv4 + # (e.g. an IPv6-only subnet). The EICE accepts either. + private_ip_address = instance_metadata.get( + "PrivateIpAddress" + ) or instance_metadata.get("Ipv6Address") + if not private_ip_address: + raise ParamValidationError( + "Unable to find any IP address on the instance to " + "connect to." + ) instance_connect_endpoint_id = parsed_args.instance_connect_endpoint_id instance_connect_endpoint_dns_name = ( diff --git a/awscli/customizations/ec2instanceconnect/ssh.py b/awscli/customizations/ec2instanceconnect/ssh.py index 09e3363e022b..46c195325074 100644 --- a/awscli/customizations/ec2instanceconnect/ssh.py +++ b/awscli/customizations/ec2instanceconnect/ssh.py @@ -86,7 +86,9 @@ class SshCommand(BasicCommand): '
  • Private IPv4
  • ' '' '' - '
  • eice: SSH using EC2 Instance Connect Endpoint. The CLI always uses the private IPv4 address.
  • ' + '
  • eice: SSH using EC2 Instance Connect Endpoint. ' + 'The CLI tries to connect using the private IPv4 address, ' + 'falling back to IPv6 if the instance has no private IPv4.
  • ' '
  • auto: The CLI automatically determines the connection type (direct or eice) ' 'to use based on the instance info. Currently the CLI tries to connect using the IP addresses ' 'in the following order and with the corresponding connection type:' @@ -171,7 +173,7 @@ def _run_main(self, parsed_args, parsed_globals): ip_address_to_connect = parsed_args.instance_ip elif parsed_args.connection_type == 'eice' or parsed_args.eice_options: use_open_tunnel = True - ip_address_to_connect = private_ip_address + ip_address_to_connect = private_ip_address or ipv6_address elif parsed_args.connection_type == 'direct': use_open_tunnel = False ip_address_to_connect = ( diff --git a/tests/functional/ec2instanceconnect/test_opentunnel.py b/tests/functional/ec2instanceconnect/test_opentunnel.py index 744aea670273..e1742665e97a 100644 --- a/tests/functional/ec2instanceconnect/test_opentunnel.py +++ b/tests/functional/ec2instanceconnect/test_opentunnel.py @@ -278,6 +278,45 @@ def describe_instance_response(): """ +@pytest.fixture +def describe_ipv6_only_instance_response(): + return """ + + + + + + i-123 + subnet-123 + vpc-123 + 2600:1f10:4f8e:db01:73f5:6b9d:c0da:1c27 + + + + + + """ + + +@pytest.fixture +def describe_no_ip_instance_response(): + return """ + + + + + + i-123 + subnet-123 + vpc-123 + + + + + + """ + + @pytest.fixture def request_params_for_describe_instance(): return {'InstanceIds': ['i-123']} @@ -816,3 +855,67 @@ def test_command_fails_when_empty_fips_endpoint_available_to_connect( assert 253 == result.rc assert "Unable to find FIPS Endpoint" in result.stderr + + def test_falls_back_to_ipv6_when_no_private_ipv4( + self, + cli_runner, + mock_crt_websocket, + connect_patch, + io_patch, + describe_ipv6_only_instance_response, + describe_eice_response, + dns_name, + datetime_utcnow_patch, + ): + cli_runner.env["AWS_USE_FIPS_ENDPOINT"] = "false" + cmdline = [ + "ec2-instance-connect", + "open-tunnel", + "--instance-id", + "i-123", + "--max-tunnel-duration", + "1", + ] + cli_runner.add_response( + HTTPResponse(body=describe_ipv6_only_instance_response) + ) + cli_runner.add_response(HTTPResponse(body=describe_eice_response)) + + test_server_input = b"Test Server Output" + mock_crt_websocket.add_output_from_server(test_server_input) + mock_crt_websocket.add_shutdown_from_server() + + with connect_patch, io_patch: + result = cli_runner.run(cmdline) + + assert 0 == result.rc + assert_url(dns_name, mock_crt_websocket.url) + parsed_qs = urllib.parse.parse_qs( + urllib.parse.urlparse(mock_crt_websocket.url).query + ) + assert parsed_qs["privateIpAddress"] == [ + "2600:1f10:4f8e:db01:73f5:6b9d:c0da:1c27" + ] + + def test_command_fails_when_instance_has_no_ip_address( + self, + cli_runner, + describe_no_ip_instance_response, + ): + cmdline = [ + "ec2-instance-connect", + "open-tunnel", + "--instance-id", + "i-123", + ] + cli_runner.add_response( + HTTPResponse(body=describe_no_ip_instance_response) + ) + + result = cli_runner.run(cmdline) + + assert 252 == result.rc + assert ( + "Unable to find any IP address on the instance to connect to." + in result.stderr + ) diff --git a/tests/functional/ec2instanceconnect/test_ssh.py b/tests/functional/ec2instanceconnect/test_ssh.py index f65109d76e57..63813d75afcf 100644 --- a/tests/functional/ec2instanceconnect/test_ssh.py +++ b/tests/functional/ec2instanceconnect/test_ssh.py @@ -128,6 +128,25 @@ def describe_ipv6_instance_response(): return get_describe_ipv6_instance_response() +def get_describe_ipv6_only_instance_response(): + return """ + + + + + + i-123 + subnet-123 + vpc-123 + 2600:1f10:4f8e:db01:73f5:6b9d:c0da:1c27 + + + + + + """ + + @pytest.fixture def describe_no_ip_instance_response(): return """ @@ -525,6 +544,36 @@ class TestSSHCommand: ], id='Open-Tunnel: select private IP when connection type eice', ), + pytest.param( + get_describe_ipv6_only_instance_response(), + get_describe_eice_response(), + get_request_params_for_describe_eice(), + [ + "ec2-instance-connect", + "ssh", + "--instance-id", + "i-123", + "--private-key-file", + "/tmp/ssh-file", + "--connection-type", + "eice", + ], + [ + 'ssh', + '-o', + 'ServerAliveInterval=5', + '-p', + '22', + '-i', + '/tmp/ssh-file', + '-o', + 'ProxyCommand=aws ec2-instance-connect open-tunnel --instance-id i-123 ' + '--private-ip-address 2600:1f10:4f8e:db01:73f5:6b9d:c0da:1c27 --remote-port 22 ' + '--instance-connect-endpoint-id eice-123 --instance-connect-endpoint-dns-name dns.com', + 'ec2-user@2600:1f10:4f8e:db01:73f5:6b9d:c0da:1c27', + ], + id='Open-Tunnel: fall back to IPv6 when connection type eice and no private IPv4', + ), pytest.param( get_describe_private_instance_response(), get_describe_eice_response(),