Trying Out a PrivateLink Tunnel Endpoint Between VPCs with Completely Overlapping CIDRs

Trying Out a PrivateLink Tunnel Endpoint Between VPCs with Completely Overlapping CIDRs

Verified HTTP connectivity over AWS PrivateLink tunnel endpoints between VPCs sharing the same CIDR (10.0.0.0/16). Tested three methods: direct GENEVE relay, HAProxy, and transparent NAT relay, and summarize when to use each.
2026.09.20

This page has been translated by machine translation. View original

Introduction

On September 18, 2026, tunnel endpoints were added to AWS PrivateLink.

In the previous article, I confirmed that HTTP communication with an EC2 instance in a VPC in another account is possible through a tunnel endpoint.

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

VPC peering cannot connect VPCs with overlapping CIDRs. With Transit Gateway, even if you can attach the VPCs, you cannot route destinations with overlapping CIDRs separately for each VPC.

To check whether VPC-to-VPC communication can be achieved using PrivateLink tunnel endpoints even in a configuration with this constraint, I prepared VPCs that both have the same 10.0.0.0/16 CIDR on each side and verified HTTP connectivity.

Common Setup — GENEVE Relay

The steps to create the Resource Gateway, Resource Configuration (CIDR type, 10.0.0.0/16), and tunnel endpoint are the same as in the previous article. Because this article uses a two-VPC configuration within the same account, RAM sharing is not performed.

The GENEVE relay is the script from the previous article, modified so that only the /32 of the destination host is pointed to tun0, rather than the entire CIDR of the destination VPC. Because the source VPC is also 10.0.0.0/16, pointing the /16 to tun0 as in the previous article would make communication within the source VPC unreachable. Since this article does not deal with DNS resolution, I have also removed the route for the resolver. This relay is run on the access source EC2 (Method 1) or on the relay EC2 (Methods 2 and 3).

This script is for verification purposes. Run it as root; it rewrites the host's routing table. Also, connectivity is checked with curl. ICMP does not pass through the tunnel, so it cannot be checked with ping.

geneve_nat_relay.py (GENEVE relay common to Methods 1–3)
#!/usr/bin/env python3
"""
TUN device + GENEVE relay for AWS PrivateLink Tunnel Endpoint (CIDR type).
Runs on either a source EC2 or an intermediary EC2.

- Creates tun0
- Routes the specific /32 host via tun0
- Reads L3 IP packets from tun0, wraps in GENEVE (VNI=0, proto=0x0800), sends UDP to tunnel EP ENI
- Receives UDP from tunnel EP ENI, strips GENEVE header, writes inner IP to tun0

Usage (run as root):
  python3 geneve_nat_relay.py <tunnel_ep_ip> <src_ip> <target_host>

  tunnel_ep_ip : Tunnel Endpoint ENI IP (e.g. 10.0.2.174)
  src_ip       : This EC2's primary private IP (e.g. 10.0.0.7)
  target_host  : /32 route target (e.g. 10.0.2.37)

Example:
  python3 geneve_nat_relay.py 10.0.2.174 10.0.0.7 10.0.2.37
"""

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

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

# Linux TUN/TAP constants
TUNSETIFF   = 0x400454ca
IFF_TUN     = 0x0001
IFF_NO_PI   = 0x1000

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

def setup_route(tun_name, src_ip, target_host):
    os.system(f'ip link set {tun_name} up')
    # /32 route only — avoids conflicting with local VPC routing (10.0.0.0/16)
    os.system(f'ip route add {target_host}/32 dev {tun_name} src {src_ip}')
    print(f'Route added: {target_host}/32 -> {tun_name} (src {src_ip})')

def teardown_route(tun_name, target_host):
    os.system(f'ip route del {target_host}/32 dev {tun_name} 2>/dev/null')
    os.system(f'ip link set {tun_name} down 2>/dev/null')
    print('Routes removed')

def main():
    if len(sys.argv) < 4:
        print(f'Usage: {sys.argv[0]} <tunnel_ep_ip> <src_ip> <target_host>')
        sys.exit(1)

    tunnel_ep   = sys.argv[1]
    src_ip      = sys.argv[2]
    target_host = sys.argv[3]
    tun_name    = 'tun0'

    print(f'Tunnel EP:   {tunnel_ep}:{TUNNEL_PORT}')
    print(f'Source IP:   {src_ip}')
    print(f'Target host: {target_host}/32 via tun0')

    tun = open_tun(tun_name)
    print(f'Opened {tun_name}')

    setup_route(tun_name, src_ip, target_host)

    udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    udp.bind(('0.0.0.0', TUNNEL_PORT))
    print(f'Listening on UDP :{TUNNEL_PORT}')
    print('Relay running. Ctrl+C to stop.')

    def shutdown(sig, frame):
        raise KeyboardInterrupt

    signal.signal(signal.SIGTERM, shutdown)

    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))
                elif fd is udp:
                    data, _ = udp.recvfrom(65536)
                    if len(data) >= 8:
                        inner = data[8:]  # strip 8-byte GENEVE header
                        tun.write(inner)
    except KeyboardInterrupt:
        print('\nStopping relay...')
    finally:
        teardown_route(tun_name, target_host)
        tun.close()
        udp.close()

