I tried PrivateLink tunnel endpoints between VPCs with completely overlapping CIDRs

I tried PrivateLink tunnel endpoints between VPCs with completely overlapping CIDRs

We confirmed HTTP connectivity between VPCs with the same CIDR (10.0.0.0/16) using AWS PrivateLink tunnel endpoints. We evaluated three methods — direct GENEVE relay launch, HAProxy, and NAT transparent relay — and organized guidelines for 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, we confirmed that HTTP communication is possible with an EC2 instance inside a VPC in another account via 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 VPCs can be attached, it cannot route to overlapping CIDR destinations distinguishing between VPCs.

To verify whether PrivateLink tunnel endpoints can enable communication between VPCs even in configurations with these constraints, we prepared VPCs with the same 10.0.0.0/16 CIDR on both sides and tested HTTP connectivity.

Common Configuration — GENEVE Relay

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

The GENEVE relay is a modified version of the script from the previous article, changed to route only the destination host's /32 to tun0 instead of the entire destination VPC CIDR. Since the source VPC also uses 10.0.0.0/16, routing /16 to tun0 as in the previous article would make communication within the source VPC unreachable. Since this article does not cover DNS resolution, the route for the resolver has also been removed. This relay is executed on the access source EC2 (Method 1) or an intermediary EC2 (Methods 2 and 3).

This script is for verification purposes. Run it as root, as it modifies the host's routing table. Connectivity is verified using curl. ICMP does not pass through the tunnel, so ping cannot be used for verification.

