From aa1a0235440225893400bcedeb6f46dcf586f8a7 Mon Sep 17 00:00:00 2001 From: Leonard Lausen Date: Thu, 28 May 2026 21:42:06 +0000 Subject: [PATCH 1/2] Fall back to IPv6 for ec2-instance-connect ssh --connection-type eice When --connection-type eice is set, resolve the destination address as private IPv4 with IPv6 as a fallback, instead of unconditionally using private IPv4. Without this, instances in IPv6-only subnets fail with "Unable to find any IP address on the instance to connect to." even though the EICE itself supports IPv6 destinations. --- .../bugfix-ec2-instance-connect-ssh-IPv6.json | 5 ++ .../customizations/ec2instanceconnect/ssh.py | 6 ++- .../functional/ec2instanceconnect/test_ssh.py | 49 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 .changes/next-release/bugfix-ec2-instance-connect-ssh-IPv6.json 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..01df11893d13 --- /dev/null +++ b/.changes/next-release/bugfix-ec2-instance-connect-ssh-IPv6.json @@ -0,0 +1,5 @@ +{ + "type": "bugfix", + "category": "``ec2-instance-connect ssh``", + "description": "Fall back to the instance's IPv6 address when ``--connection-type eice`` is used and the instance has no private IPv4 address (e.g. an IPv6-only subnet)." +} 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_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(), From 1f7122247b1a128321ff1ba9bdfc96b437f5b297 Mon Sep 17 00:00:00 2001 From: Leonard Lausen Date: Fri, 29 May 2026 04:28:41 +0000 Subject: [PATCH 2/2] Fall back to IPv6 for ec2-instance-connect open-tunnel When --instance-id is given without --private-ip-address, resolve the destination as private IPv4 with IPv6 as a fallback, instead of KeyError'ing on PrivateIpAddress for instances in IPv6-only subnets. The EICE accepts either address family. Also raise a clear ParamValidationError when the instance has no IP address at all (matching the ssh subcommand's behavior). --- .../bugfix-ec2-instance-connect-ssh-IPv6.json | 4 +- .../ec2instanceconnect/opentunnel.py | 15 ++- .../ec2instanceconnect/test_opentunnel.py | 103 ++++++++++++++++++ 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/.changes/next-release/bugfix-ec2-instance-connect-ssh-IPv6.json b/.changes/next-release/bugfix-ec2-instance-connect-ssh-IPv6.json index 01df11893d13..2398806295ee 100644 --- a/.changes/next-release/bugfix-ec2-instance-connect-ssh-IPv6.json +++ b/.changes/next-release/bugfix-ec2-instance-connect-ssh-IPv6.json @@ -1,5 +1,5 @@ { "type": "bugfix", - "category": "``ec2-instance-connect ssh``", - "description": "Fall back to the instance's IPv6 address when ``--connection-type eice`` is used and the instance has no private IPv4 address (e.g. an IPv6-only subnet)." + "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/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 + )