if __name__ == '__main__':
    main()

Method 1 — Run the GENEVE Relay Directly on the Access Source

This is a configuration where the GENEVE relay runs on the access source EC2 itself, without a relay EC2. The access source EC2 and the destination HTTP server both belong to 10.0.2.0/24. This is the most restrictive case, where even the subnet CIDRs overlap.

接続元 VPC (10.0.0.0/16)
  アクセス元 EC2 (10.0.2.30, prv-1a)
    │
    ▼
  Tunnel Endpoint ENI (10.0.2.174, 1a)
    │
    ▼
接続先 VPC (10.0.0.0/16)
  Resource Gateway
    │
    ▼
  HTTP サーバー (10.0.2.37, prv-1a)

Start the GENEVE relay on the access source EC2.

sudo python3 geneve_nat_relay.py 10.0.2.174 10.0.2.30 10.0.2.37

Leave the 10.0.0.0/16 route in the EC2's OS route table as is, and point only 10.0.2.37/32, the destination HTTP server, to tun0. Because of longest match, the /32 host route takes precedence over the /16, so the application can reach the HTTP server on the destination VPC side while still specifying 10.0.2.37. On the other hand, from an EC2 that has this /32 route, you can no longer reach a host with the same IP 10.0.2.37 within the source VPC, even if one exists.

From the access source EC2, send an HTTP request to the HTTP server in the destination VPC (10.0.2.37, port 80).

curl -v http://10.0.2.37/
* Established connection to 10.0.2.37 (10.0.2.37 port 80) from 10.0.2.30 port 33330
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.9.25
hello from http-server
curl exit status: 0

I confirmed HTTP connectivity even between VPCs whose CIDRs completely overlap.

Method 2 — HAProxy L4 Proxy (with Destination Change)

In this configuration, HAProxy and the GENEVE relay run on the relay EC2, and the application connects to HAProxy. In conventional PrivateLink, the NLB on the destination side distributes traffic to the destination resources. In this method, HAProxy in the source VPC takes on that role, and traffic from HAProxy is sent to the PrivateLink tunnel endpoint by the GENEVE relay on the same EC2. In this verification, I reused the NAT instance from Method 3 as the relay EC2.

接続元 VPC (10.0.0.0/16)
  アクセス元 EC2 (10.0.2.30, prv-1a)
    │
    ▼
  NAT インスタンス (10.0.0.7, pub-1a)
    │
    ▼
  Tunnel Endpoint ENI (10.0.2.174, 1a)
    │
    ▼
接続先 VPC (10.0.0.0/16)
  Resource Gateway
    │
    ▼
  HTTP サーバー (10.0.2.37, prv-1a)

Start the GENEVE relay and HAProxy on the NAT instance. Allow TCP 8080 in the NAT instance's SG so that the access source can connect to HAProxy.

sudo python3 geneve_nat_relay.py 10.0.2.174 10.0.0.7 10.0.2.37
/etc/haproxy/haproxy.cfg (the part related to relaying)
defaults
    mode tcp
    timeout connect 5s
    timeout client  30s
    timeout server  30s

frontend proxy_in
    bind 0.0.0.0:8080
    default_backend http_server

backend http_server
    server http_server 10.0.2.37:80 check

From the access source EC2, send an HTTP request specifying the private IP address (10.0.0.7) and listening port (8080) of HAProxy running on the NAT instance.

curl -v http://10.0.0.7:8080/
* Established connection to 10.0.0.7 (10.0.0.7 port 8080) from 10.0.2.30 port 38194
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.9.25
hello from http-server
curl exit status: 0

I was able to get 200 OK from the HTTP server in the destination VPC via HAProxy. The GENEVE relay is not needed on the access source EC2.

I have not verified it this time, but a forward proxy such as Squid may also be usable. In that case, proxy settings are required on the client side.

Method 3 — Transparent NAT Relay

I verified whether packets from the access source subnet can be transparently forwarded to the tunnel endpoint using a NAT instance as the relay point. No GENEVE relay or proxy is installed on the access source EC2; packets are forwarded to the NAT instance via the VPC route table.

This method can be used only when the CIDRs of the access source subnet and the destination subnet differ. In this verification, I placed the access source EC2 in 10.0.3.0/24 and the destination HTTP server in the 10.0.2.0/24 subnet. Because the CIDRs of the two subnets differ, the route table of the access source subnet can point 10.0.2.0/24 to the NAT instance's ENI.

On the other hand, if the access source and destination appear to be in the same subnet CIDR, that traffic does not go through the VPC route table, so it cannot be directed to the NAT instance. A route that points only the destination IP as a /32 to the NAT ENI is also rejected by the CreateRoute API. The destinations that can be pointed to an ENI in a VPC route table are limited to those that match a subnet CIDR within the VPC.