geneve_nat_relay.py (Common GENEVE relay for 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 — Directly Starting the GENEVE Relay on the Access Source

This configuration runs the GENEVE relay on the access source EC2 itself, without an intermediary EC2. The access source EC2 and the destination HTTP server both belong to 10.0.2.0/24. This is the most constrained case, with subnet CIDRs also overlapping.

Source VPC (10.0.0.0/16)
  Access source EC2 (10.0.2.30, prv-1a)


  Tunnel Endpoint ENI (10.0.2.174, 1a)


Destination VPC (10.0.0.0/16)
  Resource Gateway


  HTTP server (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

The 10.0.0.0/16 route in the EC2 OS routing table is left unchanged, and only 10.0.2.37/32 for the destination HTTP server is directed to tun0. Due to longest prefix matching, the /32 host route takes priority over /16, so the application can reach the HTTP server on the destination VPC side while still specifying 10.0.2.37. However, an EC2 with this /32 route will no longer be able to reach a host with the same IP 10.0.2.37 inside the source VPC.

Send an HTTP request from the access source EC2 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

HTTP connectivity was confirmed even between VPCs with completely overlapping CIDRs.

Method 2 — HAProxy L4 Proxy (with Destination Change)

This configuration runs HAProxy and the GENEVE relay on an intermediary EC2, and the application connects to HAProxy. With traditional PrivateLink, the NLB on the destination side distributes traffic to destination resources. In this method, HAProxy inside the source VPC handles that distribution, and communication from HAProxy is sent to the PrivateLink tunnel endpoint via the GENEVE relay on the same EC2. In this verification, the NAT instance from Method 3 was reused as the intermediary EC2.

Source VPC (10.0.0.0/16)
  Access source EC2 (10.0.2.30, prv-1a)


  NAT instance (10.0.0.7, pub-1a)


  Tunnel Endpoint ENI (10.0.2.174, 1a)


Destination VPC (10.0.0.0/16)
  Resource Gateway


  HTTP server (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 (relay-related portion)
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

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

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

A 200 OK was obtained from the HTTP server in the destination VPC via HAProxy. No GENEVE relay is required on the access source EC2.

Although not verified this time, there is a possibility that forward proxies such as Squid could also be used. In that case, proxy settings would be required on the client side.

Method 3 — Transparent NAT Relay

We verified whether packets from the access source subnet can be transparently forwarded to the tunnel endpoint using a NAT instance as a relay point. No GENEVE relay or proxy is deployed on the access source EC2; instead, forwarding is done via the VPC route table to the NAT instance.

This method can only be used when the CIDRs of the access source subnet and the destination subnet are different. In this verification, the access source EC2 was placed in the 10.0.3.0/24 subnet and the destination HTTP server in the 10.0.2.0/24 subnet. Since the two subnet CIDRs are different, a route for 10.0.2.0/24 pointing to the NAT instance's ENI can be set in the access source subnet's route table.

On the other hand, when the access source and destination appear to be in the same subnet CIDR, that communication does not go through the VPC route table and therefore cannot be directed to the NAT instance. A route pointing only the destination IP as /32 to the NAT ENI is also rejected by the CreateRoute API. Destinations that can be directed to an ENI in the VPC route table are limited to those matching subnet CIDRs within the VPC.

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

Source VPC (10.0.0.0/16)
  Access source EC2 (10.0.3.243, prv-1c)
    │  VPC route: 10.0.2.0/24 → NAT ENI

  NAT instance (10.0.0.7, pub-1a)


  Tunnel Endpoint ENI (10.0.2.174, 1a)


Destination VPC (10.0.0.0/16)
  Resource Gateway


  HTTP server (10.0.2.37, prv-1a)

Set a route in the access source subnet's route table directing 10.0.2.0/24, the destination subnet's CIDR, to the NAT instance's ENI. Disable source/destination check on the NAT instance and enable IP forwarding. The NAT instance's SG must specify the source using CIDR rather than SG ID reference. In the verification, leaving it as SG ID reference caused forwarded traffic not to match, resulting in no connectivity.

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

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

# Executed from: 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 obtained on the NAT instance, we confirmed that the SYN received pre-encapsulation on ens5 is GENEVE-encapsulated and sent to the tunnel endpoint, and on the return path, the decapsulated SYN-ACK is forwarded to the access source.

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 completed)

Although referred to as a NAT instance, no address translation (SNAT/DNAT) is performed in this path. The inner packet's source remains 10.0.3.243 and is GENEVE-encapsulated as-is.

From the above, we confirmed that the access source EC2 can forward packets to the tunnel endpoint via the NAT instance without changing the destination to 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 the source VPC normally, but only the forward path goes through the NAT instance, resulting in an asymmetric routing path.

Choosing Between Methods

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

Method Can be used even when access source and destination subnet CIDRs overlap? Conditions for selection
Direct GENEVE relay startup Yes The GENEVE relay can be executed on the access source EC2. The application's connection destination should not be changed
HAProxy L4 Yes The application's connection destination can be changed to HAProxy on an intermediary EC2
Transparent NAT relay No The access source and destination subnet CIDRs are different, and a VPC route to the destination subnet can be directed to the NAT ENI

Summary

Using PrivateLink tunnel endpoints, we confirmed that HTTP connectivity is possible even between VPCs with completely overlapping CIDRs, which cannot be connected via VPC peering or Transit Gateway.

When the subnet CIDRs of the access source and destination also overlap, the applicable methods are directly starting the GENEVE relay on the access source EC2 and routing via HAProxy. The transparent NAT relay is limited to cases where the access source and destination subnet CIDRs differ.

In environments with overlapping CIDRs, it is not possible to route the entire destination VPC CIDR to tun0 as in the previous article. This article specifies a /32 route for each destination host, and an EC2 with that route will no longer be able to reach the same IP within the source VPC. The need for route management scaled to the number of destinations is something to consider when adopting this approach.

On the other hand, tunnel endpoints have constraints such as connections can only be initiated from the source side (see the previous article for details). The method of directly starting the relay on the access source EC2 requires distributing the relay and running it as root on each EC2, while the method using an intermediary EC2 can make that EC2 a single point of failure or a performance bottleneck. Please thoroughly evaluate requirements, availability, and performance before use.

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

Share this article

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