I tried to see if I could access the internet from a closed VPC using AWS PrivateLink tunnel endpoints

I tried to see if I could access the internet from a closed VPC using AWS PrivateLink tunnel endpoints

Tested if a VPC without IGW or NAT Gateway can reach the internet via AWS PrivateLink tunnel endpoints. HTTP/1.1, HTTP/2, and STS worked; QUIC (HTTP/3) did not. Source IP was also verified.
2026.10.04

This page has been translated by machine translation. View original

Introduction

In September 2026, tunnel endpoints became available as new VPC endpoints for PrivateLink.
The fundamentals of tunnel endpoints, how GENEVE relay works, and verification between VPCs with overlapping CIDRs are introduced in the following articles.

https://dev.classmethod.jp/articles/aws-privatelink-tunnel-endpoint-cross-account/

https://dev.classmethod.jp/articles/privatelink-tunnel-endpoint-cidr-overlap/

In this article, I verified whether an EC2 instance in a VPC without an IGW or NAT Gateway can communicate with AWS APIs and the general internet via a NAT Gateway in a different VPC.

Verification Configuration

Closed VPC and Egress VPC

I prepared two VPCs in the Tokyo region (ap-northeast-1). The closed VPC in this article is a VPC that has no IGW, NAT Gateway, or VPC endpoint for STS. Since SSM is used to operate the EC2 instance, there is an endpoint for SSM.

Closed VPC Egress VPC
CIDR 10.1.0.0/16 10.2.0.0/16
Routes 10.1.0.0/16 local only. No IGW or NAT Gateway Public subnet has IGW and NAT Gateway. Private subnet (Resource Gateway) has 0.0.0.0/0 → NAT Gateway
VPC Endpoints ssm / ssmmessages / ec2messages (Interface) and tunnel endpoint. No STS endpoint —
Placement 1 EC2 instance (1a, t3.micro, Amazon Linux 2023, curl 8.21.0 supports HTTP/2, not HTTP/3). Tunnel endpoint in 2 AZs: 1a / 1c Resource Gateway in 1a / 1c

The Resource Configuration is CIDR type, with a shared CIDR of 0.0.0.0/0, protocol TCP_UDP, and ports 1-65535.

According to the documentation, to allow access to the entire network, you specify 0.0.0.0/0 as the shared CIDR for IPv4. Packets with a destination outside the shared CIDR range are not forwarded at the tunnel endpoint.

https://docs.aws.amazon.com/vpc/latest/privatelink/resource-configuration.html

https://docs.aws.amazon.com/vpc/latest/privatelink/privatelink-access-cidr-ranges.html

The security groups for the tunnel endpoint and EC2 instance include rules allowing UDP 6081 from 10.1.0.0/16.

EC2-Side Configuration

I placed the following relay on the EC2 instance in the closed VPC as /opt/geneve_relay.py. This is based on the script from the previous article, modified to allow specifying the destination CIDR to route into the tunnel as a command-line argument.

geneve_relay.py
#!/usr/bin/env python3
"""
TUN device + GENEVE relay for AWS PrivateLink Tunnel Endpoint (CIDR type).
Runs on the source EC2 itself. Routes the given CIDRs into tun0.

Based on geneve_nat_relay.py from the previous verification; the only change is
that the routed destinations are arbitrary CIDRs instead of a single /32.

Usage (run as root):
  python3 geneve_relay.py <tunnel_ep_ip> <src_ip> <cidr> [<cidr> ...]

Example (send all IPv4 except VPC-local and link-local through the tunnel):
  ip route add 169.254.0.0/16 dev ens5        # keep IMDS / Amazon DNS / NTP local
  python3 geneve_relay.py 10.1.2.211 10.1.2.10 0.0.0.0/1 128.0.0.0/1
"""

import sys, os, struct, socket, fcntl, select, signal, time