https://docs.aws.amazon.com/vpc/latest/userguide/route-table-options.html#appliance-considerations

接続元 VPC (10.0.0.0/16)
  アクセス元 EC2 (10.0.3.243, prv-1c)
    │  VPC ルート: 10.0.2.0/24 → NAT ENI
    ▼
  NAT インスタンス (10.0.0.7, pub-1a)
    │
    ▼
  Tunnel Endpoint ENI (10.0.2.174, 1a)
    │
    ▼
接続先 VPC (10.0.0.0/16)
  Resource Gateway
    │
    ▼
  HTTP サーバー (10.0.2.37, prv-1a)

In the route table of the access source subnet, set a route that points 10.0.2.0/24, the CIDR of the destination subnet, to the NAT instance's ENI. On the NAT instance, disable the source/destination check and enable IP forwarding. The NAT instance's SG must specify the source by CIDR rather than by SG ID reference. In my verification, with the SG ID reference left in place, the forwarded traffic did not match and connectivity failed.

When the GENEVE relay is started on the NAT instance, packets destined for 10.0.2.37/32 are received on tun0, GENEVE-encapsulated, and sent to the tunnel endpoint.

sudo sysctl -w net.ipv4.ip_forward=1
sudo python3 geneve_nat_relay.py 10.0.2.174 10.0.0.7 10.0.2.37

From the access source EC2 (10.0.3.243, prv-1c subnet), send an HTTP request specifying the private IP address (10.0.2.37) and listening port (80) of the HTTP server in the destination VPC.

# 実行元: 10.0.3.243 (prv-1c)
curl -v http://10.0.2.37/
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.9.25
hello from http-server
curl exit status: 0

In the packet capture taken on the NAT instance, I confirmed that the pre-encapsulation SYN received on ens5 was GENEVE-encapsulated and sent out to the tunnel endpoint, and that the return traffic was decapsulated and forwarded to the access source as a SYN-ACK.

18:46:14.828757 IP 10.0.3.243.52276 > 10.0.2.37.http: Flags [S]
18:46:14.828879 IP 10.0.0.7.geneve > 10.0.2.174.geneve: Geneve, vni 0x0:
    IP 10.0.3.243.52276 > 10.0.2.37.http: Flags [S]
18:46:14.831782 IP 10.0.2.174.geneve > 10.0.0.7.geneve: Geneve, vni 0x0:
    IP 10.0.2.37.http > 10.0.3.243.52276: Flags [S.]
18:46:14.831880 IP 10.0.2.37.http > 10.0.3.243.52276: Flags [S.]
...(GET / HTTP/1.1 → HTTP/1.0 200 OK 完了)

Although I call it a NAT instance, no address translation (SNAT/DNAT) is performed on this path. The source of the inner packet remains 10.0.3.243 when GENEVE-encapsulated.

From the above, I confirmed that the access source EC2 can forward traffic to the tunnel endpoint via the NAT instance without changing its destination from 10.0.2.37.

Note that with this route, all traffic from the access source subnet destined for 10.0.2.0/24 goes through the NAT instance. Destinations other than 10.0.2.37 reach their targets within the source VPC as usual, but this results in an asymmetric path in which only the outbound traffic passes through the NAT instance.

Choosing Between the Methods

The conditions under which each method can be chosen are as follows.

Method Usable even if the access source and destination subnet CIDRs overlap? Conditions for choosing it
Direct GENEVE relay Usable The GENEVE relay can be run on the access source EC2. You do not want to change the application's destination
HAProxy L4 Usable The application's destination can be changed to HAProxy on the relay EC2
Transparent NAT relay Not usable The CIDRs of the access source and destination subnets differ, and the VPC route to the destination subnet can be pointed to the NAT ENI

Summary

Using PrivateLink tunnel endpoints, I confirmed that HTTP connections are possible even between VPCs whose CIDRs completely overlap, which cannot be connected with VPC peering or Transit Gateway.

When even the subnet CIDRs of the access source and destination overlap, the usable methods are running the GENEVE relay directly on the access source EC2 and going through HAProxy. The transparent NAT relay is limited to cases where the subnet CIDRs of the access source and destination differ.

In an environment with overlapping CIDRs, you cannot point the entire CIDR of the destination VPC to tun0 as in the previous article. In this article, I specified a /32 route for each destination host, and from an EC2 that has that route, you can no longer reach the same IP within the source VPC. Please take into account, when adopting this, that route management according to the number of destinations is required.

On the other hand, tunnel endpoints have constraints such as connections being able to be initiated only from the source side (see the previous article for details). With the method of running the relay directly on the access source EC2, the relay must be distributed to and run as root on each EC2, and with the method that uses a relay EC2, that EC2 can become a single point of failure or a performance bottleneck. Please evaluate requirements, availability, and performance thoroughly before using them.

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

Share this article

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