TUNNEL_PORT = 6081
GENEVE_HDR  = struct.pack('!BBH', 0, 0, 0x0800) + b'\x00\x00\x00\x00'  # 8 bytes, VNI=0

TUNSETIFF = 0x400454ca
IFF_TUN   = 0x0001
IFF_NO_PI = 0x1000

def log(msg):
    print(f'{time.strftime("%H:%M:%S")} {msg}', flush=True)

def open_tun(name='tun0'):
    tun = open('/dev/net/tun', 'r+b', buffering=0)
    fcntl.ioctl(tun, TUNSETIFF, struct.pack('16sH', name.encode(), IFF_TUN | IFF_NO_PI))
    return tun

def main():
    if len(sys.argv) < 4:
        print(f'Usage: {sys.argv[0]} <tunnel_ep_ip> <src_ip> <cidr> [<cidr> ...]')
        sys.exit(1)
    tunnel_ep, src_ip, cidrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    tun_name = 'tun0'

    tun = open_tun(tun_name)
    os.system(f'ip link set {tun_name} up')
    for c in cidrs:
        rc = os.system(f'ip route replace {c} dev {tun_name} src {src_ip}')
        log(f'route {c} -> {tun_name} (src {src_ip}) rc={rc}')

    udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    udp.bind(('0.0.0.0', TUNNEL_PORT))
    log(f'relay up: tunnel_ep={tunnel_ep}:{TUNNEL_PORT}')

    signal.signal(signal.SIGTERM, lambda s, f: (_ for _ in ()).throw(KeyboardInterrupt))
    sent = recv = 0
    last = time.time()
    try:
        while True:
            rlist, _, _ = select.select([tun, udp], [], [], 1.0)
            for fd in rlist:
                if fd is tun:
                    pkt = tun.read(65536)
                    if pkt:
                        udp.sendto(GENEVE_HDR + pkt, (tunnel_ep, TUNNEL_PORT))
                        sent += 1
                else:
                    data, _ = udp.recvfrom(65536)
                    if len(data) >= 8:
                        hdr_len = 8 + (data[0] & 0x3f) * 4  # base header + options
                        tun.write(data[hdr_len:])
                        recv += 1
            if time.time() - last >= 30:
                log(f'stats sent={sent} recv={recv}')
                last = time.time()
    except KeyboardInterrupt:
        log('stopping')
    finally:
        for c in cidrs:
            os.system(f'ip route del {c} dev {tun_name} 2>/dev/null')
        os.system(f'ip link set {tun_name} down 2>/dev/null')
        log(f'routes removed. total sent={sent} recv={recv}')

if __name__ == '__main__':
    main()

I ran this relay as root with the following command. Replace <...> with your environment-specific values.

ip route replace 169.254.0.0/16 dev ens5
python3 /opt/geneve_relay.py <tunnel endpoint ENI IP> <EC2 private IP> 0.0.0.0/1 128.0.0.0/1

In practice, the python3 line is launched as a resident process via systemd-run. The two routes 0.0.0.0/1 and 128.0.0.0/1 together direct all IPv4 traffic to tun0. Before starting the relay, I added a route pointing 169.254.0.0/16 to ens5 to prevent IMDS and NTP traffic from flowing into tun0.

Here is an excerpt of the routing table after startup (the default and gateway-bound entries are omitted).

0.0.0.0/1 dev tun0 scope link src <EC2 private IP>
10.1.0.2 via <subnet gateway> dev ens5 proto dhcp src <EC2 private IP> metric 512
10.1.2.0/24 dev ens5 proto kernel scope link src <EC2 private IP> metric 512
128.0.0.0/1 dev tun0 scope link src <EC2 private IP>
169.254.0.0/16 dev ens5 scope link

Since 0.0.0.0/1 covers the entire 10.1.0.0/16 range, traffic destined for other subnets within the VPC also goes to tun0. Of the VPC-internal traffic, what remains on ens5 is the direct route for 10.1.2.0/24 (the same subnet as the EC2 instance) and the host route for 10.1.0.2 (Amazon DNS) inserted by DHCP. The tunnel endpoint ENI specified as the relay destination was in the same subnet as the EC2 instance, so it is reachable via this direct route.

Can Traffic Exit from the Closed VPC to the Internet?

Nothing is reachable without the tunnel

Before starting the relay, I made one STS call from the same EC2 instance. For dev.classmethod.jp and example.com, I ran curl 4 times in total — once each with HTTP/1.1 and HTTP/2. All of them resulted in connection timeouts. The same results were obtained when measured again after stopping the relay. Below is an excerpt of the output.

$ aws sts get-caller-identity --cli-connect-timeout 5 --cli-read-timeout 5 --region ap-northeast-1 --query Arn --output text
exit=255 elapsed=16.612 at=2026-10-03T16:18:59.602Z
$ curl -sS --http1.1 --connect-timeout 5 --max-time 8 -o /dev/null -w http_code=%{http_code} http_version=%{http_version} remote_ip=%{remote_ip} total=%{time_total}\n https://dev.classmethod.jp/
http_code=000 http_version=0 remote_ip= total=5.002210
exit=28 elapsed=5.009 at=2026-10-03T16:19:04.637Z
$ curl -sS --http2 --connect-timeout 5 --max-time 8 -o /dev/null -w http_code=%{http_code} http_version=%{http_version} remote_ip=%{remote_ip} total=%{time_total}\n https://example.com/
http_code=000 http_version=0 remote_ip= total=5.000986
exit=28 elapsed=5.008 at=2026-10-03T16:19:19.741Z
aws: [ERROR]: Connect timeout on endpoint URL: "https://sts.ap-northeast-1.amazonaws.com/"

STS and external sites are reachable via the tunnel

With the relay running, I fetched the same 2 sites 3 times each using HTTP/1.1 and HTTP/2. The following is the command used for dev.classmethod.jp.

curl -sS --http2 --connect-timeout 10 --max-time 20 -o /dev/null -w 'http_code=%{http_code} http_version=%{http_version} remote_ip=%{remote_ip}:%{remote_port} ssl_verify=%{ssl_verify_result} size=%{size_download} t_connect=%{time_connect} t_tls=%{time_appconnect} total=%{time_total}\n' https://dev.classmethod.jp/
Destination Option http_code http_version size_download time_total
dev.classmethod.jp --http1.1 200 ×3 1.1 283309 0.029–0.074 sec
dev.classmethod.jp --http2 200 ×3 2 283309 0.027–0.036 sec
example.com --http1.1 200 ×3 1.1 577 0.026–0.057 sec
example.com --http2 200 ×3 2 577 0.025–0.026 sec

For both sites, certificate verification succeeded (ssl_verify_result=0) in all 3 attempts each with HTTP/1.1 and HTTP/2 (6 attempts total). The 6 connections to dev.classmethod.jp were distributed across 4 different IPs in the 18.65.214.x range.

aws sts get-caller-identity also succeeded all 3 times, with elapsed times of approximately 0.50–0.53 seconds. The ARN in the response was a session for the role attached to this EC2 instance (arn:aws:sts::<ACCT>:assumed-role/<role name>/<instance ID>).

Inspecting GENEVE packet contents with tcpdump

Looking at GENEVE packets destined for the tunnel endpoint ENI in a tcpdump on ens5, there was a SYN destined for dev.classmethod.jp's IP:443 inside the packets.

1791044829.666713 IP <EC2 private IP>.6081 > <tunnel endpoint ENI IP>.6081: Geneve, Flags [none], vni 0x0: IP <EC2 private IP>.44212 > 18.65.214.101.443: Flags [S], seq 4166829652, win 64240, options [mss 1460,sackOK,TS val 3555845180 ecr 0,nop,wscale 7], length 0
ens5 packets to 18.65.214.101:443 inside GENEVE: 176
ens5 packets from 18.65.214.101:443 inside GENEVE: 428

18.65.214.101 matched the remote_ip that the --http2 curl connected to.

Package Installation and Docker

Running dnf -y install docker on the closed EC2 instance successfully fetched packages from the Amazon Linux 2023 official repository via the tunnel and completed the installation. The initial run, including repository metadata retrieval, took approximately 60 seconds.

With routes for 0.0.0.0/1 and 128.0.0.0/1 on tun0, the first systemctl start docker failed (could not find an available, non-overlapping IPv4 address). Specifying the default bridge address in /etc/docker/daemon.json, followed by reset-failed and restarting, resolved the issue (Docker version 25.0.14). The commands are as follows.

mkdir -p /etc/docker
echo '{"bip":"172.17.0.1/16"}' > /etc/docker/daemon.json
systemctl reset-failed docker
systemctl start docker

After that, docker pull curlimages/curl:latest succeeded in approximately 3.2 seconds. ymuski/curl-http3, badouralix/curl-http3 (both from Docker Hub), and quay.io/curl/curl:latest (from quay.io) were also successfully pulled.

Source IP and CloudTrail

The response from checkip.amazonaws.com matched the EIP of the NAT Gateway in the egress VPC (<NAT-EIP>). In CloudTrail, I confirmed 24 events for this instance (16:20–16:40 UTC).

Event sourceIPAddress vpcEndpointId Count
sts GetCallerIdentity <NAT-EIP> None 3
ec2 DescribeRegions <NAT-EIP> None 15
ssm UpdateInstanceInformation / ListInstanceAssociations EC2 private IP vpce-… 6

DescribeRegions was called to verify that the EC2 API was functioning. SSM communication from the same EC2 instance goes through the Interface endpoint, so the EC2 private IP and VPC endpoint ID were recorded. In the GetCallerIdentity event body, vpcEndpointId was null and clientProvidedHostHeader was sts.ap-northeast-1.amazonaws.com.

QUIC (HTTP/3) is not reachable

The response from dev.classmethod.jp includes alt-svc: h3=":443"; ma=86400, indicating HTTP/3 support.

On the closed EC2 instance's Docker (with --network host), I used two HTTP/3-capable curl images with the latest tag. I ran --http3-only twice each and --http3 (with fallback) once each against dev.classmethod.jp and www.google.com.

Image curl --http3-only --http3
ymuski/curl-http3:latest 8.2.1-DEV (quiche 0.18.0) Connection timeout after 8 seconds for all 2 sites × 2 attempts (exit=28) 200 for both sites, http_version=2
badouralix/curl-http3:latest 8.2.0-DEV (ngtcp2 0.17.0-DEV) Connection timeout after 8 seconds for all 2 sites × 2 attempts (exit=28) 200 for both sites, http_version=1.1

In the tcpdump on tun0 during the HTTP/3 attempts, 66 packets were sent from the closed EC2 instance to UDP port 443, and 0 UDP responses were received from port 443. An example of a sent packet is <EC2 private IP>.37648 > 18.65.214.101.443: UDP, length 1200.

This verification confirmed that QUIC (HTTP/3) cannot be used through the tunnel in this configuration.

Summary

Using PrivateLink tunnel endpoints, an EC2 instance in a VPC with neither an IGW nor a NAT Gateway was able to access AWS APIs and HTTP/1.1/HTTP/2 websites using a NAT Gateway in a separate VPC as the egress point.

Given the configuration management required on the EC2 side for using the tunnel, it would be difficult to use this in a permanent production setup. However, it was confirmed that tunnel endpoints can serve as a temporary path when TCP internet access is needed — such as accessing external APIs or package repositories — during maintenance of EC2 instances in a closed VPC.

Additionally, if maintaining the isolation of a VPC is a requirement, it is recommended to pay close attention to how tunnel endpoints are handled.

Share this article

AWSのお困り事はクラスメソッドへ